From 990dba211885630bbf2318ac94ab6dee06712fc2 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:18:38 -0500 Subject: [PATCH 01/56] feat: sources --- package.json | 1 + src/app/api/chat/route.ts | 3 +- .../ai-elements/inline-citation.tsx | 340 ++++++++++++++++++ src/components/assistant-ui/markdown-text.tsx | 203 ++++++++--- src/components/assistant-ui/thread.tsx | 2 +- src/components/ui/carousel.tsx | 241 +++++++++++++ src/components/ui/hover-card.tsx | 4 +- src/components/ui/streamdown-markdown.tsx | 22 +- src/hooks/ai/use-citations-from-message.ts | 41 +++ src/lib/ai/citations/extract-from-inline.ts | 47 +++ src/lib/ai/citations/types.ts | 17 + src/lib/utils/format-workspace-context.ts | 20 ++ src/lib/utils/preprocess-latex.ts | 28 +- 13 files changed, 904 insertions(+), 65 deletions(-) create mode 100644 src/components/ai-elements/inline-citation.tsx create mode 100644 src/components/ui/carousel.tsx create mode 100644 src/hooks/ai/use-citations-from-message.ts create mode 100644 src/lib/ai/citations/extract-from-inline.ts create mode 100644 src/lib/ai/citations/types.ts diff --git a/package.json b/package.json index 01f088b3..8a5ef28a 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "cmdk": "1.1.1", "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", + "embla-carousel-react": "^8.6.0", "geist": "^1.7.0", "gsap": "^3.14.2", "input-otp": "1.4.2", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index bfe94d42..34b1b3d6 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,5 +1,5 @@ import { gateway } from "ai"; -import { streamText, convertToModelMessages, stepCountIs, wrapLanguageModel, tool } from "ai"; +import { streamText, smoothStream, convertToModelMessages, stepCountIs, wrapLanguageModel, tool } from "ai"; import { devToolsMiddleware } from "@ai-sdk/devtools"; import { PostHog } from "posthog-node"; import { withTracing } from "@posthog/ai"; @@ -298,6 +298,7 @@ export async function POST(req: Request) { stopWhen: stepCountIs(25), tools, providerOptions, + experimental_transform: smoothStream({ chunking: "word", delayInMs: 15 }), onFinish: ({ usage, finishReason }) => { const usageInfo = { inputTokens: usage?.inputTokens, diff --git a/src/components/ai-elements/inline-citation.tsx b/src/components/ai-elements/inline-citation.tsx new file mode 100644 index 00000000..ba367688 --- /dev/null +++ b/src/components/ai-elements/inline-citation.tsx @@ -0,0 +1,340 @@ +"use client"; + +import type { CarouselApi } from "@/components/ui/carousel"; +import type { ComponentProps, ReactNode } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { + Carousel, + CarouselContent, + CarouselItem, +} from "@/components/ui/carousel"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +export type InlineCitationProps = ComponentProps<"span">; + +export const InlineCitation = ({ + className, + ...props +}: InlineCitationProps) => ( + +); + +export type InlineCitationTextProps = ComponentProps<"span">; + +export const InlineCitationText = ({ + className, + ...props +}: InlineCitationTextProps) => ( + +); + +export type InlineCitationCardProps = ComponentProps; + +export const InlineCitationCard = (props: InlineCitationCardProps) => ( + +); + +export type InlineCitationCardTriggerProps = ComponentProps & { + sources: string[]; + /** Shown when sources is empty (e.g. during streaming) */ + fallbackLabel?: string; +}; + +function getBadgeLabel(sources: string[], fallbackLabel: string): ReactNode { + if (!sources[0]) return fallbackLabel; + try { + const url = new URL(sources[0]); + return ( + <> + {url.hostname}{" "} + {sources.length > 1 && `+${sources.length - 1}`} + + ); + } catch { + return fallbackLabel; + } +} + +export const InlineCitationCardTrigger = ({ + sources, + fallbackLabel = "unknown", + className, + ...props +}: InlineCitationCardTriggerProps) => ( + + + {getBadgeLabel(sources, fallbackLabel)} + + +); + +export type InlineCitationCardBodyProps = ComponentProps<"div">; + +export const InlineCitationCardBody = ({ + className, + ...props +}: InlineCitationCardBodyProps) => ( + +); + +const CarouselApiContext = createContext(undefined); + +const useCarouselApi = () => { + const context = useContext(CarouselApiContext); + return context; +}; + +export type InlineCitationCarouselProps = ComponentProps; + +export const InlineCitationCarousel = ({ + className, + children, + ...props +}: InlineCitationCarouselProps) => { + const [api, setApi] = useState(); + + return ( + + + {children} + + + ); +}; + +export type InlineCitationCarouselContentProps = ComponentProps<"div">; + +export const InlineCitationCarouselContent = ( + props: InlineCitationCarouselContentProps +) => ; + +export type InlineCitationCarouselItemProps = ComponentProps<"div">; + +export const InlineCitationCarouselItem = ({ + className, + ...props +}: InlineCitationCarouselItemProps) => ( + +); + +export type InlineCitationCarouselHeaderProps = ComponentProps<"div">; + +export const InlineCitationCarouselHeader = ({ + className, + ...props +}: InlineCitationCarouselHeaderProps) => ( +
+); + +export type InlineCitationCarouselIndexProps = ComponentProps<"div">; + +export const InlineCitationCarouselIndex = ({ + children, + className, + ...props +}: InlineCitationCarouselIndexProps) => { + const api = useCarouselApi(); + const [current, setCurrent] = useState(0); + const [count, setCount] = useState(0); + + useEffect(() => { + if (!api) { + return; + } + + setCount(api.scrollSnapList().length); + setCurrent(api.selectedScrollSnap() + 1); + + const handleSelect = () => { + setCurrent(api.selectedScrollSnap() + 1); + }; + + api.on("select", handleSelect); + + return () => { + api.off("select", handleSelect); + }; + }, [api]); + + return ( +
+ {children ?? `${current}/${count}`} +
+ ); +}; + +export type InlineCitationCarouselPrevProps = ComponentProps<"button">; + +export const InlineCitationCarouselPrev = ({ + className, + ...props +}: InlineCitationCarouselPrevProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollPrev(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationCarouselNextProps = ComponentProps<"button">; + +export const InlineCitationCarouselNext = ({ + className, + ...props +}: InlineCitationCarouselNextProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollNext(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationSourceProps = ComponentProps<"div"> & { + title?: string; + url?: string; + /** When set (and no url), makes content clickable (e.g. open workspace note) */ + onClick?: () => void; +}; + +export const InlineCitationSource = ({ + title, + url, + onClick, + className, + children, + ...props +}: InlineCitationSourceProps) => { + const content = ( + <> + {title && ( +

{title}

+ )} + {url && ( +

{url}

+ )} + {children} + + ); + + const clickable = url || onClick; + const baseClasses = cn( + "space-y-1 block w-full text-left", + clickable && "cursor-pointer transition-colors hover:bg-muted/50 rounded-md -m-1 p-1" + ); + + if (url) { + return ( + )} + > + {content} + + ); + } + if (onClick) { + return ( + + ); + } + return ( +
+ {content} +
+ ); +}; + +export type InlineCitationQuoteProps = ComponentProps<"blockquote">; + +export const InlineCitationQuote = ({ + children, + className, + ...props +}: InlineCitationQuoteProps) => ( +
+ {children} +
+); diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index 30b61ab6..f6a3554d 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -1,28 +1,167 @@ "use client"; -import { Streamdown, defaultRehypePlugins } from "streamdown"; +import { Streamdown } from "streamdown"; import "streamdown/styles.css"; import { createCodePlugin } from "@streamdown/code"; import { mermaid } from "@streamdown/mermaid"; import { createMathPlugin } from "@streamdown/math"; +import { useMessagePartText, useAuiState } from "@assistant-ui/react"; +import { + Children, + createContext, + isValidElement, + memo, + useRef, + useEffect, + useContext, + type ReactNode, +} from "react"; +import type { + AnchorHTMLAttributes, + ClipboardEvent as ReactClipboardEvent, + HTMLAttributes, +} from "react"; +import { + InlineCitation, + InlineCitationCard, + InlineCitationCardTrigger, + InlineCitationCardBody, + InlineCitationCarousel, + InlineCitationCarouselContent, + InlineCitationCarouselItem, + InlineCitationCarouselHeader, + InlineCitationCarouselIndex, + InlineCitationCarouselPrev, + InlineCitationCarouselNext, + InlineCitationSource, + InlineCitationQuote, +} from "@/components/ai-elements/inline-citation"; +import { MarkdownLink } from "@/components/ui/markdown-link"; +import { useCitationsFromMessage } from "@/hooks/ai/use-citations-from-message"; +import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; +import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useUIStore } from "@/lib/stores/ui-store"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import type { Citation } from "@/lib/ai/citations/types"; +import { preprocessLatex } from "@/lib/utils/preprocess-latex"; +import { cn } from "@/lib/utils"; const math = createMathPlugin({ singleDollarTextMath: true }); +const code = createCodePlugin({ themes: ["one-dark-pro", "one-dark-pro"] }); +const CitationContext = createContext([]); -// Create code plugin with one-dark-pro theme -const code = createCodePlugin({ - themes: ['one-dark-pro', 'one-dark-pro'], -}); -import { useMessagePartText } from "@assistant-ui/react"; -import { useAuiState } from "@assistant-ui/react"; -import { cn } from "@/lib/utils"; -import React, { memo, useRef, useEffect } from "react"; -import type { AnchorHTMLAttributes, HTMLAttributes } from "react"; -import { MarkdownLink } from "@/components/ui/markdown-link"; -import { preprocessLatex } from "@/lib/utils/preprocess-latex"; +/** Recursively extract all text from children (handles nested elements from markdown parsing). */ +function extractAllText(children: ReactNode): string { + if (typeof children === "string") return children; + if (children == null) return ""; + const arr = Children.toArray(children); + return arr + .map((child) => { + if (typeof child === "string") return child; + if (isValidElement(child)) { + const nested = (child.props as { children?: ReactNode }).children; + if (nested != null) return extractAllText(nested); + } + return ""; + }) + .join(""); +} + +/** Parse "N | quote" or legacy "N" from citation element content. */ +function parseCitationContent(children: ReactNode): { number: string; quote?: string } { + const text = extractAllText(children).trim(); + if (!text) return { number: "1" }; + // New format: "N | quote" — same source can have different quotes per use + const pipeIdx = text.indexOf(" | "); + if (pipeIdx !== -1) { + return { + number: text.slice(0, pipeIdx).trim() || "1", + quote: text.slice(pipeIdx + 3).trim() || undefined, + }; + } + // Legacy: just "N" + return { number: text }; +} + +const CitationRenderer = memo( + ({ children }: { children?: ReactNode }) => { + const citations = useContext(CitationContext); + const { number: idStr, quote: instanceQuote } = parseCitationContent(children); + const index = parseInt(idStr, 10); + const citation = !isNaN(index) && index >= 1 ? citations[index - 1] : null; + // Prefer instance-level quote (new format); fall back to source quote (legacy) + const quote = instanceQuote ?? citation?.quote; + const effectiveCitation = citation ?? { + number: idStr, + title: `Source ${idStr}`, + }; + + const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); + const { state: workspaceState } = useWorkspaceState(workspaceId); + const navigateToItem = useNavigateToItem(); + const setOpenModalItemId = useUIStore((s) => s.setOpenModalItemId); + + const titleNorm = (s: string) => s.trim().toLowerCase(); + const handleWorkspaceNoteClick = () => { + if (!workspaceState?.items || !effectiveCitation.title) return; + const item = workspaceState.items.find( + (i) => + i.type === "note" && + titleNorm(i.name) === titleNorm(effectiveCitation.title) + ); + if (item && navigateToItem(item.id, { silent: true })) { + setOpenModalItemId(item.id); + } + }; + + const hasWorkspaceNote = + !effectiveCitation.url && + workspaceState?.items?.some( + (i) => + i.type === "note" && + titleNorm(i.name) === titleNorm(effectiveCitation.title) + ); + + return ( + + + + + + + + + + + + + + {quote && {quote}} + + + + + + + + ); + } +); +CitationRenderer.displayName = "CitationRenderer"; const MarkdownTextImpl = () => { // Get the text content from assistant-ui context const { text } = useMessagePartText(); + const citations = useCitationsFromMessage(); // Get thread and message ID for unique key per message const threadId = useAuiState(({ threads }) => (threads as any)?.mainThreadId); @@ -69,7 +208,7 @@ const MarkdownTextImpl = () => { }, [threadId, messageId]); // Ensure copied content keeps rich HTML but strips background/highlight styles - const handleCopy = (event: React.ClipboardEvent) => { + const handleCopy = (event: ReactClipboardEvent) => { if (typeof window === "undefined") return; const selection = window.getSelection(); @@ -82,15 +221,10 @@ const MarkdownTextImpl = () => { const wrapper = document.createElement("div"); wrapper.appendChild(fragment); - // Strip background-related inline styles so no highlight color is preserved + // Strip background/highlight styles from copied HTML wrapper.querySelectorAll("*").forEach((el) => { - const style = el.style; - if (!style) return; - - // Remove background colors/highlights while leaving other styling intact - style.removeProperty("background"); - style.removeProperty("background-color"); - // Lines 88-89 already remove background/background-color, so this line can be deleted + el.style?.removeProperty("background"); + el.style?.removeProperty("background-color"); }); const html = wrapper.innerHTML; @@ -109,7 +243,9 @@ const MarkdownTextImpl = () => { return (
+ { "streamdown-content size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0" )} linkSafety={{ enabled: false }} - plugins={{ - code: code, - mermaid: mermaid, - math: math, - }} - rehypePlugins={[ - defaultRehypePlugins.raw, - defaultRehypePlugins.sanitize, - [ - // @ts-ignore - accessing internal harden plugin - defaultRehypePlugins.harden[0], - { - allowedLinkPrefixes: ["*"], - allowedImagePrefixes: ["*"], - allowedProtocols: ["*"], - allowDataImages: true, - } - ] - ]} + plugins={{ code, mermaid, math }} components={{ a: (props: AnchorHTMLAttributes & { node?: any }) => ( ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + citation: (props: any) => ( + {props.children} + ), ol: ({ children, node, ...props }: HTMLAttributes & { node?: any }) => (
    {children} @@ -159,6 +281,7 @@ const MarkdownTextImpl = () => { > {preprocessLatex(text)} +
); }; diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 5602b8f7..cafa29cd 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -989,7 +989,7 @@ const AssistantMessage: FC = () => { return (
diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 00000000..0e05a77e --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,241 @@ +"use client" + +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) return + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) return + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel() + + return ( +
+ ) +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/src/components/ui/hover-card.tsx b/src/components/ui/hover-card.tsx index e7541864..4ecc37ef 100644 --- a/src/components/ui/hover-card.tsx +++ b/src/components/ui/hover-card.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import * as HoverCardPrimitive from "@radix-ui/react-hover-card" +import { HoverCard as HoverCardPrimitive } from "radix-ui" import { cn } from "@/lib/utils" @@ -32,7 +32,7 @@ function HoverCardContent({ align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", + "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 z-50 origin-(--radix-hover-card-content-transform-origin) outline-hidden", className )} {...props} diff --git a/src/components/ui/streamdown-markdown.tsx b/src/components/ui/streamdown-markdown.tsx index 0f65bd69..930f9ae3 100644 --- a/src/components/ui/streamdown-markdown.tsx +++ b/src/components/ui/streamdown-markdown.tsx @@ -1,6 +1,6 @@ "use client"; -import { Streamdown, defaultRehypePlugins } from "streamdown"; +import { Streamdown } from "streamdown"; import { createCodePlugin } from "@streamdown/code"; import { mermaid } from "@streamdown/mermaid"; import { createMathPlugin } from "@streamdown/math"; @@ -36,25 +36,7 @@ const StreamdownMarkdownImpl: React.FC = ({ caret="block" className="streamdown-content size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0" linkSafety={{ enabled: false }} - plugins={{ - code: code, - mermaid: mermaid, - math: math, - }} - rehypePlugins={[ - defaultRehypePlugins.raw, - defaultRehypePlugins.sanitize, - [ - // @ts-ignore - accessing internal harden plugin - defaultRehypePlugins.harden[0], - { - allowedLinkPrefixes: ["*"], - allowedImagePrefixes: ["*"], - allowedProtocols: ["*"], - allowDataImages: true, - } - ] - ]} + plugins={{ code, mermaid, math }} components={{ a: (props: AnchorHTMLAttributes & { node?: any }) => ( diff --git a/src/hooks/ai/use-citations-from-message.ts b/src/hooks/ai/use-citations-from-message.ts new file mode 100644 index 00000000..9492409b --- /dev/null +++ b/src/hooks/ai/use-citations-from-message.ts @@ -0,0 +1,41 @@ +"use client"; + +import { useMessage, useAuiState } from "@assistant-ui/react"; +import { useMemo } from "react"; +import type { Citation } from "@/lib/ai/citations/types"; +import { extractCitationsFromInlineData } from "@/lib/ai/citations/extract-from-inline"; + +/** Extracts citations from the model-generated ... block in message text. */ +const CITATION_EXTRACTORS = [extractCitationsFromInlineData]; + +/** + * Extracts citations from the current message. + * Citations come from the optional model-generated block appended to the message. + * Returns a stable array; citations are 1-indexed by number. + */ +export function useCitationsFromMessage(): Citation[] { + const message = useMessage(); + const messages = useAuiState((s) => (s.thread as { messages?: unknown[] } | undefined)?.messages ?? []); + const thread = useMemo(() => ({ messages }), [messages]); + + return useMemo(() => { + const msg = { + id: (message as { id?: string }).id, + role: (message as { role?: string }).role, + content: (message as { content?: unknown[] }).content, + }; + + // Extract from model-generated block + for (const extract of CITATION_EXTRACTORS) { + const citations = extract(msg, thread); + if (citations.length > 0) { + return citations.map((c, i) => ({ + ...c, + number: String(i + 1), + })); + } + } + + return []; + }, [message, thread]); +} diff --git a/src/lib/ai/citations/extract-from-inline.ts b/src/lib/ai/citations/extract-from-inline.ts new file mode 100644 index 00000000..db36f49e --- /dev/null +++ b/src/lib/ai/citations/extract-from-inline.ts @@ -0,0 +1,47 @@ +import type { Citation } from "./types"; + +const CITATIONS_BLOCK_REGEX = /([\s\S]*?)<\/citations>/i; + +/** + * Parses the optional model-generated ... block from message text. + * The model outputs sources at the beginning (no quotes — quotes are per-instance in inline elements). + */ +export function extractCitationsFromInlineData( + message: { id?: string; role?: string; content?: unknown[] }, + _thread?: { messages?: unknown[] } +): Citation[] { + const content = message?.content; + if (!Array.isArray(content)) return []; + + const textParts = content + .filter((p): p is { type: string; text?: string } => (p as any)?.type === "text" && typeof (p as any).text === "string") + .map((p) => (p as { text: string }).text); + + const fullText = textParts.join("\n"); + const match = fullText.match(CITATIONS_BLOCK_REGEX); + if (!match?.[1]) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(match[1].trim()); + } catch { + return []; + } + + if (!Array.isArray(parsed)) return []; + + const citations: Citation[] = []; + for (const item of parsed) { + const c = item as Record; + const number = String(c?.number ?? ""); + const title = String(c?.title ?? "Source"); + const url = typeof c?.url === "string" ? c.url : undefined; + const quote = typeof c?.quote === "string" ? c.quote : undefined; + + if (number) { + citations.push({ number, title, url, quote }); + } + } + + return citations; +} diff --git a/src/lib/ai/citations/types.ts b/src/lib/ai/citations/types.ts new file mode 100644 index 00000000..cf7d3849 --- /dev/null +++ b/src/lib/ai/citations/types.ts @@ -0,0 +1,17 @@ +/** + * Generic citation type for inline citations. + * Source-agnostic: can represent web search, URL context, workspace content, etc. + * Quote is per-instance (in inline element), not per-source. + */ +export type Citation = { + number: string; // "1", "2", ... (1-based index for display) + title: string; + url?: string; // optional — workspace items may not have URLs + quote?: string; // optional at source level; use instance quote from inline element +}; + +/** Extractor function: takes a message (and optional thread) and returns citations */ +export type CitationExtractor = ( + message: { id?: string; role?: string; content?: unknown[] }, + thread?: { messages?: unknown[] } +) => Citation[]; diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 84b1bfee..cd54022d 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -62,6 +62,26 @@ Rules: - Use chunk.web.uri exactly as provided (even redirect URLs) - Never make up or hallucinate URLs - Include article dates in responses when available + +INLINE CITATIONS (optional): +Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation carries its own quote so the same source can be cited multiple times with different excerpts. + +Sources block (no quotes — quotes go in each inline use): +[{"number":"1","title":"Source title","url":"https://example.com"}] + +For workspace items: omit url. Example: +[{"number":"1","title":"My Calculus Notes"}] + +Inline format — each use has its own quote: N | exact excerpt for this point +Use a pipe with spaces to separate the source number from the quote. + +CRITICAL — Punctuation placement: End the sentence/clause with the period or comma BEFORE the citation. The citation always comes after the punctuation, never before it. +Correct: "...flow of goods and services." 1 | comprehensive administration of the flow +Correct: "demand forecasting, supply planning." 1 | demand forecasting, supply planning +Wrong: "...flow of goods and services" 1 | .... (do NOT put the period after the citation) +- Sources block: number, title, url (for web) or omit (workspace) +- Inline: N | quote — quote is required per use +Omit the block entirely if you have no citations. You may invent credible source metadata when not from a tool. diff --git a/src/lib/utils/preprocess-latex.ts b/src/lib/utils/preprocess-latex.ts index 88da7901..5ffbf1a3 100644 --- a/src/lib/utils/preprocess-latex.ts +++ b/src/lib/utils/preprocess-latex.ts @@ -2,12 +2,35 @@ * Preprocesses markdown content to normalize LaTeX delimiters for Streamdown/remark-math. * * Handles: + * 0. Strips optional model-generated ... block (for display only; extracted separately) * 1. Protects currency values ($19.99, $5, $1,000) from being parsed as math * 2. Converts \(...\) → $...$ and \[...\] → $$...$$ (remark-math doesn't support these) * * Preserves code blocks (``` and inline `) so their contents are never modified. */ +// Match complete citations block at start (model generates sources first so they're ready during streaming) +const CITATIONS_BLOCK_AT_START_REGEX = /^\s*[\s\S]*?<\/citations>\s*/i; +// Fallback: match complete block at end if model still outputs there (backwards compatible) +const CITATIONS_BLOCK_AT_END_REGEX = /[\s\S]*?<\/citations>\s*$/i; +// During streaming: incomplete block at start (... without ) — hide from to end so user never sees raw JSON +const CITATIONS_BLOCK_INCOMPLETE_AT_START_REGEX = /^\s*[\s\S]*$/i; + +/** Strips the optional ... block from markdown so it doesn't render. Also hides in-progress block during streaming. */ +export function stripCitationsBlock(markdown: string): string { + if (!markdown) return markdown; + let out = markdown; + // 1. Strip complete block at start (or in-progress block at start — hide raw JSON during streaming) + if (CITATIONS_BLOCK_AT_START_REGEX.test(out)) { + out = out.replace(CITATIONS_BLOCK_AT_START_REGEX, "").trimStart(); + } else if (CITATIONS_BLOCK_INCOMPLETE_AT_START_REGEX.test(out)) { + out = out.replace(CITATIONS_BLOCK_INCOMPLETE_AT_START_REGEX, "").trimStart(); + } + // 2. Strip complete block at end + out = out.replace(CITATIONS_BLOCK_AT_END_REGEX, "").trimEnd(); + return out; +} + // Currency pattern: $ followed by digits, optional commas/decimals — e.g. $5, $19.99, $1,000.50 // Must NOT be preceded by another $ (to avoid matching inside $$...$$) const CURRENCY_REGEX = /(? { + let result = markdown.replace(/```[\s\S]*?```|`[^`\n]+`/g, (match: string) => { preserved.push(match); return `\x00CODE${preserved.length - 1}\x00`; }); From 45b9c05e9c22647ee2c56a55d47ad3db1c67fb75 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:55:28 -0500 Subject: [PATCH 02/56] fix: sources --- package.json | 1 + src/app/globals.css | 14 ++ .../ai-elements/inline-citation.tsx | 195 ++---------------- src/components/assistant-ui/markdown-text.tsx | 54 ++--- src/components/editor/BlockNoteEditor.tsx | 87 ++++++++ .../editor/search-highlight-extension.ts | 19 ++ src/components/pdf/AppPdfViewer.tsx | 79 ++++++- .../workspace-canvas/ItemPanelContent.tsx | 1 + src/lib/ai/tools/process-files.ts | 63 ++++-- src/lib/stores/ui-store.ts | 10 + src/lib/utils/format-workspace-context.ts | 17 +- 11 files changed, 306 insertions(+), 234 deletions(-) create mode 100644 src/components/editor/search-highlight-extension.ts diff --git a/package.json b/package.json index 8a5ef28a..9c82e973 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "posthog-js": "^1.345.0", "posthog-node": "^5.24.14", "prosemirror-highlight": "^0.13.0", + "prosemirror-search": "^1.1.0", "radix-ui": "^1.4.3", "react": "19.2.4", "react-color": "^2.19.3", diff --git a/src/app/globals.css b/src/app/globals.css index 9b8c8253..8e9bc650 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -208,6 +208,20 @@ overflow-wrap: break-word !important; } +/* Prosemirror-search citation highlight - theme-aware */ +.ProseMirror-search-match { + background-color: rgba(234, 179, 8, 0.35); +} +.ProseMirror-active-search-match { + background-color: rgba(234, 179, 8, 0.55); +} +.dark .ProseMirror-search-match { + background-color: rgba(251, 191, 36, 0.35); +} +.dark .ProseMirror-active-search-match { + background-color: rgba(251, 191, 36, 0.5); +} + /* Fix BlockNote formatting toolbar display - compact version */ .bn-formatting-toolbar { min-height: 1.75rem !important; diff --git a/src/components/ai-elements/inline-citation.tsx b/src/components/ai-elements/inline-citation.tsx index ba367688..5a58924c 100644 --- a/src/components/ai-elements/inline-citation.tsx +++ b/src/components/ai-elements/inline-citation.tsx @@ -1,28 +1,14 @@ "use client"; -import type { CarouselApi } from "@/components/ui/carousel"; import type { ComponentProps, ReactNode } from "react"; import { Badge } from "@/components/ui/badge"; import { - Carousel, - CarouselContent, - CarouselItem, -} from "@/components/ui/carousel"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { cn } from "@/lib/utils"; -import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from "react"; export type InlineCitationProps = ComponentProps<"span">; @@ -31,7 +17,7 @@ export const InlineCitation = ({ ...props }: InlineCitationProps) => ( ); @@ -48,10 +34,10 @@ export const InlineCitationText = ({ /> ); -export type InlineCitationCardProps = ComponentProps; +export type InlineCitationCardProps = ComponentProps; export const InlineCitationCard = (props: InlineCitationCardProps) => ( - + ); export type InlineCitationCardTriggerProps = ComponentProps & { @@ -81,15 +67,15 @@ export const InlineCitationCardTrigger = ({ className, ...props }: InlineCitationCardTriggerProps) => ( - + {getBadgeLabel(sources, fallbackLabel)} - + ); export type InlineCitationCardBodyProps = ComponentProps<"div">; @@ -98,164 +84,9 @@ export const InlineCitationCardBody = ({ className, ...props }: InlineCitationCardBodyProps) => ( - + ); -const CarouselApiContext = createContext(undefined); - -const useCarouselApi = () => { - const context = useContext(CarouselApiContext); - return context; -}; - -export type InlineCitationCarouselProps = ComponentProps; - -export const InlineCitationCarousel = ({ - className, - children, - ...props -}: InlineCitationCarouselProps) => { - const [api, setApi] = useState(); - - return ( - - - {children} - - - ); -}; - -export type InlineCitationCarouselContentProps = ComponentProps<"div">; - -export const InlineCitationCarouselContent = ( - props: InlineCitationCarouselContentProps -) => ; - -export type InlineCitationCarouselItemProps = ComponentProps<"div">; - -export const InlineCitationCarouselItem = ({ - className, - ...props -}: InlineCitationCarouselItemProps) => ( - -); - -export type InlineCitationCarouselHeaderProps = ComponentProps<"div">; - -export const InlineCitationCarouselHeader = ({ - className, - ...props -}: InlineCitationCarouselHeaderProps) => ( -
-); - -export type InlineCitationCarouselIndexProps = ComponentProps<"div">; - -export const InlineCitationCarouselIndex = ({ - children, - className, - ...props -}: InlineCitationCarouselIndexProps) => { - const api = useCarouselApi(); - const [current, setCurrent] = useState(0); - const [count, setCount] = useState(0); - - useEffect(() => { - if (!api) { - return; - } - - setCount(api.scrollSnapList().length); - setCurrent(api.selectedScrollSnap() + 1); - - const handleSelect = () => { - setCurrent(api.selectedScrollSnap() + 1); - }; - - api.on("select", handleSelect); - - return () => { - api.off("select", handleSelect); - }; - }, [api]); - - return ( -
- {children ?? `${current}/${count}`} -
- ); -}; - -export type InlineCitationCarouselPrevProps = ComponentProps<"button">; - -export const InlineCitationCarouselPrev = ({ - className, - ...props -}: InlineCitationCarouselPrevProps) => { - const api = useCarouselApi(); - - const handleClick = useCallback(() => { - if (api) { - api.scrollPrev(); - } - }, [api]); - - return ( - - ); -}; - -export type InlineCitationCarouselNextProps = ComponentProps<"button">; - -export const InlineCitationCarouselNext = ({ - className, - ...props -}: InlineCitationCarouselNextProps) => { - const api = useCarouselApi(); - - const handleClick = useCallback(() => { - if (api) { - api.scrollNext(); - } - }, [api]); - - return ( - - ); -}; - export type InlineCitationSourceProps = ComponentProps<"div"> & { title?: string; url?: string; @@ -285,8 +116,8 @@ export const InlineCitationSource = ({ const clickable = url || onClick; const baseClasses = cn( - "space-y-1 block w-full text-left", - clickable && "cursor-pointer transition-colors hover:bg-muted/50 rounded-md -m-1 p-1" + "space-y-1 block w-full text-left min-h-0", + clickable && "cursor-pointer transition-colors hover:bg-muted/50 rounded-md" ); if (url) { diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index f6a3554d..db3bc9cf 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -26,13 +26,6 @@ import { InlineCitationCard, InlineCitationCardTrigger, InlineCitationCardBody, - InlineCitationCarousel, - InlineCitationCarouselContent, - InlineCitationCarouselItem, - InlineCitationCarouselHeader, - InlineCitationCarouselIndex, - InlineCitationCarouselPrev, - InlineCitationCarouselNext, InlineCitationSource, InlineCitationQuote, } from "@/components/ai-elements/inline-citation"; @@ -102,23 +95,28 @@ const CitationRenderer = memo( const setOpenModalItemId = useUIStore((s) => s.setOpenModalItemId); const titleNorm = (s: string) => s.trim().toLowerCase(); - const handleWorkspaceNoteClick = () => { + const setCitationHighlightQuery = useUIStore((s) => s.setCitationHighlightQuery); + const handleWorkspaceItemClick = () => { if (!workspaceState?.items || !effectiveCitation.title) return; const item = workspaceState.items.find( (i) => - i.type === "note" && + (i.type === "note" || i.type === "pdf") && titleNorm(i.name) === titleNorm(effectiveCitation.title) ); - if (item && navigateToItem(item.id, { silent: true })) { - setOpenModalItemId(item.id); + if (!item) return; + // Set highlight query first (works when item is already open) + if (quote?.trim()) { + setCitationHighlightQuery({ itemId: item.id, query: quote.trim() }); } + navigateToItem(item.id, { silent: true }); + setOpenModalItemId(item.id); }; - const hasWorkspaceNote = + const hasWorkspaceItem = !effectiveCitation.url && workspaceState?.items?.some( (i) => - i.type === "note" && + (i.type === "note" || i.type === "pdf") && titleNorm(i.name) === titleNorm(effectiveCitation.title) ); @@ -130,26 +128,16 @@ const CitationRenderer = memo( fallbackLabel={idStr} /> - - - - - - - - - - {quote && {quote}} - - - - + + {quote && {quote}} + diff --git a/src/components/editor/BlockNoteEditor.tsx b/src/components/editor/BlockNoteEditor.tsx index 99fceaf5..532294c5 100644 --- a/src/components/editor/BlockNoteEditor.tsx +++ b/src/components/editor/BlockNoteEditor.tsx @@ -13,6 +13,8 @@ import { schema } from "./schema"; import { uploadFile } from "@/lib/editor/upload-file"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useUIStore } from "@/lib/stores/ui-store"; +import { searchHighlightExtension } from "./search-highlight-extension"; +import { SearchQuery, setSearchState } from "prosemirror-search"; import { extractTextFromSelection } from "@/lib/utils/extract-blocknote-text"; import { MathEditProvider } from "./MathEditDialog"; import { useTheme } from "next-themes"; @@ -116,6 +118,7 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca uploadFile: blockNoteUploadFile, dictionary: en, autofocus: autofocus ? (typeof autofocus === "boolean" ? "start" : autofocus) : false, + extensions: cardId && !readOnly ? [searchHighlightExtension] : [], }); useEffect(() => { @@ -235,6 +238,90 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca }; }, [editor, readOnly, cardId, cardName, setBlockNoteSelection, clearBlockNoteSelection]); + // Sync citationHighlightQuery from store to prosemirror-search plugin (citation highlight) + useEffect(() => { + if (!cardId || readOnly) return; + + const HIGHLIGHT_DURATION_MS = 2500; + const setCitationHighlightQuery = useUIStore.getState().setCitationHighlightQuery; + + const applyHighlight = (query: string) => { + try { + const view = editor.prosemirrorView; + if (!view) return; + + const { state } = view; + const docSize = state.doc.content.size; + + const searchQuery = new SearchQuery({ + search: query, + literal: true, // treat as plain text, escape regex chars + caseSensitive: false, + }); + if (!searchQuery.valid) return; + + const tr = state.tr; + setSearchState(tr, searchQuery, { from: 0, to: docSize }); + view.dispatch(tr); + + // Scroll first match into view after DOM updates + const result = searchQuery.findNext(state, 0, docSize); + if (result) { + requestAnimationFrame(() => { + try { + const { node } = view.domAtPos(result.from); + const el = (node as Node).nodeType === 3 ? (node as Text).parentElement : (node as HTMLElement); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + } catch { + // ignore + } + }); + } + + // Clear highlight after a few seconds + const timeoutId = setTimeout(() => { + try { + const v = editor.prosemirrorView; + if (!v) return; + const s = v.state; + const clearQuery = new SearchQuery({ search: "\0", literal: true }); + const clearTr = s.tr; + setSearchState(clearTr, clearQuery, null); + v.dispatch(clearTr); + setCitationHighlightQuery(null); + } catch { + // ignore + } + }, HIGHLIGHT_DURATION_MS); + + return () => clearTimeout(timeoutId); + } catch (e) { + console.warn("[BlockNoteEditor] Citation highlight apply failed:", e); + } + }; + + let clearTimeoutFn: (() => void) | undefined; + + const unsub = useUIStore.subscribe((state) => { + const hl = state.citationHighlightQuery; + if (!hl || hl.itemId !== cardId) return; + if (!hl.query?.trim()) return; + clearTimeoutFn?.(); + clearTimeoutFn = applyHighlight(hl.query.trim()); + }); + + // Apply immediately if we already have a matching query (e.g. note already open) + const hl = useUIStore.getState().citationHighlightQuery; + if (hl?.itemId === cardId && hl.query?.trim()) { + clearTimeoutFn = applyHighlight(hl.query.trim()); + } + + return () => { + unsub(); + clearTimeoutFn?.(); + }; + }, [editor, cardId, readOnly]); + // Sync content ONLY when updated by AGENT (prevents cursor jumping on user input) useEffect(() => { // Only proceed if this is an update triggered by the agent diff --git a/src/components/editor/search-highlight-extension.ts b/src/components/editor/search-highlight-extension.ts new file mode 100644 index 00000000..f2dda6b4 --- /dev/null +++ b/src/components/editor/search-highlight-extension.ts @@ -0,0 +1,19 @@ +"use client"; + +import { createExtension } from "@blocknote/core"; +import { search } from "prosemirror-search"; +import "prosemirror-search/style/search.css"; + +/** + * BlockNote extension that adds prosemirror-search plugin for citation highlight. + * The plugin shows decoration-based highlights; the actual query is set via + * setSearchState() dispatched from BlockNoteEditor when citationHighlightQuery changes. + */ +export const searchHighlightExtension = createExtension({ + key: "searchHighlight", + prosemirrorPlugins: [ + search({ + // No initial query; BlockNoteEditor syncs from citationHighlightQuery store + }), + ], +}); diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx index 9018642e..68c568dd 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -209,6 +209,8 @@ interface Props { /** Render prop for custom header - receives documentId for PDF plugin hooks */ renderHeader?: (documentId: string, annotationControls?: { showAnnotations: boolean, toggleAnnotations: () => void }) => ReactNode; itemName?: string; + /** Workspace item ID - for citation highlight sync */ + itemId?: string; isMaximized?: boolean; /** Initial visibility of the annotation toolbar */ initialShowAnnotations?: boolean; @@ -691,7 +693,79 @@ const PdfSearchBar = ({ documentId }: { documentId: string }) => { ); }; -const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName, isMaximized, initialShowAnnotations = false }: Props) => { +const PDF_HIGHLIGHT_DURATION_MS = 2500; + +/** Syncs citation highlight query from store: search PDF and scroll to first match */ +const PdfCitationHighlightSync = ({ documentId, itemId }: { documentId: string; itemId?: string }) => { + const { state, provides } = useSearch(documentId); + const { provides: scrollCapability } = useScrollCapability(); + const scroll = scrollCapability?.forDocument(documentId); + const triggeredRef = useRef(null); + const timeoutRef = useRef | undefined>(undefined); + + useEffect(() => { + if (!itemId) return; + + const applyHighlight = (query: string) => { + if (!provides || !query?.trim()) return; + triggeredRef.current = query; + provides.searchAllPages(query); + + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + provides.stopSearch(); + useUIStore.getState().setCitationHighlightQuery(null); + triggeredRef.current = null; + }, PDF_HIGHLIGHT_DURATION_MS); + }; + + const unsub = useUIStore.subscribe((storeState) => { + const hl = storeState.citationHighlightQuery; + if (!hl || hl.itemId !== itemId || !hl.query?.trim()) return; + applyHighlight(hl.query.trim()); + }); + + const hl = useUIStore.getState().citationHighlightQuery; + if (hl?.itemId === itemId && hl.query?.trim()) { + applyHighlight(hl.query.trim()); + } + + return () => { + unsub(); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [documentId, itemId, provides]); + + // Scroll to first result when search completes + useEffect(() => { + if (!scroll || !triggeredRef.current || state.loading) return; + const results = state.results; + if (!results?.length) return; + + const idx = state.activeResultIndex ?? 0; + const item = results[idx]; + if (!item) return; + + const minCoordinates = item.rects.reduce( + (min, rect) => ({ + x: Math.min(min.x, rect.origin.x), + y: Math.min(min.y, rect.origin.y), + }), + { x: Infinity, y: Infinity } + ); + + scroll.scrollToPage({ + pageNumber: item.pageIndex + 1, + pageCoordinates: minCoordinates, + alignX: 50, + alignY: 50, + }); + }, [scroll, state.results, state.activeResultIndex, state.loading]); + + return null; +}; + +const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName, itemId, isMaximized, initialShowAnnotations = false }: Props) => { // Use the shared Pdfium engine from context const { engine, isLoading } = useEngineContext(); @@ -859,6 +933,9 @@ const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName, {/* Viewport */}
+ {itemId && ( + + )} (
diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts index 29421ed4..327bc721 100644 --- a/src/lib/ai/tools/process-files.ts +++ b/src/lib/ai/tools/process-files.ts @@ -37,6 +37,53 @@ function getMediaTypeFromUrl(url: string): string { return 'application/octet-stream'; } +const IMAGE_MEDIA_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', +]; + +function isPdf(mediaType: string): boolean { + return mediaType === 'application/pdf'; +} + +function isImage(mediaType: string): boolean { + return IMAGE_MEDIA_TYPES.includes(mediaType); +} + +function buildFileProcessingPrompt( + fileInfos: Array<{ filename: string; mediaType: string }> +): { defaultInstruction: string; outputFormat: string } { + const hasPdfs = fileInfos.some((f) => isPdf(f.mediaType)); + const hasImages = fileInfos.some((f) => isImage(f.mediaType)); + const hasOther = fileInfos.some((f) => !isPdf(f.mediaType) && !isImage(f.mediaType)); + + const parts: string[] = []; + if (hasPdfs) { + parts.push( + 'For PDFs: Extract the exact textual content in markdown format. Preserve layout: headings (# ## ###), bullet/numbered lists, tables, paragraphs, and structure. Include all text verbatim where possible.' + ); + } + if (hasImages) { + parts.push('For images: Provide a brief summary of what the image shows, its subject, and any notable details.'); + } + if (hasOther) { + parts.push( + 'For other files (documents, audio, video): Extract or summarize the main content, key points, and important information.' + ); + } + + const defaultInstruction = parts.join('\n\n'); + + const outputFormat = `Format each file's output as: +**filename.ext:** +[Content — for PDFs use markdown with preserved layout; for images use a short summary]`; + + return { defaultInstruction, outputFormat }; +} + /** * Extract filename from local file URL */ @@ -97,15 +144,11 @@ async function processLocalFiles( } const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename}`).join('\n'); - - const outputFormat = `Format each file's analysis as: -**filename.ext:** -- Summary: [1-2 sentences] -- Key points: [bullet list]`; + const { defaultInstruction, outputFormat } = buildFileProcessingPrompt(fileInfos); const batchPrompt = instruction ? `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${instruction}\n\n${outputFormat}` - : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\nFor each file, extract and summarize: main topics, key information, important facts or insights, and any structured data.\n\n${outputFormat}`; + : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`; const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [ { type: "text", text: batchPrompt }, @@ -145,15 +188,11 @@ async function processSupabaseFiles( }); const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename}`).join('\n'); - - const outputFormat = `Format each file's analysis as: -**filename.ext:** -- Summary: [1-2 sentences] -- Key points: [bullet list]`; + const { defaultInstruction, outputFormat } = buildFileProcessingPrompt(fileInfos); const batchPrompt = instruction ? `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${instruction}\n\n${outputFormat}` - : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\nFor each file, extract and summarize: main topics, key information, important facts or insights, and any structured data.\n\n${outputFormat}`; + : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`; const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [ { type: "text", text: batchPrompt }, diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index 8e2f35fe..ce293577 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -56,6 +56,8 @@ interface UIState { // BlockNote text selection state blockNoteSelection: { cardId: string; cardName: string; text: string } | null; + // Citation highlight: when opening note/PDF from citation click, highlight/search this quote + citationHighlightQuery: { itemId: string; query: string } | null; // Actions - Chat setIsChatExpanded: (expanded: boolean) => void; @@ -128,6 +130,7 @@ interface UIState { // Actions - BlockNote selection setBlockNoteSelection: (selection: { cardId: string; cardName: string; text: string } | null) => void; clearBlockNoteSelection: () => void; + setCitationHighlightQuery: (query: { itemId: string; query: string } | null) => void; // Utility actions resetChatState: () => void; @@ -180,6 +183,7 @@ const initialState = { // BlockNote selection blockNoteSelection: null, + citationHighlightQuery: null, }; export const useUIStore = create()( @@ -350,6 +354,7 @@ export const useUIStore = create()( maximizedItemId: null, selectedCardIds: newSelectedCardIds, panelAutoSelectedCardIds: newPanelAutoSelectedCardIds, + citationHighlightQuery: null, }; } @@ -376,6 +381,7 @@ export const useUIStore = create()( maximizedItemId: null, selectedCardIds: newSelectedCardIds, panelAutoSelectedCardIds: new Set(), + citationHighlightQuery: null, }; }); }, @@ -412,6 +418,7 @@ export const useUIStore = create()( maximizedItemId: null, selectedCardIds: newSelectedCardIds, panelAutoSelectedCardIds: new Set(), + citationHighlightQuery: null, }; } else { const isAlreadyOpen = state.openPanelIds.length === 1 && state.openPanelIds[0] === id && state.maximizedItemId === id; @@ -542,6 +549,9 @@ export const useUIStore = create()( clearBlockNoteSelection: () => { set({ blockNoteSelection: null }); }, + setCitationHighlightQuery: (query) => { + set({ citationHighlightQuery: query }); + }, // Utility actions diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index cd54022d..2150b2c4 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -64,7 +64,7 @@ Rules: - Include article dates in responses when available INLINE CITATIONS (optional): -Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation carries its own quote so the same source can be cited multiple times with different excerpts. +Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation may optionally include a quote; omit the quote if you do not have the exact text from the source. Sources block (no quotes — quotes go in each inline use): [{"number":"1","title":"Source title","url":"https://example.com"}] @@ -72,15 +72,20 @@ Sources block (no quotes — quotes go in each inline use): For workspace items: omit url. Example: [{"number":"1","title":"My Calculus Notes"}] -Inline format — each use has its own quote: N | exact excerpt for this point -Use a pipe with spaces to separate the source number from the quote. +Inline format: N or N | exact excerpt +- Without quote: 1 — use when you cannot cite an exact excerpt. +- With quote: 1 | exact excerpt from source — use only when you have the exact text from the source. Use pipe with spaces to separate. + +NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt from the source (tool response, context, or workspace content). If unsure or you do not have the exact text, use N without a quote. Never fabricate or paraphrase quotes. + +When quoting: Use ONLY plain text — no math ($$...$$), code blocks, or special formatting. Use surrounding prose or omit the quote instead. CRITICAL — Punctuation placement: End the sentence/clause with the period or comma BEFORE the citation. The citation always comes after the punctuation, never before it. Correct: "...flow of goods and services." 1 | comprehensive administration of the flow -Correct: "demand forecasting, supply planning." 1 | demand forecasting, supply planning -Wrong: "...flow of goods and services" 1 | .... (do NOT put the period after the citation) +Correct: "demand forecasting." 1 +Wrong: "...flow of goods and services" 1. (do NOT put the period after the citation) - Sources block: number, title, url (for web) or omit (workspace) -- Inline: N | quote — quote is required per use +- Inline: N (no quote) or N | quote (quote optional; only when you have exact text) Omit the block entirely if you have no citations. You may invent credible source metadata when not from a tool. From 644c1eafd3d3f3f7f6d4108cf0d59dd1c7da66d4 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:47:24 -0500 Subject: [PATCH 03/56] feat: iteration 1 --- src/app/api/share/[id]/route.ts | 3 +- src/app/api/workspaces/[id]/route.ts | 3 +- src/app/api/workspaces/route.ts | 5 -- src/app/api/workspaces/slug/[slug]/route.ts | 3 +- src/app/dashboard/page.tsx | 3 +- src/app/share-copy/[id]/layout.tsx | 2 +- src/components/workspace/SidebarCardList.tsx | 2 +- .../workspace/use-workspace-operations.ts | 11 --- src/lib/utils/format-workspace-context.ts | 69 +++++++++++++++++-- src/lib/utils/virtual-workspace-fs.ts | 47 +++++++++++++ src/lib/workspace-state/state.ts | 2 - src/lib/workspace-state/types.ts | 4 +- src/lib/workspace/clone-demo.ts | 1 - src/lib/workspace/event-reducer.ts | 41 ++++++----- src/lib/workspace/import-validation.ts | 4 +- src/lib/workspace/state-loader.ts | 2 - src/lib/workspace/templates.ts | 6 -- 17 files changed, 145 insertions(+), 63 deletions(-) create mode 100644 src/lib/utils/virtual-workspace-fs.ts diff --git a/src/app/api/share/[id]/route.ts b/src/app/api/share/[id]/route.ts index b1381263..cf05c2b0 100644 --- a/src/app/api/share/[id]/route.ts +++ b/src/app/api/share/[id]/route.ts @@ -30,9 +30,8 @@ export async function GET( const state = await loadWorkspaceState(id); // Ensure state has workspace metadata if empty - if (!state.globalTitle && !state.globalDescription) { + if (!state.globalTitle) { state.globalTitle = workspace[0].name || ""; - state.globalDescription = workspace[0].description || ""; } // Return workspace data for forking (public access) diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts index 2453bce4..6e8f48c6 100644 --- a/src/app/api/workspaces/[id]/route.ts +++ b/src/app/api/workspaces/[id]/route.ts @@ -39,9 +39,8 @@ async function handleGET( const state = await loadWorkspaceState(id); // Ensure state has workspace metadata if empty - if (!state.globalTitle && !state.globalDescription) { + if (!state.globalTitle) { state.globalTitle = workspace.name || ""; - state.globalDescription = workspace.description || ""; } return NextResponse.json({ diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index f4df5270..26866aa9 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -218,19 +218,14 @@ async function handlePOST(request: NextRequest) { // Use provided initial state (already validated on client side) initialState = customInitialState; initialState.workspaceId = workspace.id; - // Ensure globalTitle and globalDescription are set from workspace metadata if (!initialState.globalTitle) { initialState.globalTitle = name; } - if (!initialState.globalDescription && description) { - initialState.globalDescription = description; - } } else { // Use template-based initial state initialState = getTemplateInitialState(effectiveTemplate); initialState.workspaceId = workspace.id; initialState.globalTitle = name; - initialState.globalDescription = description || ""; } // Note: No need to create workspace_states record - state is now managed via events diff --git a/src/app/api/workspaces/slug/[slug]/route.ts b/src/app/api/workspaces/slug/[slug]/route.ts index 1ee9580c..7945148d 100644 --- a/src/app/api/workspaces/slug/[slug]/route.ts +++ b/src/app/api/workspaces/slug/[slug]/route.ts @@ -103,9 +103,8 @@ async function handleGET( const state = await loadWorkspaceState(workspace.id); // Ensure state has workspace metadata if empty - if (!state.globalTitle && !state.globalDescription) { + if (!state.globalTitle) { state.globalTitle = workspace.name || ""; - state.globalDescription = workspace.description || ""; } return NextResponse.json({ diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 5c604378..ca9a6d5f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -296,10 +296,9 @@ function DashboardContent({ const getStatePreviewJSON = (s: AgentState | undefined): Record => { const snapshot = (s ?? initialState) as AgentState; - const { globalTitle, globalDescription, items } = snapshot; + const { globalTitle, items } = snapshot; return { globalTitle: globalTitle ?? initialState.globalTitle, - globalDescription: globalDescription ?? initialState.globalDescription, items: items ?? initialState.items, }; }; diff --git a/src/app/share-copy/[id]/layout.tsx b/src/app/share-copy/[id]/layout.tsx index 7828e3e1..cbbebad5 100644 --- a/src/app/share-copy/[id]/layout.tsx +++ b/src/app/share-copy/[id]/layout.tsx @@ -31,7 +31,7 @@ export async function generateMetadata( const state = await loadWorkspaceState(id); const title = state.globalTitle || workspace[0].name || "Untitled Workspace"; - const description = state.globalDescription || workspace[0].description || "View and import this shared ThinkEx workspace."; + const description = workspace[0].description || "View and import this shared ThinkEx workspace."; return { title: `Shared Workspace: ${title}`, diff --git a/src/components/workspace/SidebarCardList.tsx b/src/components/workspace/SidebarCardList.tsx index 95d8d094..54c9383c 100644 --- a/src/components/workspace/SidebarCardList.tsx +++ b/src/components/workspace/SidebarCardList.tsx @@ -797,7 +797,7 @@ function SidebarCardList() { }, [workspaces, currentWorkspaceId]); // Get workspace operations for delete functionality - const operations = useWorkspaceOperations(currentWorkspaceId, state || { items: [], workspaceId: currentWorkspaceId || '', globalTitle: '', globalDescription: '', globalTags: [] }); + const operations = useWorkspaceOperations(currentWorkspaceId, state || { items: [], workspaceId: currentWorkspaceId || '', globalTitle: '' }); const handleDeleteItem = useCallback( async (itemId: string) => { diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index 4e1d87ed..7ea7b1aa 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -26,7 +26,6 @@ export interface WorkspaceOperations { deleteItem: (id: string) => void; updateAllItems: (items: Item[]) => void; setGlobalTitle: (title: string) => void; - setGlobalDescription: (description: string) => void; flushPendingChanges: (itemId: string) => void; // Folder operations @@ -305,15 +304,6 @@ export function useWorkspaceOperations( [mutation, userId, userName] ); - const setGlobalDescription = useCallback( - (description: string) => { - const event = createEvent("GLOBAL_DESCRIPTION_SET", { description }, userId, userName); - mutation.mutate(event); - }, - [mutation, userId, userName] - ); - - // Helper for updating item data (used by field actions) const updateItemData = useCallback( (itemId: string, updater: (prev: Item['data']) => Item['data'], source: 'user' | 'agent' = 'user') => { @@ -696,7 +686,6 @@ export function useWorkspaceOperations( deleteItem, updateAllItems, setGlobalTitle, - setGlobalDescription, flushPendingChanges, // Folder operations createFolder, diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 2150b2c4..a60fc077 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -1,10 +1,71 @@ import type { AgentState, Item, NoteData, PdfData, FlashcardData, FlashcardItem, YouTubeData, QuizData, QuizQuestion, ImageData, AudioData } from "@/lib/workspace-state/types"; import { serializeBlockNote } from "./serialize-blocknote"; import { type Block } from "@/components/editor/BlockNoteEditor"; +import { getVirtualPath } from "./virtual-workspace-fs"; + +/** + * Formats item metadata only (no content). Used for virtual FS in system context. + */ +function formatItemMetadata(item: Item, items: Item[]): string { + const path = getVirtualPath(item, items); + const parts: string[] = [path, `type=${item.type}`, `name="${item.name}"`]; + if (item.subtitle) parts.push(`subtitle="${item.subtitle}"`); + + switch (item.type) { + case "pdf": { + const d = item.data as PdfData; + if (d?.filename) parts.push(`filename=${d.filename}`); + if (d?.textContent) parts.push("hasContent=true"); + break; + } + case "flashcard": { + const d = item.data as FlashcardData; + const n = d?.cards?.length ?? (d?.front ? 1 : 0); + parts.push(`cards=${n}`); + break; + } + case "quiz": { + const d = item.data as QuizData; + parts.push(`questions=${d?.questions?.length ?? 0}`); + break; + } + case "audio": { + const d = item.data as AudioData; + parts.push(`status=${d?.processingStatus ?? "unknown"}`); + break; + } + } + return parts.join(" "); +} + +/** + * Formats the workspace as a virtual file system with metadata only (no content). + * Replaces per-card context registration — send this once in workspace context. + * Content is available via selected cards context or tools (processFiles, etc.). + */ +export function formatVirtualWorkspaceFS(state: AgentState): string { + const { items = [] } = state; + const contentItems = items.filter((i) => i.type !== "folder"); + if (contentItems.length === 0) { + return ` +Workspace is empty. Reference items by name when created. +`; + } + + const entries = contentItems.map((item) => + formatItemMetadata(item, items) + ); + + return ` +Paths and metadata. Reference items by path or name. Use processFiles or selected cards for content. + +${entries.join("\n")} +`; +} /** * Formats minimal workspace context (metadata and system instructions only) - * Cards register their own context individually, so we don't include the items list here + * Virtual FS (formatVirtualWorkspaceFS) provides the item tree and metadata only. */ export function formatWorkspaceContext(state: AgentState): string { const { globalTitle } = state; @@ -27,10 +88,10 @@ Your knowledge cutoff date is January 2025. -WORKSPACE ITEMS: -The tags represent cards in the workspace. Items named "Update me" are template placeholders awaiting content generation. +WORKSPACE (virtual file system): +${formatVirtualWorkspaceFS(state)} -When users say "this", they may mean information in the section. Reference cards by name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCard tool. +When users say "this", they may mean information in the section. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCard tool. When answering questions about selected cards or content in , rely only on the facts directly mentioned in that context. Do not invent or assume information not present. If the answer is not in the context, say so. diff --git a/src/lib/utils/virtual-workspace-fs.ts b/src/lib/utils/virtual-workspace-fs.ts new file mode 100644 index 00000000..b75253b0 --- /dev/null +++ b/src/lib/utils/virtual-workspace-fs.ts @@ -0,0 +1,47 @@ +import type { Item } from "@/lib/workspace-state/types"; +import { getFolderPath } from "@/lib/workspace-state/search"; + +/** + * Get the virtual file path for an item in the workspace. + * Example: "Physics/Thermodynamics/notes/Heat Transfer.md" + */ +export function getVirtualPath(item: Item, items: Item[]): string { + const sanitize = (s: string) => + s.replace(/[/\\?*:|"<>]/g, "-").trim() || "untitled"; + + const extByType: Record = { + note: "md", + pdf: "pdf", + flashcard: "md", + youtube: "url", + quiz: "md", + image: "png", + audio: "audio", + }; + const ext = extByType[item.type] || "md"; + const typeDir = `${item.type}s`; + const filename = `${sanitize(item.name)}.${ext}`; + + if (item.type === "folder") { + const folderPath = getFolderPath(item.id, items); + const segments = folderPath.map((f) => sanitize(f.name)); + return segments.length > 0 ? segments.join("/") + "/" : "/"; + } + + const folderSegments: string[] = []; + let folderId = item.folderId; + while (folderId) { + const folder = items.find( + (i) => i.id === folderId && i.type === "folder" + ); + if (!folder) break; + folderSegments.unshift(sanitize(folder.name)); + folderId = folder.folderId; + } + + const pathParts = + folderSegments.length > 0 + ? [...folderSegments, typeDir, filename] + : [typeDir, filename]; + return pathParts.join("/"); +} diff --git a/src/lib/workspace-state/state.ts b/src/lib/workspace-state/state.ts index d0805635..1936ce60 100644 --- a/src/lib/workspace-state/state.ts +++ b/src/lib/workspace-state/state.ts @@ -5,9 +5,7 @@ import { AgentState, CardType, ItemData, NoteData, PdfData } from "@/lib/workspa export const initialState: AgentState = { items: [], globalTitle: "", - globalDescription: "", lastAction: "", - itemsCreated: 0, }; diff --git a/src/lib/workspace-state/types.ts b/src/lib/workspace-state/types.ts index fdfbb382..d6f9323d 100644 --- a/src/lib/workspace-state/types.ts +++ b/src/lib/workspace-state/types.ts @@ -178,14 +178,14 @@ export interface Item { */ layout?: ResponsiveLayouts | LayoutPosition; lastSource?: 'user' | 'agent'; + /** Timestamp (ms) when item was last modified. Set by event reducer. Used for AI conflict detection. */ + lastModified?: number; } export interface AgentState { items: Item[]; // Includes folder-type items (type: 'folder') globalTitle: string; - globalDescription: string; lastAction?: string; - itemsCreated: number; workspaceId?: string; // Supabase workspace ID for persistence /** @deprecated Folders are now items with type: 'folder'. This field is kept for backward compatibility but is not used. */ folders?: Folder[]; diff --git a/src/lib/workspace/clone-demo.ts b/src/lib/workspace/clone-demo.ts index 612bf55d..0ffce6f6 100644 --- a/src/lib/workspace/clone-demo.ts +++ b/src/lib/workspace/clone-demo.ts @@ -51,7 +51,6 @@ export async function cloneDemoWorkspace( // Ensure titles match if missing in state if (!initialState.globalTitle) initialState.globalTitle = name; - if (!initialState.globalDescription) initialState.globalDescription = description; } else { // Fallback to blank if demo workspace not found or state load failed initialState = getTemplateInitialState("blank"); diff --git a/src/lib/workspace/event-reducer.ts b/src/lib/workspace/event-reducer.ts index 18c0dca2..fd0713df 100644 --- a/src/lib/workspace/event-reducer.ts +++ b/src/lib/workspace/event-reducer.ts @@ -12,17 +12,19 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta return { ...state, globalTitle: event.payload.title, - globalDescription: event.payload.description, }; - case 'ITEM_CREATED': - const isFolder = event.payload.item.type === 'folder'; + case 'ITEM_CREATED': { + const item = event.payload.item; + const now = event.timestamp || Date.now(); return { ...state, - items: [...state.items, event.payload.item], + items: [...state.items, { ...item, lastModified: now }], }; + } - case 'ITEM_UPDATED': + case 'ITEM_UPDATED': { + const now = event.timestamp || Date.now(); return { ...state, items: state.items.map(item => @@ -37,11 +39,13 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta ...event.payload.changes.data, // Apply new data updates } : item.data, - lastSource: event.payload.source // Propagate source to item state + lastSource: event.payload.source, // Propagate source to item state + lastModified: now, } : item ), }; + } case 'ITEM_DELETED': { const deletedItemId = event.payload.id; @@ -68,10 +72,8 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta }; case 'GLOBAL_DESCRIPTION_SET': - return { - ...state, - globalDescription: event.payload.description, - }; + // No-op: globalDescription removed from state + return state; case 'WORKSPACE_SNAPSHOT': @@ -138,12 +140,17 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta } } - case 'BULK_ITEMS_CREATED': - // Create multiple items atomically in a single event + case 'BULK_ITEMS_CREATED': { + const now = event.timestamp || Date.now(); + const itemsWithModified = event.payload.items.map((item) => ({ + ...item, + lastModified: now, + })); return { ...state, - items: [...state.items, ...event.payload.items], + items: [...state.items, ...itemsWithModified], }; + } @@ -214,8 +221,8 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta } case 'FOLDER_CREATED_WITH_ITEMS': { - // Create folder and move items atomically in a single operation - const folder = event.payload.folder; + const now = event.timestamp || Date.now(); + const folder = { ...event.payload.folder, lastModified: now }; const itemIdsSet = new Set(event.payload.itemIds); const folderId = folder.id; @@ -226,12 +233,12 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta ? { ...item, folderId: folderId, - layout: undefined // Clear layout for fresh positioning in new folder + layout: undefined, + lastModified: now, } : item ); - // Add the folder item itself updatedItems.push(folder); return { diff --git a/src/lib/workspace/import-validation.ts b/src/lib/workspace/import-validation.ts index 0ff02bfd..a20e3afd 100644 --- a/src/lib/workspace/import-validation.ts +++ b/src/lib/workspace/import-validation.ts @@ -1,4 +1,4 @@ -import type { AgentState, Item, CardType } from "@/lib/workspace-state/types"; +import type { AgentState, Item, CardType } from "@/lib/workspace-state/types"; export interface ValidationResult { isValid: boolean; @@ -47,8 +47,6 @@ export function validateImportedJSON(jsonString: string): ValidationResult { const validatedState: AgentState = { items: parsed.items, globalTitle: typeof parsed.globalTitle === 'string' ? parsed.globalTitle : "", - globalDescription: typeof parsed.globalDescription === 'string' ? parsed.globalDescription : "", - itemsCreated: typeof parsed.itemsCreated === 'number' ? parsed.itemsCreated : parsed.items.length, }; return { diff --git a/src/lib/workspace/state-loader.ts b/src/lib/workspace/state-loader.ts index 19f75265..1e74a24f 100644 --- a/src/lib/workspace/state-loader.ts +++ b/src/lib/workspace/state-loader.ts @@ -59,8 +59,6 @@ export async function loadWorkspaceState(workspaceId: string): Promise { @@ -72,8 +70,6 @@ export const WORKSPACE_TEMPLATES: TemplateDefinition[] = [ } ], globalTitle: "", - globalDescription: "", - itemsCreated: 3, }, }; })(), @@ -94,7 +90,5 @@ export function getTemplateInitialState(template: string): AgentState { return { items: templateDef.initialState.items || [], globalTitle: templateDef.initialState.globalTitle || "", - globalDescription: templateDef.initialState.globalDescription || "", - itemsCreated: templateDef.initialState.itemsCreated || 0, }; } From ed0657dab06cb97d470589bf43221230e8676984 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:15:15 -0500 Subject: [PATCH 04/56] feat: use our db for chat --- drizzle/0002_deep_shadowcat.sql | 35 + drizzle/meta/0002_snapshot.json | 1823 +++++++++++++++++ drizzle/meta/_journal.json | 7 + eslint.config.mjs | 1 + package.json | 2 +- src/app/api/assistant-ui-token/route.ts | 50 - src/app/api/threads/[id]/archive/route.ts | 51 + .../[id]/messages/[messageId]/route.ts | 75 + src/app/api/threads/[id]/messages/route.ts | 117 ++ src/app/api/threads/[id]/route.ts | 120 ++ src/app/api/threads/[id]/title/route.ts | 101 + src/app/api/threads/[id]/unarchive/route.ts | 51 + src/app/api/threads/route.ts | 106 + .../ai-elements/inline-citation.tsx | 2 +- .../assistant-ui/WorkspaceRuntimeProvider.tsx | 89 +- src/hooks/ai/use-citations-from-message.ts | 10 +- src/lib/chat/aui-v0.ts | 124 ++ .../chat/custom-thread-history-adapter.tsx | 153 ++ src/lib/chat/custom-thread-list-adapter.tsx | 135 ++ src/lib/db/relations.ts | 23 +- src/lib/db/schema.ts | 70 + tsconfig.json | 3 +- 22 files changed, 3026 insertions(+), 122 deletions(-) create mode 100644 drizzle/0002_deep_shadowcat.sql create mode 100644 drizzle/meta/0002_snapshot.json delete mode 100644 src/app/api/assistant-ui-token/route.ts create mode 100644 src/app/api/threads/[id]/archive/route.ts create mode 100644 src/app/api/threads/[id]/messages/[messageId]/route.ts create mode 100644 src/app/api/threads/[id]/messages/route.ts create mode 100644 src/app/api/threads/[id]/route.ts create mode 100644 src/app/api/threads/[id]/title/route.ts create mode 100644 src/app/api/threads/[id]/unarchive/route.ts create mode 100644 src/app/api/threads/route.ts create mode 100644 src/lib/chat/aui-v0.ts create mode 100644 src/lib/chat/custom-thread-history-adapter.tsx create mode 100644 src/lib/chat/custom-thread-list-adapter.tsx diff --git a/drizzle/0002_deep_shadowcat.sql b/drizzle/0002_deep_shadowcat.sql new file mode 100644 index 00000000..eb03c2f7 --- /dev/null +++ b/drizzle/0002_deep_shadowcat.sql @@ -0,0 +1,35 @@ +CREATE TABLE "chat_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "thread_id" uuid NOT NULL, + "message_id" text NOT NULL, + "parent_id" text, + "format" text NOT NULL, + "content" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "chat_threads" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "user_id" text NOT NULL, + "title" text, + "is_archived" boolean DEFAULT false, + "external_id" text, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + "last_message_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "chat_threads" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_thread_id_chat_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."chat_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_threads" ADD CONSTRAINT "chat_threads_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_chat_messages_thread" ON "chat_messages" USING btree ("thread_id" uuid_ops);--> statement-breakpoint +CREATE INDEX "idx_chat_messages_thread_created" ON "chat_messages" USING btree ("thread_id" uuid_ops,"created_at" timestamptz_ops);--> statement-breakpoint +CREATE INDEX "idx_chat_threads_workspace" ON "chat_threads" USING btree ("workspace_id" uuid_ops);--> statement-breakpoint +CREATE INDEX "idx_chat_threads_user" ON "chat_threads" USING btree ("user_id" text_ops);--> statement-breakpoint +CREATE INDEX "idx_chat_threads_last_message" ON "chat_threads" USING btree ("workspace_id" uuid_ops,"last_message_at" timestamptz_ops);--> statement-breakpoint +CREATE POLICY "Users can manage threads in their workspaces" ON "chat_threads" AS PERMISSIVE FOR ALL TO "authenticated" USING ((EXISTS ( SELECT 1 FROM workspaces w + WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) + OR (EXISTS ( SELECT 1 FROM workspace_collaborators c + WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..c7678de5 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1823 @@ +{ + "id": "208b0e93-f789-49c6-abc8-e9328aab88b5", + "prevId": "1fc3e75d-4a15-4c00-ad92-4edde926b371", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_account_user_id": { + "name": "idx_account_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_messages_thread": { + "name": "idx_chat_messages_thread", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_messages_thread_created": { + "name": "idx_chat_messages_thread_created", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_threads_workspace": { + "name": "idx_chat_threads_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_user": { + "name": "idx_chat_threads_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_last_message": { + "name": "idx_chat_threads_last_message", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can manage threads in their workspaces": { + "name": "Users can manage threads in their workspaces", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1 FROM workspaces w\n WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM workspace_collaborators c\n WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_session_user_id": { + "name": "idx_session_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_token": { + "name": "idx_session_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_email": { + "name": "idx_user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_user_profiles_user_id": { + "name": "idx_user_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profiles_user_id_key": { + "name": "user_profiles_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": { + "Users can insert their own profile": { + "name": "Users can insert their own profile", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can update their own profile": { + "name": "Users can update their own profile", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ] + }, + "Users can view their own profile": { + "name": "Users can view their own profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_verification_identifier": { + "name": "idx_verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_collaborators": { + "name": "workspace_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_collaborators_lookup": { + "name": "idx_workspace_collaborators_lookup", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_workspace": { + "name": "idx_workspace_collaborators_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_last_opened_at": { + "name": "idx_workspace_collaborators_last_opened_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_collaborators_workspace_id_fkey": { + "name": "workspace_collaborators_workspace_id_fkey", + "tableFrom": "workspace_collaborators", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_collaborators_invite_token_unique": { + "name": "workspace_collaborators_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + }, + "workspace_collaborators_workspace_user_unique": { + "name": "workspace_collaborators_workspace_user_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "policies": { + "Owners can manage collaborators": { + "name": "Owners can manage collaborators", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Collaborators can view their access": { + "name": "Collaborators can view their access", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "(user_id = (auth.jwt() ->> 'sub'::text))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_events": { + "name": "workspace_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspace_events_event_id": { + "name": "idx_workspace_events_event_id", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_timestamp": { + "name": "idx_workspace_events_timestamp", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_user_name": { + "name": "idx_workspace_events_user_name", + "columns": [ + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_workspace": { + "name": "idx_workspace_events_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_events_workspace_id_fkey": { + "name": "workspace_events_workspace_id_fkey", + "tableFrom": "workspace_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_events_event_id_key": { + "name": "workspace_events_event_id_key", + "nullsNotDistinct": false, + "columns": [ + "event_id" + ] + } + }, + "policies": { + "Users can insert workspace events they have write access to": { + "name": "Users can insert workspace events they have write access to", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + }, + "Users can read workspace events they have access to": { + "name": "Users can read workspace events they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_invites_token": { + "name": "idx_workspace_invites_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_email": { + "name": "idx_workspace_invites_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_workspace": { + "name": "idx_workspace_invites_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_fkey": { + "name": "workspace_invites_workspace_id_fkey", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invites_token_key": { + "name": "workspace_invites_token_key", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": { + "Public can view invite by token": { + "name": "Public can view invite by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "true" + }, + "Users can insert invites for workspaces they own/edit": { + "name": "Users can insert invites for workspaces they own/edit", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_invites.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_invites.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_share_links": { + "name": "workspace_share_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_workspace_share_links_token": { + "name": "idx_workspace_share_links_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_share_links_workspace": { + "name": "idx_workspace_share_links_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_share_links_workspace_id_fkey": { + "name": "workspace_share_links_workspace_id_fkey", + "tableFrom": "workspace_share_links", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_share_links_token_key": { + "name": "workspace_share_links_token_key", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + }, + "workspace_share_links_workspace_key": { + "name": "workspace_share_links_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "workspace_id" + ] + } + }, + "policies": { + "Public can view share link by token": { + "name": "Public can view share link by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "true" + }, + "Owners and editors can manage share links": { + "name": "Owners and editors can manage share links", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_share_links.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_snapshots": { + "name": "workspace_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "snapshot_version": { + "name": "snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event_count": { + "name": "event_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_snapshots_version": { + "name": "idx_workspace_snapshots_version", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "snapshot_version", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_snapshots_workspace": { + "name": "idx_workspace_snapshots_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_snapshots_workspace_id_fkey": { + "name": "workspace_snapshots_workspace_id_fkey", + "tableFrom": "workspace_snapshots", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_snapshots_workspace_id_snapshot_version_key": { + "name": "workspace_snapshots_workspace_id_snapshot_version_key", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "snapshot_version" + ] + } + }, + "policies": { + "Service role can insert workspace snapshots": { + "name": "Service role can insert workspace snapshots", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "true" + }, + "Users can read workspace snapshots they have access to": { + "name": "Users can read workspace snapshots they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'blank'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspaces_created_at": { + "name": "idx_workspaces_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_slug": { + "name": "idx_workspaces_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_id": { + "name": "idx_workspaces_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_slug": { + "name": "idx_workspaces_user_slug", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_sort_order": { + "name": "idx_workspaces_user_sort_order", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_last_opened_at": { + "name": "idx_workspaces_last_opened_at", + "columns": [ + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can delete their own workspaces": { + "name": "Users can delete their own workspaces", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can insert their own workspaces": { + "name": "Users can insert their own workspaces", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ] + }, + "Users can update their own workspaces": { + "name": "Users can update their own workspaces", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ] + }, + "Users can view their own workspaces": { + "name": "Users can view their own workspaces", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f7042925..222c9751 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1771377459878, "tag": "0002_add_workspace_share_links", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1771631691687, + "tag": "0002_deep_shadowcat", + "breakpoints": true } ] } \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 35232dec..382e1ee3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,7 @@ const compat = new FlatCompat({ }); const eslintConfig = [ + { ignores: ["assistant-ui-main/**"] }, ...compat.extends("next/core-web-vitals", "next/typescript"), { rules: { diff --git a/package.json b/package.json index 9c82e973..3fb7ddfe 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "@tanstack/react-virtual": "^3.13.14", "@vercel/speed-insights": "^1.3.1", "ai": "^6.0.78", - "assistant-cloud": "^0.1.17", + "assistant-stream": "^0.3.2", "better-auth": "^1.4.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/app/api/assistant-ui-token/route.ts b/src/app/api/assistant-ui-token/route.ts deleted file mode 100644 index f012403e..00000000 --- a/src/app/api/assistant-ui-token/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AssistantCloud } from "@assistant-ui/react"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; -import { NextRequest, NextResponse } from "next/server"; - -export async function POST(req: NextRequest) { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - return NextResponse.json( - { error: "User not authenticated" }, - { status: 401 } - ); - } - - const userId = session.user.id; - - const body = await req.json().catch(() => ({})); - const { workspaceId } = body; - - if (!workspaceId) { - return NextResponse.json( - { error: "workspaceId is required" }, - { status: 400 } - ); - } - - // Create AssistantCloud instance with API key - const assistantCloud = new AssistantCloud({ - apiKey: process.env.ASSISTANT_API_KEY!, - userId, - workspaceId, // This scopes threads to the specific workspace - }); - - // Generate auth token for this user and workspace - const { token } = await assistantCloud.auth.tokens.create(); - - return NextResponse.json({ token }); - } catch (error) { - console.error("Failed to generate assistant token:", error); - return NextResponse.json( - { error: "Failed to generate token" }, - { status: 500 } - ); - } -} - diff --git a/src/app/api/threads/[id]/archive/route.ts b/src/app/api/threads/[id]/archive/route.ts new file mode 100644 index 00000000..e4661436 --- /dev/null +++ b/src/app/api/threads/[id]/archive/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq } from "drizzle-orm"; + +/** + * POST /api/threads/[id]/archive + * Archive a thread + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, id)) + .limit(1); + + if (!thread) { + return NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId, "editor"); + + await db + .update(chatThreads) + .set({ + isArchived: true, + updatedAt: new Date().toISOString(), + }) + .where(eq(chatThreads.id, id)); + + return new Response(null, { status: 204 }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] archive error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/[id]/messages/[messageId]/route.ts b/src/app/api/threads/[id]/messages/[messageId]/route.ts new file mode 100644 index 00000000..a1df4ac3 --- /dev/null +++ b/src/app/api/threads/[id]/messages/[messageId]/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads, chatMessages } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq, and } from "drizzle-orm"; + +async function getThreadAndVerify(threadId: string, userId: string) { + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, threadId)) + .limit(1); + + if (!thread) { + throw NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId); + + return thread; +} + +/** + * PATCH /api/threads/[id]/messages/[messageId] + * Update an existing message (e.g. step timestamps/duration from useExternalHistory) + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string; messageId: string }> } +) { + try { + const userId = await requireAuth(); + const { id: threadId, messageId } = await params; + const body = await req.json().catch(() => ({})); + const { format, content } = body; + + if (!format || content === undefined) { + return NextResponse.json( + { error: "format and content are required" }, + { status: 400 } + ); + } + + await getThreadAndVerify(threadId, userId); + + const [updated] = await db + .update(chatMessages) + .set({ + content: typeof content === "object" ? content : { raw: content }, + }) + .where( + and( + eq(chatMessages.threadId, threadId), + eq(chatMessages.messageId, messageId) + ) + ) + .returning({ messageId: chatMessages.messageId }); + + if (!updated) { + return NextResponse.json({ error: "Message not found" }, { status: 404 }); + } + + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] messages PATCH error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/[id]/messages/route.ts b/src/app/api/threads/[id]/messages/route.ts new file mode 100644 index 00000000..b9a0e535 --- /dev/null +++ b/src/app/api/threads/[id]/messages/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads, chatMessages } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq, desc } from "drizzle-orm"; + +async function getThreadAndVerify(id: string, userId: string) { + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, id)) + .limit(1); + + if (!thread) { + throw NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId); + + return thread; +} + +/** + * GET /api/threads/[id]/messages?format=aui/v0 + * Load messages for a thread + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + const { searchParams } = new URL(req.url); + const format = searchParams.get("format") ?? "aui/v0"; + + await getThreadAndVerify(id, userId); + + const rows = await db + .select() + .from(chatMessages) + .where(eq(chatMessages.threadId, id)) + .orderBy(desc(chatMessages.createdAt)); + + const messages = rows + .filter((r) => format === "aui/v0" || r.format === format) + .map((r) => ({ + id: r.messageId, + parent_id: r.parentId, + format: r.format, + content: r.content, + created_at: r.createdAt, + })); + + return NextResponse.json({ messages }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] messages GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * POST /api/threads/[id]/messages + * Append a message to a thread + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + const body = await req.json().catch(() => ({})); + const { messageId, parentId, format, content } = body; + + if (!messageId || !format || content === undefined) { + return NextResponse.json( + { error: "messageId, format, and content are required" }, + { status: 400 } + ); + } + + const thread = await getThreadAndVerify(id, userId); + + await db.insert(chatMessages).values({ + threadId: id, + messageId: String(messageId), + parentId: parentId ?? null, + format: String(format), + content: typeof content === "object" ? content : { raw: content }, + }); + + await db + .update(chatThreads) + .set({ + lastMessageAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .where(eq(chatThreads.id, id)); + + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] messages POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/[id]/route.ts b/src/app/api/threads/[id]/route.ts new file mode 100644 index 00000000..f73e0baf --- /dev/null +++ b/src/app/api/threads/[id]/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq } from "drizzle-orm"; + +async function getThreadAndVerify( + id: string, + userId: string, + permission: "viewer" | "editor" = "viewer" +) { + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, id)) + .limit(1); + + if (!thread) { + throw NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId, permission); + + return thread; +} + +/** + * GET /api/threads/[id] + * Fetch a single thread + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + + const thread = await getThreadAndVerify(id, userId); + + return NextResponse.json({ + id: thread.id, + remoteId: thread.id, + status: thread.isArchived ? "archived" : "regular", + title: thread.title ?? undefined, + externalId: thread.externalId ?? undefined, + }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] GET [id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/threads/[id] + * Update thread (e.g. rename) + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + const body = await req.json().catch(() => ({})); + const { title } = body; + + await getThreadAndVerify(id, userId, "editor"); + + if (title !== undefined) { + await db + .update(chatThreads) + .set({ title: String(title), updatedAt: new Date().toISOString() }) + .where(eq(chatThreads.id, id)); + } + + return new Response(null, { status: 204 }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] PATCH error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/threads/[id] + * Delete a thread (and its messages via cascade) + */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + + await getThreadAndVerify(id, userId, "editor"); + + await db.delete(chatThreads).where(eq(chatThreads.id, id)); + + return new Response(null, { status: 204 }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/[id]/title/route.ts b/src/app/api/threads/[id]/title/route.ts new file mode 100644 index 00000000..24b05d24 --- /dev/null +++ b/src/app/api/threads/[id]/title/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { google } from "@ai-sdk/google"; +import { generateText } from "ai"; +import { db } from "@/lib/db/client"; +import { chatThreads } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq } from "drizzle-orm"; + +/** Model ID used for processFiles and other lightweight tasks */ +const GEMINI_FLASH_LITE_MODEL = "gemini-2.5-flash-lite"; + +function extractTextFromMessage(msg: { content?: unknown[] }): string { + if (!msg.content || !Array.isArray(msg.content)) return ""; + return (msg.content as { type?: string; text?: string }[]) + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join(" ") + .trim(); +} + +/** + * POST /api/threads/[id]/title + * Generate a title from messages using Gemini Flash Lite (same model as processFiles). + * Body: { messages: ThreadMessage[] } + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + const body = await req.json().catch(() => ({})); + const { messages } = body; + + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, id)) + .limit(1); + + if (!thread) { + return NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId); + + let title = "New Chat"; + + if (messages && Array.isArray(messages) && messages.length > 0) { + const conversationText = messages + .slice(0, 6) + .map((m: { role?: string; content?: unknown[] }) => { + const text = extractTextFromMessage(m); + if (!text) return ""; + const role = m.role === "user" ? "User" : "Assistant"; + return `${role}: ${text}`; + }) + .filter(Boolean) + .join("\n\n"); + + if (conversationText.trim()) { + try { + const { text } = await generateText({ + model: google(GEMINI_FLASH_LITE_MODEL), + system: `Generate a very short chat title (2-6 words) that captures the topic. Output ONLY the title, no quotes or punctuation.`, + prompt: `Conversation:\n\n${conversationText}\n\nTitle:`, + }); + const generated = text.trim().slice(0, 60); + if (generated) title = generated; + } catch (err) { + console.warn("[threads] title Gemini fallback:", err); + const firstUser = messages.find( + (m: { role?: string }) => m.role === "user" + ); + const fallback = extractTextFromMessage(firstUser ?? {}); + if (fallback) { + title = fallback.slice(0, 50) + (fallback.length > 50 ? "..." : ""); + } + } + } + } + + await db + .update(chatThreads) + .set({ title }) + .where(eq(chatThreads.id, id)); + + return NextResponse.json({ title }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] title error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/[id]/unarchive/route.ts b/src/app/api/threads/[id]/unarchive/route.ts new file mode 100644 index 00000000..71136b1a --- /dev/null +++ b/src/app/api/threads/[id]/unarchive/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq } from "drizzle-orm"; + +/** + * POST /api/threads/[id]/unarchive + * Unarchive a thread + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const userId = await requireAuth(); + const { id } = await params; + + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, id)) + .limit(1); + + if (!thread) { + return NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId, "editor"); + + await db + .update(chatThreads) + .set({ + isArchived: false, + updatedAt: new Date().toISOString(), + }) + .where(eq(chatThreads.id, id)); + + return new Response(null, { status: 204 }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] unarchive error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/threads/route.ts b/src/app/api/threads/route.ts new file mode 100644 index 00000000..63103a14 --- /dev/null +++ b/src/app/api/threads/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq, and, desc } from "drizzle-orm"; + +/** + * GET /api/threads?workspaceId=xxx + * List threads for a workspace + */ +export async function GET(req: NextRequest) { + try { + const userId = await requireAuth(); + const { searchParams } = new URL(req.url); + const workspaceId = searchParams.get("workspaceId"); + + if (!workspaceId) { + return NextResponse.json( + { error: "workspaceId is required" }, + { status: 400 } + ); + } + + await verifyWorkspaceAccess(workspaceId, userId); + + const threads = await db + .select({ + id: chatThreads.id, + title: chatThreads.title, + isArchived: chatThreads.isArchived, + externalId: chatThreads.externalId, + }) + .from(chatThreads) + .where(eq(chatThreads.workspaceId, workspaceId)) + .orderBy(desc(chatThreads.lastMessageAt)); + + return NextResponse.json({ + threads: threads.map((t) => ({ + remoteId: t.id, + status: t.isArchived ? "archived" : "regular", + title: t.title ?? undefined, + externalId: t.externalId ?? undefined, + })), + }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * POST /api/threads + * Create a new thread + */ +export async function POST(req: NextRequest) { + try { + const userId = await requireAuth(); + const body = await req.json().catch(() => ({})); + const { workspaceId, localId, externalId } = body; + + if (!workspaceId) { + return NextResponse.json( + { error: "workspaceId is required" }, + { status: 400 } + ); + } + + await verifyWorkspaceAccess(workspaceId, userId, "editor"); + + const [inserted] = await db + .insert(chatThreads) + .values({ + workspaceId, + userId, + externalId: externalId ?? undefined, + }) + .returning({ id: chatThreads.id, externalId: chatThreads.externalId }); + + if (!inserted) { + return NextResponse.json( + { error: "Failed to create thread" }, + { status: 500 } + ); + } + + return NextResponse.json({ + id: inserted.id, + remoteId: inserted.id, + externalId: inserted.externalId ?? undefined, + }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/components/ai-elements/inline-citation.tsx b/src/components/ai-elements/inline-citation.tsx index 5a58924c..eee46a23 100644 --- a/src/components/ai-elements/inline-citation.tsx +++ b/src/components/ai-elements/inline-citation.tsx @@ -139,7 +139,7 @@ export const InlineCitationSource = ({ type="button" onClick={onClick} className={cn(baseClasses, className)} - {...props} + {...(props as ComponentProps<"button">)} > {content} diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index 6bc81b6f..78567ab1 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -1,15 +1,17 @@ "use client"; -import { AssistantCloud, AssistantRuntimeProvider } from "@assistant-ui/react"; +import { + AssistantRuntimeProvider, + unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime, +} from "@assistant-ui/react"; import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; import { useMemo, useCallback } from "react"; import { AssistantAvailableProvider } from "@/contexts/AssistantAvailabilityContext"; import { useUIStore } from "@/lib/stores/ui-store"; -import { useSession } from "@/lib/auth-client"; import { toast } from "sonner"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context"; -import { SupabaseAttachmentAdapter } from "@/lib/attachments/supabase-attachment-adapter"; +import { createThreadListAdapter } from "@/lib/chat/custom-thread-list-adapter"; interface WorkspaceRuntimeProviderProps { workspaceId: string; @@ -23,23 +25,11 @@ export function WorkspaceRuntimeProvider({ workspaceId, children }: WorkspaceRuntimeProviderProps) { - // ... existing hooks - - const selectedModelId = useUIStore((state) => state.selectedModelId); const activeFolderId = useUIStore((state) => state.activeFolderId); - /* - FIX for "Maximum update depth exceeded": - We select the Set directly because Array.from() inside the selector creates a new reference on every render, - triggering an infinite update loop. The Set reference is stable until changed. - */ const selectedCardIdsSet = useUIStore((state) => state.selectedCardIds); - const { data: session } = useSession(); - - // Get workspace state to format selected cards context on client const { state: workspaceState } = useWorkspaceState(workspaceId); - // Format selected cards context on client side (eliminates server-side DB fetch) const selectedCardsContext = useMemo(() => { if (!workspaceState?.items || selectedCardIdsSet.size === 0) { return ""; @@ -56,39 +46,6 @@ export function WorkspaceRuntimeProvider({ return formatSelectedCardsContext(selectedItems, workspaceState.items); }, [workspaceState?.items, selectedCardIdsSet]); - // Create AssistantCloud instance - use anonymous mode for anonymous users - const cloud = useMemo(() => { - // If user is anonymous, use Assistant UI's built-in anonymous mode - if (session?.user?.isAnonymous) { - return new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!, - anonymous: true, // Browser-session based user ID - }); - } - - // For authenticated users, use token-based authentication - return new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!, - authToken: async () => { - const response = await fetch("/api/assistant-ui-token", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ workspaceId }), - }); - - if (!response.ok) { - throw new Error("Failed to get auth token"); - } - - const { token } = await response.json(); - return token; - }, - }); - }, [workspaceId, session?.user?.isAnonymous]); - - // Error handler for chat runtime errors (timeouts, network issues, etc.) const handleChatError = useCallback((error: Error) => { console.error("[Chat Error]", error); @@ -130,28 +87,32 @@ export function WorkspaceRuntimeProvider({ } }, []); - // Create runtime with Assistant Cloud integration - const runtime = useChatRuntime({ - cloud, - transport: useMemo(() => { - const transport = new AssistantChatTransport({ + const threadListAdapter = useMemo( + () => createThreadListAdapter(workspaceId), + [workspaceId] + ); + + const transport = useMemo( + () => + new AssistantChatTransport({ api: "/api/chat", body: { workspaceId, modelId: selectedModelId, activeFolderId, - selectedCardsContext, // Pre-formatted context (client-side) instead of IDs - }, - headers: { - // Headers for static context if needed + selectedCardsContext, }, - }); - return transport; - }, [workspaceId, selectedModelId, activeFolderId, selectedCardsContext]), - adapters: { - attachments: new SupabaseAttachmentAdapter(), - }, - onError: handleChatError, + }), + [workspaceId, selectedModelId, activeFolderId, selectedCardsContext] + ); + + const runtime = useRemoteThreadListRuntime({ + runtimeHook: () => + useChatRuntime({ + transport, + onError: handleChatError, + }), + adapter: threadListAdapter, }); return ( diff --git a/src/hooks/ai/use-citations-from-message.ts b/src/hooks/ai/use-citations-from-message.ts index 9492409b..b08e9694 100644 --- a/src/hooks/ai/use-citations-from-message.ts +++ b/src/hooks/ai/use-citations-from-message.ts @@ -15,14 +15,16 @@ const CITATION_EXTRACTORS = [extractCitationsFromInlineData]; */ export function useCitationsFromMessage(): Citation[] { const message = useMessage(); - const messages = useAuiState((s) => (s.thread as { messages?: unknown[] } | undefined)?.messages ?? []); + const messages = useAuiState( + (s) => (s.thread as unknown as { messages?: unknown[] } | undefined)?.messages ?? [] + ); const thread = useMemo(() => ({ messages }), [messages]); return useMemo(() => { const msg = { - id: (message as { id?: string }).id, - role: (message as { role?: string }).role, - content: (message as { content?: unknown[] }).content, + id: (message as unknown as { id?: string }).id, + role: (message as unknown as { role?: string }).role, + content: (message as unknown as { content?: unknown[] }).content, }; // Extract from model-generated block diff --git a/src/lib/chat/aui-v0.ts b/src/lib/chat/aui-v0.ts new file mode 100644 index 00000000..4a265091 --- /dev/null +++ b/src/lib/chat/aui-v0.ts @@ -0,0 +1,124 @@ +import type { ThreadMessage } from "@assistant-ui/react"; +import { INTERNAL } from "@assistant-ui/react"; + +type StoredMessage = { + id: string; + parent_id: string | null; + format: string; + content: unknown; + created_at: string; +}; + +type AuiV0MessagePart = + | { readonly type: "text"; readonly text: string } + | { readonly type: "reasoning"; readonly text: string } + | { + readonly type: "source"; + readonly sourceType: "url"; + readonly id: string; + readonly url: string; + readonly title?: string; + } + | { + readonly type: "tool-call"; + readonly toolCallId: string; + readonly toolName: string; + readonly args?: Record; + readonly argsText?: string; + readonly result?: unknown; + readonly isError?: true; + } + | { readonly type: "image"; readonly image: string } + | { + readonly type: "file"; + readonly data: string; + readonly mimeType: string; + readonly filename?: string; + }; + +type AuiV0Message = { + readonly role: "assistant" | "user" | "system"; + readonly status?: { type: string; reason?: string }; + readonly content: readonly AuiV0MessagePart[]; + readonly metadata: { + readonly unstable_state?: unknown; + readonly unstable_annotations: readonly unknown[]; + readonly unstable_data: readonly unknown[]; + readonly steps: readonly { readonly usage?: { inputTokens?: number; outputTokens?: number } }[]; + readonly custom: Record; + }; +}; + +export function auiV0Encode(message: ThreadMessage): AuiV0Message { + const status = + message.status?.type === "running" + ? ({ type: "incomplete", reason: "cancelled" } as const) + : message.status; + + return { + role: message.role, + content: message.content.map((part) => { + const type = part.type; + switch (type) { + case "text": + return { type: "text", text: part.text }; + case "reasoning": + return { type: "reasoning", text: part.text }; + case "source": + return { + type: "source", + sourceType: part.sourceType, + id: part.id, + url: part.url, + ...(part.title ? { title: part.title } : undefined), + }; + case "tool-call": + return { + type: "tool-call", + toolCallId: part.toolCallId, + toolName: part.toolName, + ...(JSON.stringify(part.args) === part.argsText + ? { args: part.args } + : { argsText: part.argsText }), + ...(part.result !== undefined ? { result: part.result } : undefined), + ...(part.isError ? { isError: true as const } : undefined), + }; + case "image": + return { type: "image", image: part.image }; + case "file": + return { + type: "file", + data: part.data, + mimeType: part.mimeType, + ...(part.filename ? { filename: part.filename } : undefined), + }; + default: + throw new Error(`Message part type not supported by aui/v0: ${(part as { type: string }).type}`); + } + }), + metadata: message.metadata as AuiV0Message["metadata"], + ...(status ? { status } : undefined), + }; +} + +export function auiV0Decode(stored: StoredMessage): { + parentId: string | null; + message: ThreadMessage; +} { + const payload = stored.content as AuiV0Message; + const like = { + id: stored.id, + createdAt: new Date(stored.created_at), + ...payload, + }; + const message = INTERNAL.fromThreadMessageLike( + like as Parameters[0], + stored.id, + { type: "complete", reason: "unknown" } + ); + + return { + parentId: stored.parent_id, + message, + }; +} diff --git a/src/lib/chat/custom-thread-history-adapter.tsx b/src/lib/chat/custom-thread-history-adapter.tsx new file mode 100644 index 00000000..2197383e --- /dev/null +++ b/src/lib/chat/custom-thread-history-adapter.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useMemo } from "react"; +import type { + ThreadHistoryAdapter, + ExportedMessageRepositoryItem, + GenericThreadHistoryAdapter, + MessageFormatAdapter, + MessageFormatItem, + MessageFormatRepository, + MessageStorageEntry, +} from "@assistant-ui/react"; +import { useAui } from "@assistant-ui/react"; +import { auiV0Encode, auiV0Decode } from "./aui-v0"; + +const FORMAT = "aui/v0"; + +/** + * Matches FormattedCloudPersistence / AssistantCloudThreadHistoryAdapter exactly: + * - API returns messages newest-first + * - We reverse to get oldest-first (parents before children) for MessageRepository.import() + * - headId falls back to messages.at(-1) in import(), which after reverse = newest = active branch tip + */ + +export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter { + const aui = useAui(); + + return useMemo(() => ({ + /** + * Required by useExternalHistory: the AI SDK runtime calls withFormat(aiSDKV6FormatAdapter) + * and uses the returned adapter for append/load. Without this, messages are never persisted. + */ + withFormat( + formatAdapter: MessageFormatAdapter + ): GenericThreadHistoryAdapter { + return { + async append(item: MessageFormatItem) { + const { remoteId } = await aui.threadListItem().initialize(); + const messageId = formatAdapter.getId(item.message); + const encoded = formatAdapter.encode(item) as TStorageFormat; + + const res = await fetch(`/api/threads/${remoteId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messageId, + parentId: item.parentId, + format: formatAdapter.format, + content: encoded, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error( + (err as { error?: string }).error || `Failed to save message: ${res.status}` + ); + } + }, + async load(): Promise> { + const remoteId = aui.threadListItem().getState().remoteId; + if (!remoteId) return { messages: [] }; + + const res = await fetch( + `/api/threads/${remoteId}/messages?format=${formatAdapter.format}` + ); + if (!res.ok) { + throw new Error(`Failed to load messages: ${res.status}`); + } + + const { messages } = await res.json(); + if (!Array.isArray(messages) || messages.length === 0) { + return { messages: [] }; + } + + const result = messages + .filter( + (m: { format?: string }) => m.format === formatAdapter.format + ) + .map( + (m: { id: string; parent_id: string | null; format: string; content: unknown }) => + formatAdapter.decode({ + id: m.id, + parent_id: m.parent_id, + format: m.format, + content: m.content as TStorageFormat, + } as MessageStorageEntry) + ) + .reverse(); + + return { messages: result }; + }, + }; + }, + async append({ parentId, message }: ExportedMessageRepositoryItem) { + const { remoteId } = await aui.threadListItem().initialize(); + const encoded = auiV0Encode(message); + + const res = await fetch(`/api/threads/${remoteId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messageId: message.id, + parentId, + format: FORMAT, + content: encoded, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error( + (err as { error?: string }).error || `Failed to save message: ${res.status}` + ); + } + }, + async load() { + const remoteId = aui.threadListItem().getState().remoteId; + if (!remoteId) return { messages: [] }; + + const res = await fetch( + `/api/threads/${remoteId}/messages?format=${FORMAT}` + ); + if (!res.ok) { + throw new Error(`Failed to load messages: ${res.status}`); + } + + const { messages } = await res.json(); + if (!Array.isArray(messages) || messages.length === 0) { + return { messages: [] }; + } + + const result = messages + .filter((m: { format?: string }) => m.format === FORMAT) + .map( + (m: { + id: string; + parent_id: string | null; + format: string; + content: unknown; + created_at?: string; + }) => + auiV0Decode({ + ...m, + created_at: m.created_at ?? new Date().toISOString(), + }) + ) + .reverse(); + + return { messages: result }; + }, + }), [aui]); +} diff --git a/src/lib/chat/custom-thread-list-adapter.tsx b/src/lib/chat/custom-thread-list-adapter.tsx new file mode 100644 index 00000000..be2e5eef --- /dev/null +++ b/src/lib/chat/custom-thread-list-adapter.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { type FC, type PropsWithChildren, useMemo } from "react"; +import { + type ThreadMessage, + type unstable_RemoteThreadListAdapter as RemoteThreadListAdapter, + RuntimeAdapterProvider, +} from "@assistant-ui/react"; +import { createAssistantStream } from "assistant-stream"; +import { useCustomThreadHistoryAdapter } from "./custom-thread-history-adapter"; +import { SupabaseAttachmentAdapter } from "@/lib/attachments/supabase-attachment-adapter"; + +const attachmentsInstance = new SupabaseAttachmentAdapter(); + +function CustomThreadListProviderInner({ + children, +}: PropsWithChildren) { + const history = useCustomThreadHistoryAdapter(); + const adapters = useMemo( + () => ({ + history, + attachments: attachmentsInstance, + }), + [history] + ); + return ( + + {children} + + ); +} + +export function createThreadListAdapter( + workspaceId: string +): RemoteThreadListAdapter { + const unstable_Provider: FC = function CustomThreadListProvider({ + children, + }) { + return {children}; + }; + + return { + async list() { + const res = await fetch(`/api/threads?workspaceId=${workspaceId}`); + if (!res.ok) throw new Error(`Failed to list threads: ${res.status}`); + const data = await res.json(); + return { + threads: (data.threads ?? []).map((t: { remoteId: string; status?: string; title?: string; externalId?: string }) => ({ + remoteId: t.remoteId, + status: t.status ?? "regular", + title: t.title, + externalId: t.externalId, + })), + }; + }, + + async initialize(_localId: string) { + const res = await fetch("/api/threads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as { error?: string }).error ?? `Failed to create thread: ${res.status}`); + } + const data = await res.json(); + return { + remoteId: data.remoteId ?? data.id, + externalId: data.externalId, + }; + }, + + async rename(remoteId: string, title: string) { + const res = await fetch(`/api/threads/${remoteId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }); + if (!res.ok) throw new Error(`Failed to rename: ${res.status}`); + }, + + async archive(remoteId: string) { + const res = await fetch(`/api/threads/${remoteId}/archive`, { + method: "POST", + }); + if (!res.ok) throw new Error(`Failed to archive: ${res.status}`); + }, + + async unarchive(remoteId: string) { + const res = await fetch(`/api/threads/${remoteId}/unarchive`, { + method: "POST", + }); + if (!res.ok) throw new Error(`Failed to unarchive: ${res.status}`); + }, + + async delete(remoteId: string) { + const res = await fetch(`/api/threads/${remoteId}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Failed to delete: ${res.status}`); + }, + + async fetch(remoteId: string) { + const res = await fetch(`/api/threads/${remoteId}`); + if (!res.ok) throw new Error(`Failed to fetch thread: ${res.status}`); + const data = await res.json(); + return { + remoteId: data.remoteId ?? data.id, + status: data.status ?? "regular", + title: data.title, + externalId: data.externalId, + }; + }, + + async generateTitle( + remoteId: string, + messages: readonly ThreadMessage[] + ) { + return createAssistantStream(async (controller) => { + const res = await fetch(`/api/threads/${remoteId}/title`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages }), + }); + if (!res.ok) { + controller.appendText("New Chat"); + return; + } + const data = await res.json(); + controller.appendText(data.title ?? "New Chat"); + }); + }, + + unstable_Provider, + }; +} diff --git a/src/lib/db/relations.ts b/src/lib/db/relations.ts index b4daca53..b442377d 100644 --- a/src/lib/db/relations.ts +++ b/src/lib/db/relations.ts @@ -1,5 +1,11 @@ import { relations } from "drizzle-orm/relations"; -import { workspaces, workspaceSnapshots, workspaceEvents } from "./schema"; +import { + workspaces, + workspaceSnapshots, + workspaceEvents, + chatThreads, + chatMessages, +} from "./schema"; // workspace_shares removed - sharing is now fork-based (users import copies) @@ -20,4 +26,19 @@ export const workspaceEventsRelations = relations(workspaceEvents, ({ one }) => fields: [workspaceEvents.workspaceId], references: [workspaces.id] }), +})); + +export const chatThreadsRelations = relations(chatThreads, ({ one, many }) => ({ + workspace: one(workspaces, { + fields: [chatThreads.workspaceId], + references: [workspaces.id], + }), + messages: many(chatMessages), +})); + +export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({ + thread: one(chatThreads, { + fields: [chatMessages.threadId], + references: [chatThreads.id], + }), })); \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index d7b9bc96..32b96800 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -265,3 +265,73 @@ export const workspaceShareLinks = pgTable("workspace_share_links", { FROM workspace_collaborators c WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))` }), ]); + +// Chat threads - workspace-scoped conversations +export const chatThreads = pgTable("chat_threads", { + id: uuid().defaultRandom().primaryKey().notNull(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + title: text("title"), + isArchived: boolean("is_archived").default(false), + externalId: text("external_id"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }) + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "string" }) + .defaultNow() + .$onUpdate(() => new Date().toISOString()), + lastMessageAt: timestamp("last_message_at", { + withTimezone: true, + mode: "string", + }).defaultNow(), +}, (table) => [ + index("idx_chat_threads_workspace").using( + "btree", + table.workspaceId.asc().nullsLast().op("uuid_ops") + ), + index("idx_chat_threads_user").using( + "btree", + table.userId.asc().nullsLast().op("text_ops") + ), + index("idx_chat_threads_last_message").using( + "btree", + table.workspaceId.asc().nullsLast().op("uuid_ops"), + table.lastMessageAt.desc().nullsFirst().op("timestamptz_ops") + ), + pgPolicy("Users can manage threads in their workspaces", { + as: "permissive", + for: "all", + to: ["authenticated"], + using: sql`(EXISTS ( SELECT 1 FROM workspaces w + WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) + OR (EXISTS ( SELECT 1 FROM workspace_collaborators c + WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))`, + }), +]); + +// Chat messages - stored per thread +export const chatMessages = pgTable("chat_messages", { + id: uuid().defaultRandom().primaryKey().notNull(), + threadId: uuid("thread_id") + .notNull() + .references(() => chatThreads.id, { onDelete: "cascade" }), + messageId: text("message_id").notNull(), + parentId: text("parent_id"), + format: text("format").notNull(), + content: jsonb().notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }) + .defaultNow(), +}, (table) => [ + index("idx_chat_messages_thread").using( + "btree", + table.threadId.asc().nullsLast().op("uuid_ops") + ), + index("idx_chat_messages_thread_created").using( + "btree", + table.threadId.asc().nullsLast().op("uuid_ops"), + table.createdAt.asc().nullsLast().op("timestamptz_ops") + ), +]); diff --git a/tsconfig.json b/tsconfig.json index b575f7da..26bd994a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "assistant-ui-main" ] } From 1c125a9032dcbf114737aee8d68c694cde94edc1 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:34:53 -0500 Subject: [PATCH 05/56] fix: thread adapter --- src/app/api/threads/[id]/messages/route.ts | 8 +- src/lib/chat/aui-v0.ts | 124 --------- .../chat/custom-thread-history-adapter.tsx | 246 +++++++++--------- 3 files changed, 127 insertions(+), 251 deletions(-) delete mode 100644 src/lib/chat/aui-v0.ts diff --git a/src/app/api/threads/[id]/messages/route.ts b/src/app/api/threads/[id]/messages/route.ts index b9a0e535..4e7d62dd 100644 --- a/src/app/api/threads/[id]/messages/route.ts +++ b/src/app/api/threads/[id]/messages/route.ts @@ -24,8 +24,8 @@ async function getThreadAndVerify(id: string, userId: string) { } /** - * GET /api/threads/[id]/messages?format=aui/v0 - * Load messages for a thread + * GET /api/threads/[id]/messages?format=ai-sdk/v6 + * Load messages for a thread. Format filter is strict (ai-sdk/v6 only). */ export async function GET( req: NextRequest, @@ -35,7 +35,7 @@ export async function GET( const userId = await requireAuth(); const { id } = await params; const { searchParams } = new URL(req.url); - const format = searchParams.get("format") ?? "aui/v0"; + const format = searchParams.get("format") ?? "ai-sdk/v6"; await getThreadAndVerify(id, userId); @@ -46,7 +46,7 @@ export async function GET( .orderBy(desc(chatMessages.createdAt)); const messages = rows - .filter((r) => format === "aui/v0" || r.format === format) + .filter((r) => r.format === format) .map((r) => ({ id: r.messageId, parent_id: r.parentId, diff --git a/src/lib/chat/aui-v0.ts b/src/lib/chat/aui-v0.ts deleted file mode 100644 index 4a265091..00000000 --- a/src/lib/chat/aui-v0.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { ThreadMessage } from "@assistant-ui/react"; -import { INTERNAL } from "@assistant-ui/react"; - -type StoredMessage = { - id: string; - parent_id: string | null; - format: string; - content: unknown; - created_at: string; -}; - -type AuiV0MessagePart = - | { readonly type: "text"; readonly text: string } - | { readonly type: "reasoning"; readonly text: string } - | { - readonly type: "source"; - readonly sourceType: "url"; - readonly id: string; - readonly url: string; - readonly title?: string; - } - | { - readonly type: "tool-call"; - readonly toolCallId: string; - readonly toolName: string; - readonly args?: Record; - readonly argsText?: string; - readonly result?: unknown; - readonly isError?: true; - } - | { readonly type: "image"; readonly image: string } - | { - readonly type: "file"; - readonly data: string; - readonly mimeType: string; - readonly filename?: string; - }; - -type AuiV0Message = { - readonly role: "assistant" | "user" | "system"; - readonly status?: { type: string; reason?: string }; - readonly content: readonly AuiV0MessagePart[]; - readonly metadata: { - readonly unstable_state?: unknown; - readonly unstable_annotations: readonly unknown[]; - readonly unstable_data: readonly unknown[]; - readonly steps: readonly { readonly usage?: { inputTokens?: number; outputTokens?: number } }[]; - readonly custom: Record; - }; -}; - -export function auiV0Encode(message: ThreadMessage): AuiV0Message { - const status = - message.status?.type === "running" - ? ({ type: "incomplete", reason: "cancelled" } as const) - : message.status; - - return { - role: message.role, - content: message.content.map((part) => { - const type = part.type; - switch (type) { - case "text": - return { type: "text", text: part.text }; - case "reasoning": - return { type: "reasoning", text: part.text }; - case "source": - return { - type: "source", - sourceType: part.sourceType, - id: part.id, - url: part.url, - ...(part.title ? { title: part.title } : undefined), - }; - case "tool-call": - return { - type: "tool-call", - toolCallId: part.toolCallId, - toolName: part.toolName, - ...(JSON.stringify(part.args) === part.argsText - ? { args: part.args } - : { argsText: part.argsText }), - ...(part.result !== undefined ? { result: part.result } : undefined), - ...(part.isError ? { isError: true as const } : undefined), - }; - case "image": - return { type: "image", image: part.image }; - case "file": - return { - type: "file", - data: part.data, - mimeType: part.mimeType, - ...(part.filename ? { filename: part.filename } : undefined), - }; - default: - throw new Error(`Message part type not supported by aui/v0: ${(part as { type: string }).type}`); - } - }), - metadata: message.metadata as AuiV0Message["metadata"], - ...(status ? { status } : undefined), - }; -} - -export function auiV0Decode(stored: StoredMessage): { - parentId: string | null; - message: ThreadMessage; -} { - const payload = stored.content as AuiV0Message; - const like = { - id: stored.id, - createdAt: new Date(stored.created_at), - ...payload, - }; - const message = INTERNAL.fromThreadMessageLike( - like as Parameters[0], - stored.id, - { type: "complete", reason: "unknown" } - ); - - return { - parentId: stored.parent_id, - message, - }; -} diff --git a/src/lib/chat/custom-thread-history-adapter.tsx b/src/lib/chat/custom-thread-history-adapter.tsx index 2197383e..3928fa77 100644 --- a/src/lib/chat/custom-thread-history-adapter.tsx +++ b/src/lib/chat/custom-thread-history-adapter.tsx @@ -3,7 +3,6 @@ import { useMemo } from "react"; import type { ThreadHistoryAdapter, - ExportedMessageRepositoryItem, GenericThreadHistoryAdapter, MessageFormatAdapter, MessageFormatItem, @@ -11,143 +10,144 @@ import type { MessageStorageEntry, } from "@assistant-ui/react"; import { useAui } from "@assistant-ui/react"; -import { auiV0Encode, auiV0Decode } from "./aui-v0"; - -const FORMAT = "aui/v0"; /** - * Matches FormattedCloudPersistence / AssistantCloudThreadHistoryAdapter exactly: - * - API returns messages newest-first - * - We reverse to get oldest-first (parents before children) for MessageRepository.import() - * - headId falls back to messages.at(-1) in import(), which after reverse = newest = active branch tip + * Sorts messages so parents always come before their children. + * Required for MessageRepository.import() which expects parent to exist before child. + * Uses topological sort with created_at + id as tiebreakers for stable ordering. */ +function sortParentsBeforeChildren< + T extends { parentId: string | null; message: { id: string } } +>(items: T[], getCreatedAt: (item: T) => string): T[] { + const output: T[] = []; + const added = new Set(); + + const getReady = () => + items.filter( + (m) => + !added.has(m.message.id) && + (m.parentId === null || added.has(m.parentId)) + ); + + while (output.length < items.length) { + const ready = getReady(); + if (ready.length === 0) { + // Orphan or circular ref: add remaining by created_at to avoid infinite loop + const remaining = items.filter((m) => !added.has(m.message.id)); + remaining.sort((a, b) => { + const tA = new Date(getCreatedAt(a)).getTime(); + const tB = new Date(getCreatedAt(b)).getTime(); + return tA - tB || a.message.id.localeCompare(b.message.id); + }); + output.push(...remaining); + break; + } + ready.sort((a, b) => { + const tA = new Date(getCreatedAt(a)).getTime(); + const tB = new Date(getCreatedAt(b)).getTime(); + return tA - tB || a.message.id.localeCompare(b.message.id); + }); + const next = ready[0]!; + output.push(next); + added.add(next.message.id); + } + + return output; +} +/** + * AI SDK–only thread history adapter. + * Uses withFormat(aiSDKV6FormatAdapter) for persistence via useExternalHistory. + */ export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter { const aui = useAui(); - return useMemo(() => ({ - /** - * Required by useExternalHistory: the AI SDK runtime calls withFormat(aiSDKV6FormatAdapter) - * and uses the returned adapter for append/load. Without this, messages are never persisted. - */ - withFormat( - formatAdapter: MessageFormatAdapter - ): GenericThreadHistoryAdapter { - return { - async append(item: MessageFormatItem) { - const { remoteId } = await aui.threadListItem().initialize(); - const messageId = formatAdapter.getId(item.message); - const encoded = formatAdapter.encode(item) as TStorageFormat; - - const res = await fetch(`/api/threads/${remoteId}/messages`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - messageId, - parentId: item.parentId, - format: formatAdapter.format, - content: encoded, - }), - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error( - (err as { error?: string }).error || `Failed to save message: ${res.status}` + return useMemo( + () => ({ + withFormat( + formatAdapter: MessageFormatAdapter + ): GenericThreadHistoryAdapter { + return { + async append(item: MessageFormatItem) { + const { remoteId } = await aui.threadListItem().initialize(); + const messageId = formatAdapter.getId(item.message); + const encoded = formatAdapter.encode(item) as TStorageFormat; + + const res = await fetch(`/api/threads/${remoteId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messageId, + parentId: item.parentId, + format: formatAdapter.format, + content: encoded, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error( + (err as { error?: string }).error || + `Failed to save message: ${res.status}` + ); + } + }, + async load(): Promise> { + const remoteId = aui.threadListItem().getState().remoteId; + if (!remoteId) return { messages: [] }; + + const res = await fetch( + `/api/threads/${remoteId}/messages?format=${formatAdapter.format}` ); - } - }, - async load(): Promise> { - const remoteId = aui.threadListItem().getState().remoteId; - if (!remoteId) return { messages: [] }; + if (!res.ok) { + throw new Error(`Failed to load messages: ${res.status}`); + } - const res = await fetch( - `/api/threads/${remoteId}/messages?format=${formatAdapter.format}` - ); - if (!res.ok) { - throw new Error(`Failed to load messages: ${res.status}`); - } + const { messages } = await res.json(); + if (!Array.isArray(messages) || messages.length === 0) { + return { messages: [] }; + } - const { messages } = await res.json(); - if (!Array.isArray(messages) || messages.length === 0) { - return { messages: [] }; - } - - const result = messages - .filter( + const filtered = messages.filter( (m: { format?: string }) => m.format === formatAdapter.format - ) - .map( - (m: { id: string; parent_id: string | null; format: string; content: unknown }) => - formatAdapter.decode({ + ); + const decoded = filtered.map( + (m: { + id: string; + parent_id: string | null; + format: string; + content: unknown; + created_at?: string; + }) => { + const d = formatAdapter.decode({ id: m.id, parent_id: m.parent_id, format: m.format, content: m.content as TStorageFormat, - } as MessageStorageEntry) - ) - .reverse(); - - return { messages: result }; - }, - }; - }, - async append({ parentId, message }: ExportedMessageRepositoryItem) { - const { remoteId } = await aui.threadListItem().initialize(); - const encoded = auiV0Encode(message); - - const res = await fetch(`/api/threads/${remoteId}/messages`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - messageId: message.id, - parentId, - format: FORMAT, - content: encoded, - }), - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error( - (err as { error?: string }).error || `Failed to save message: ${res.status}` - ); - } - }, - async load() { - const remoteId = aui.threadListItem().getState().remoteId; - if (!remoteId) return { messages: [] }; - - const res = await fetch( - `/api/threads/${remoteId}/messages?format=${FORMAT}` - ); - if (!res.ok) { - throw new Error(`Failed to load messages: ${res.status}`); - } - - const { messages } = await res.json(); - if (!Array.isArray(messages) || messages.length === 0) { - return { messages: [] }; - } + } as MessageStorageEntry); + return { + ...d, + created_at: m.created_at ?? new Date().toISOString(), + }; + } + ); - const result = messages - .filter((m: { format?: string }) => m.format === FORMAT) - .map( - (m: { - id: string; - parent_id: string | null; - format: string; - content: unknown; - created_at?: string; - }) => - auiV0Decode({ - ...m, - created_at: m.created_at ?? new Date().toISOString(), - }) - ) - .reverse(); + // Topological sort: parents before children for MessageRepository.import() + const sorted = sortParentsBeforeChildren( + decoded, + (d) => d.created_at ?? new Date().toISOString() + ); - return { messages: result }; - }, - }), [aui]); + return { + messages: sorted.map(({ parentId, message }) => ({ + parentId, + message, + })), + }; + }, + }; + }, + }), + [aui] + ); } From 8df461ee8f6442c3a17d3b474933850af6389c57 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:23:04 -0500 Subject: [PATCH 06/56] feat: show ui for added context --- .../assistant-ui/WorkspaceRuntimeProvider.tsx | 2 + src/components/assistant-ui/thread.tsx | 2 + src/components/chat/MessageContextBadges.tsx | 72 +++++++++++++++++++ src/lib/chat/toCreateMessageWithContext.ts | 62 ++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 src/components/chat/MessageContextBadges.tsx create mode 100644 src/lib/chat/toCreateMessageWithContext.ts diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index 78567ab1..ba89adfc 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -12,6 +12,7 @@ import { toast } from "sonner"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context"; import { createThreadListAdapter } from "@/lib/chat/custom-thread-list-adapter"; +import { toCreateMessageWithContext } from "@/lib/chat/toCreateMessageWithContext"; interface WorkspaceRuntimeProviderProps { workspaceId: string; @@ -111,6 +112,7 @@ export function WorkspaceRuntimeProvider({ useChatRuntime({ transport, onError: handleChatError, + toCreateMessage: toCreateMessageWithContext, }), adapter: threadListAdapter, }); diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index cafa29cd..55f8c1ad 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -96,6 +96,7 @@ import { ToolGroup } from "@/components/assistant-ui/tool-group"; import type { Item, PdfData } from "@/lib/workspace-state/types"; import { CardContextDisplay } from "@/components/chat/CardContextDisplay"; import { ReplyContextDisplay } from "@/components/chat/ReplyContextDisplay"; +import { MessageContextBadges } from "@/components/chat/MessageContextBadges"; import { MentionMenu } from "@/components/chat/MentionMenu"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useUIStore, selectReplySelections, selectSelectedCardIdsArray, selectBlockNoteSelection } from "@/lib/stores/ui-store"; @@ -1382,6 +1383,7 @@ const UserMessage: FC = () => {
+
+ text.length <= max ? text : text.slice(0, max).trim() + "..."; + +/** + * Renders persisted context badges (BlockNote selection, reply) + * from message.metadata.custom when viewing user messages in history. + */ +function MessageContextBadgesImpl() { + const message = useAuiState((s) => s.message); + if (!message || message.role !== "user") return null; + + const custom = (message.metadata as { custom?: MessageCustomMetadata } | undefined)?.custom; + if (!custom) return null; + + const { blockNoteSelection, replySelections } = custom; + const hasAny = blockNoteSelection || (replySelections && replySelections.length > 0); + if (!hasAny) return null; + + return ( +
+ {blockNoteSelection && ( +
+ + + From "{blockNoteSelection.cardName}": {truncate(blockNoteSelection.text, 40)} + +
+ )} + {replySelections?.map((sel, i) => ( +
+ + + {truncate(sel.text, 40)} + +
+ ))} +
+ ); +} + +export const MessageContextBadges = memo(MessageContextBadgesImpl); diff --git a/src/lib/chat/toCreateMessageWithContext.ts b/src/lib/chat/toCreateMessageWithContext.ts new file mode 100644 index 00000000..9be2a672 --- /dev/null +++ b/src/lib/chat/toCreateMessageWithContext.ts @@ -0,0 +1,62 @@ +import type { AppendMessage } from "@assistant-ui/react"; +import { + CreateUIMessage, + UIDataTypes, + UIMessage, + UIMessagePart, + UITools, +} from "ai"; + +/** + * Custom toCreateMessage that merges runConfig (BlockNote selection, reply, selected cards) + * into the UIMessage metadata so it persists and can be rendered when viewing message history. + * Based on @assistant-ui/react-ai-sdk toCreateMessage with runConfig merged into metadata. + */ +export function toCreateMessageWithContext< + UI_MESSAGE extends UIMessage = UIMessage, +>(message: AppendMessage): CreateUIMessage { + const inputParts = [ + ...message.content.filter((c) => c.type !== "file"), + ...(message.attachments?.flatMap((a) => + a.content.map((c) => ({ + ...c, + filename: a.name, + })), + ) ?? []), + ]; + + const parts = inputParts.map((part): UIMessagePart => { + switch (part.type) { + case "text": + return { type: "text", text: part.text }; + case "image": + return { + type: "file", + url: part.image, + ...(part.filename && { filename: part.filename }), + mediaType: "image/png", + }; + case "file": + return { + type: "file", + url: part.data, + mediaType: part.mimeType, + ...(part.filename && { filename: part.filename }), + }; + default: + throw new Error(`Unsupported part type: ${(part as { type: string }).type}`); + } + }); + + const runConfig = message.runConfig as Record | undefined; + const metadata = { + ...message.metadata, + ...(runConfig && Object.keys(runConfig).length > 0 ? runConfig : {}), + }; + + return { + role: message.role, + parts, + metadata, + } as CreateUIMessage; +} From 15eaf88ce54dda366a1b2016f9a63d453a9e661a Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:07:11 -0500 Subject: [PATCH 07/56] feat: remove old context --- src/app/api/chat/route.ts | 10 +++++- .../FlashcardWorkspaceCard.tsx | 4 --- .../workspace-canvas/FolderCard.tsx | 6 +--- .../workspace-canvas/WorkspaceCard.tsx | 4 --- src/contexts/AssistantAvailabilityContext.tsx | 2 +- src/hooks/ai/use-card-context-provider.ts | 33 ------------------- 6 files changed, 11 insertions(+), 48 deletions(-) delete mode 100644 src/hooks/ai/use-card-context-provider.ts diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 34b1b3d6..9392bdc3 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,5 +1,5 @@ import { gateway } from "ai"; -import { streamText, smoothStream, convertToModelMessages, stepCountIs, wrapLanguageModel, tool } from "ai"; +import { streamText, smoothStream, convertToModelMessages, pruneMessages, stepCountIs, wrapLanguageModel, tool } from "ai"; import { devToolsMiddleware } from "@ai-sdk/devtools"; import { PostHog } from "posthog-node"; import { withTracing } from "@posthog/ai"; @@ -203,6 +203,14 @@ export async function POST(req: Request) { throw convertError; } + // Prune older reasoning and tool calls to save context + convertedMessages = pruneMessages({ + messages: convertedMessages, + reasoning: "before-last-message", + toolCalls: "before-last-5-messages", + emptyMessages: "remove", + }); + // Process messages in single pass: extract URLs and clean markers const { urlContextUrls, cleanedMessages } = processMessages(convertedMessages); diff --git a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx index 838f7d23..a822d5e1 100644 --- a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx +++ b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx @@ -13,7 +13,6 @@ import { useUIStore, selectItemScrollLocked } from "@/lib/stores/ui-store"; import { plainTextToBlocks, type Block } from "@/components/editor/BlockNoteEditor"; import { BlockNotePreview, PreviewBlock } from "@/components/editor/BlockNotePreview"; import { generateItemId } from "@/lib/workspace-state/item-helpers"; -import { useCardContextProvider } from "@/hooks/ai/use-card-context-provider"; import { DropdownMenu, DropdownMenuContent, @@ -167,9 +166,6 @@ export function FlashcardWorkspaceCard({ onOpenModal, onMoveItem, }: FlashcardWorkspaceCardProps) { - // Register this card's minimal context (title, id, type) with the assistant - useCardContextProvider(item); - // Subscribe directly to this card's selection state from the store // This prevents full grid re-renders when selection changes const isSelected = useUIStore( diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx index 5bff8e66..54c94abe 100644 --- a/src/components/workspace-canvas/FolderCard.tsx +++ b/src/components/workspace-canvas/FolderCard.tsx @@ -10,7 +10,6 @@ import { getCardColorCSS, getCardAccentColor, SWATCHES_COLOR_GROUPS, type CardCo import { useTheme } from "next-themes"; import { useUIStore } from "@/lib/stores/ui-store"; -import { useCardContextProvider } from "@/hooks/ai/use-card-context-provider"; import { DropdownMenu, DropdownMenuContent, @@ -91,10 +90,7 @@ function FolderCardComponent({ const isSelected = useUIStore( (state) => state.selectedCardIds.has(item.id) ); - const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - - // Register folder context with assistant - useCardContextProvider(item); + const onToggleSelection = useUIStore((state) => state.toggleCardSelection); // Track drag movement to prevent opening folder after drag const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null); diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 57898681..bf5b1ab3 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -27,7 +27,6 @@ import "react-quizlet-flashcard/dist/index.css"; import ReactMarkdown from "react-markdown"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; -import { useCardContextProvider } from "@/hooks/ai/use-card-context-provider"; import { useElementSize } from "@/hooks/use-element-size"; import { useIsVisible } from "@/hooks/use-is-visible"; import { extractYouTubeVideoId, extractYouTubePlaylistId } from "@/lib/utils/youtube-url"; @@ -228,9 +227,6 @@ function WorkspaceCard({ const viewMode = useUIStore((state) => state.viewMode); const splitWithItem = useUIStore((state) => state.splitWithItem); - // Register this card's minimal context (title, id, type) with the assistant - useCardContextProvider(item); - // No dynamic calculations needed - just overflow hidden const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); diff --git a/src/contexts/AssistantAvailabilityContext.tsx b/src/contexts/AssistantAvailabilityContext.tsx index 58744a26..d3d807da 100644 --- a/src/contexts/AssistantAvailabilityContext.tsx +++ b/src/contexts/AssistantAvailabilityContext.tsx @@ -4,7 +4,7 @@ import { createContext, useContext, type ReactNode } from "react"; /** * Context to indicate whether the AI assistant is available - * Used by hooks like useCardContextProvider to skip AI-related operations + * Used by hooks that need to skip AI-related operations * when not inside an AssistantProvider (e.g., in guest mode) */ interface AssistantAvailabilityContextType { diff --git a/src/hooks/ai/use-card-context-provider.ts b/src/hooks/ai/use-card-context-provider.ts deleted file mode 100644 index 9765f7b4..00000000 --- a/src/hooks/ai/use-card-context-provider.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useAssistantInstructions } from "@assistant-ui/react"; -import { useEffect, useMemo } from "react"; -import type { Item } from "@/lib/workspace-state/types"; -import { useIsAssistantAvailable } from "@/contexts/AssistantAvailabilityContext"; - -/** - * Hook that registers minimal context for an individual card - * Each card registers its own context (title, id, type) using modelContext API - * Automatically updates when card properties change and cleans up on unmount - * - * Note: Gracefully handles missing AssistantProvider (e.g., in guest mode) - * by checking useIsAssistantAvailable() first and becoming a no-op - */ -export function useCardContextProvider(item: Item) { - const isAssistantAvailable = useIsAssistantAvailable(); - - // Format minimal card context - memoized to avoid recalculation - // Only include type and title, not ID (tools will handle ID lookup by title) - const cardContext = useMemo(() => { - return ``; - }, [item.name, item.type]); - - // Use the high-level hook to register instructions - // This automatically handles cleanup and updates - // We disable it if the assistant isn't available (e.g. guest mode) - useAssistantInstructions({ - instruction: cardContext, - disabled: !isAssistantAvailable, - }); -} - -// Re-export for components that need more control -export { useIsAssistantAvailable }; From a72da653d43b6619be0600e3db8f2738916b9ab4 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:52:55 -0500 Subject: [PATCH 08/56] fix: more features --- src/app/api/workspaces/[id]/events/route.ts | 33 +++++ .../assistant-ui/GrepWorkspaceToolUI.tsx | 86 ++++++++++++ .../assistant-ui/ReadWorkspaceToolUI.tsx | 75 ++++++++++ .../assistant-ui/WorkspaceRuntimeProvider.tsx | 4 +- src/components/assistant-ui/thread.tsx | 5 +- .../workspace/use-workspace-operations.ts | 60 ++++++-- src/lib/ai/tools/grep-workspace.ts | 130 ++++++++++++++++++ src/lib/ai/tools/index.ts | 4 + src/lib/ai/tools/read-workspace.ts | 96 +++++++++++++ src/lib/ai/tools/workspace-search-utils.ts | 114 +++++++++++++++ src/lib/ai/tools/workspace-tools.ts | 8 +- src/lib/ai/workers/workspace-worker.ts | 36 +++++ .../chat/custom-thread-history-adapter.tsx | 24 +++- src/lib/utils/format-workspace-context.ts | 64 +++++++-- src/lib/workspace/unique-name.ts | 33 +++++ 15 files changed, 741 insertions(+), 31 deletions(-) create mode 100644 src/components/assistant-ui/GrepWorkspaceToolUI.tsx create mode 100644 src/components/assistant-ui/ReadWorkspaceToolUI.tsx create mode 100644 src/lib/ai/tools/grep-workspace.ts create mode 100644 src/lib/ai/tools/read-workspace.ts create mode 100644 src/lib/ai/tools/workspace-search-utils.ts create mode 100644 src/lib/workspace/unique-name.ts diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index bf80ab52..945b3b77 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events"; import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; +import { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import { hasDuplicateName } from "@/lib/workspace/unique-name"; import { db, workspaceEvents } from "@/lib/db/client"; import { eq, gt, asc, sql, and } from "drizzle-orm"; import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; @@ -232,6 +234,37 @@ async function handlePOST( await verifyWorkspaceAccess(id, userId, 'editor'); timings.workspaceCheck = Date.now() - workspaceCheckStart; + // Validate unique name for ITEM_CREATED and ITEM_UPDATED (when name changes) + if (event.type === "ITEM_CREATED") { + const item = event.payload?.item; + if (item?.name != null && item?.type) { + const state = await loadWorkspaceState(id); + if (hasDuplicateName(state.items, item.name, item.type, item.folderId ?? null)) { + return NextResponse.json( + { error: `A ${item.type} named "${item.name}" already exists in this folder` }, + { status: 400 } + ); + } + } + } + if (event.type === "ITEM_UPDATED" && event.payload?.changes?.name != null) { + const itemId = event.payload?.id; + const newName = event.payload.changes.name; + if (itemId && newName) { + const state = await loadWorkspaceState(id); + const existingItem = state.items.find((i: { id: string }) => i.id === itemId); + if (existingItem) { + const newFolderId = event.payload.changes.folderId ?? existingItem.folderId ?? null; + if (hasDuplicateName(state.items, newName, existingItem.type, newFolderId, itemId)) { + return NextResponse.json( + { error: `A ${existingItem.type} named "${newName}" already exists in this folder` }, + { status: 400 } + ); + } + } + } + } + // Use the append function to handle versioning and conflicts const appendStart = Date.now(); const result = await db.execute(sql` diff --git a/src/components/assistant-ui/GrepWorkspaceToolUI.tsx b/src/components/assistant-ui/GrepWorkspaceToolUI.tsx new file mode 100644 index 00000000..57d43d5a --- /dev/null +++ b/src/components/assistant-ui/GrepWorkspaceToolUI.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Search } from "lucide-react"; +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; +import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; +import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; + +type GrepArgs = { pattern: string; include?: string; path?: string }; +type GrepResult = { success: boolean; matches?: number; output?: string; message?: string }; + +function GrepArgsSummary({ args }: { args?: GrepArgs }) { + if (!args?.pattern) return null; + const parts = [`pattern: "${args.pattern}"`]; + if (args.include) parts.push(`type: ${args.include}`); + if (args.path) parts.push(`path: ${args.path}`); + return ( +
+ {parts.join(" · ")} +
+ ); +} + +export const GrepWorkspaceToolUI = makeAssistantToolUI({ + toolName: "grepWorkspace", + render: ({ args, status, result }) => { + let content: React.ReactNode = null; + + if (status.type === "running") { + content = ( +
+ + +
+ ); + } else if (status.type === "complete" && result) { + if (!result.success && result.message) { + content = ( +
+ + +
+ ); + } else if (result.success && result.output) { + content = ( +
+ +
+ + {result.matches != null && ( + + {result.matches} match + {result.matches !== 1 ? "es" : ""} + + )} +
+
+                            {result.output}
+                        
+
+ ); + } + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( +
+ + +
+ ); + } + + return ( + + {content} + + ); + }, +}); diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx new file mode 100644 index 00000000..52522f86 --- /dev/null +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { FileText } from "lucide-react"; +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { StandaloneMarkdown } from "@/components/assistant-ui/standalone-markdown"; +import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; +import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; +import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; + +type ReadArgs = { path?: string; itemName?: string }; +type ReadResult = { + success: boolean; + itemName?: string; + type?: string; + path?: string; + content?: string; + message?: string; +}; + +export const ReadWorkspaceToolUI = makeAssistantToolUI({ + toolName: "readWorkspace", + render: ({ args, status, result }) => { + let content: React.ReactNode = null; + + if (status.type === "running") { + const label = args?.path + ? `Reading ${args.path}` + : args?.itemName + ? `Reading "${args.itemName}"` + : "Reading workspace item..."; + content = ; + } else if (status.type === "complete" && result) { + if (!result.success && result.message) { + content = ( + + ); + } else if (result.success && result.content) { + content = ( +
+
+ + + {result.path ?? result.itemName} + {result.type && ( + + {result.type} + + )} + +
+
+ {result.content} +
+
+ ); + } + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } + + return ( + + {content} + + ); + }, +}); diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index ba89adfc..e2d8739d 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -10,7 +10,7 @@ import { AssistantAvailableProvider } from "@/contexts/AssistantAvailabilityCont import { useUIStore } from "@/lib/stores/ui-store"; import { toast } from "sonner"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context"; +import { formatSelectedCardsMetadata } from "@/lib/utils/format-workspace-context"; import { createThreadListAdapter } from "@/lib/chat/custom-thread-list-adapter"; import { toCreateMessageWithContext } from "@/lib/chat/toCreateMessageWithContext"; @@ -44,7 +44,7 @@ export function WorkspaceRuntimeProvider({ return ""; } - return formatSelectedCardsContext(selectedItems, workspaceState.items); + return formatSelectedCardsMetadata(selectedItems, workspaceState.items); }, [workspaceState?.items, selectedCardIdsSet]); const handleChatError = useCallback((error: Error) => { diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 55f8c1ad..82eb4453 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -75,6 +75,8 @@ import { URLContextToolUI } from "@/components/assistant-ui/URLContextToolUI"; import { UpdateNoteToolUI } from "@/components/assistant-ui/UpdateNoteToolUI"; import { WebSearchToolUI } from "@/components/assistant-ui/WebSearchToolUI"; import { UpdatePdfContentToolUI } from "@/components/assistant-ui/UpdatePdfContentToolUI"; +import { GrepWorkspaceToolUI } from "@/components/assistant-ui/GrepWorkspaceToolUI"; +import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI"; import { DeleteCardToolUI } from "@/components/assistant-ui/DeleteCardToolUI"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; @@ -110,7 +112,6 @@ import { useCreateCardFromMessage } from "@/hooks/ai/use-create-card-from-messag import { extractUrls, createUrlFile } from "@/lib/attachments/url-utils"; import { filterItems } from "@/lib/workspace-state/search"; import { useSession } from "@/lib/auth-client"; -import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context"; import { focusComposerInput } from "@/lib/utils/composer-utils"; import { SpeechToTextButton } from "@/components/assistant-ui/SpeechToTextButton"; @@ -198,6 +199,8 @@ export const Thread: FC = ({ items = [] }) => { {/* */} + + { // Validate type is a valid CardType @@ -167,6 +179,15 @@ export function useWorkspaceOperations( } const id = generateItemId(); + const finalName = name || `New ${validType.charAt(0).toUpperCase() + validType.slice(1)}`; + const folderId = activeFolderId ?? null; + + // Check duplicate against existing + already-created in this batch + const allItemsSoFar = [...currentState.items, ...itemsSoFar]; + if (hasDuplicateName(allItemsSoFar, finalName, validType, folderId)) { + logger.warn(`🔧 [CREATE-ITEMS] Skipping duplicate: ${finalName} (${validType})`); + return null; + } // Merge default data with initial data const baseData = defaultDataFor(validType); @@ -199,17 +220,28 @@ export function useWorkspaceOperations( }); } - return { + const newItem: Item = { id, type: validType, - name: name || `New ${validType.charAt(0).toUpperCase() + validType.slice(1)}`, + name: finalName, subtitle: "", data: mergedData as ItemData, color: getRandomCardColor(), // Assign random color to new cards folderId: activeFolderId ?? undefined, // Auto-assign to active folder layout, }; - }); + itemsSoFar.push(newItem); + return newItem; + }).filter((item): item is Item => item !== null); + + if (createdItems.length === 0) { + toast.error("All items were skipped (duplicate names in folder)"); + return []; + } + + if (createdItems.length < items.length) { + toast.warning(`${items.length - createdItems.length} item(s) skipped due to duplicate names`); + } // Create single batch event with all items const event = createEvent("BULK_ITEMS_CREATED", { items: createdItems }, userId, userName); @@ -223,7 +255,7 @@ export function useWorkspaceOperations( // Return array of created item IDs return createdItems.map(item => item.id); }, - [mutation, userId, userName, workspaceId] + [mutation, userId, userName, workspaceId, currentState.items] ); const updateItem = useCallback( @@ -244,9 +276,21 @@ export function useWorkspaceOperations( const finalChanges = pendingItemChangesRef.current.get(id); if (finalChanges) { const item = currentState.items.find(i => i.id === id); - const name = (finalChanges as Partial).name ?? item?.name; + const newName = (finalChanges as Partial).name ?? item?.name; + const newType = (finalChanges as Partial).type ?? item?.type; + const folderId = ((finalChanges as Partial).folderId ?? item?.folderId) ?? null; + + if (newName && newType && "name" in finalChanges) { + if (hasDuplicateName(currentState.items, newName, newType, folderId, id)) { + toast.error(`A ${newType} named "${newName}" already exists in this folder`); + pendingItemChangesRef.current.delete(id); + updateItemDebounceRef.current.delete(id); + return; + } + } + logger.debug("⏱️ [DEBOUNCE] updateItem firing after 500ms:", { id, changes: finalChanges, source }); - const event = createEvent("ITEM_UPDATED", { id, changes: finalChanges, source, name }, userId, userName); + const event = createEvent("ITEM_UPDATED", { id, changes: finalChanges, source, name: newName }, userId, userName); mutation.mutate(event); // Clean up pendingItemChangesRef.current.delete(id); diff --git a/src/lib/ai/tools/grep-workspace.ts b/src/lib/ai/tools/grep-workspace.ts new file mode 100644 index 00000000..67e80a8c --- /dev/null +++ b/src/lib/ai/tools/grep-workspace.ts @@ -0,0 +1,130 @@ +import { tool, zodSchema } from "ai"; +import { z } from "zod"; +import { loadStateForTool } from "./tool-utils"; +import { extractSearchableText } from "./workspace-search-utils"; +import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs"; +import type { WorkspaceToolContext } from "./workspace-tools"; +import type { Item } from "@/lib/workspace-state/types"; + +const MAX_LINE_LENGTH = 2000; +const MAX_MATCHES = 100; + +function buildRegex(pattern: string): RegExp { + const hasRegexChars = /[.*+?^${}()|[\]\\]/.test(pattern); + if (hasRegexChars) { + try { + return new RegExp(pattern, "gi"); + } catch { + return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); + } + } + const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(escaped, "gi"); +} + +export function createGrepWorkspaceTool(ctx: WorkspaceToolContext) { + return tool({ + description: + "Search for a pattern in workspace item content (notes, flashcards, PDFs, quizzes, audio transcripts). Use when you need to find where a term or phrase appears across the user's materials.", + inputSchema: zodSchema( + z.object({ + pattern: z.string().describe("The regex or plain text pattern to search for"), + include: z + .string() + .optional() + .describe('Filter by item type, e.g. "note", "flashcard", "pdf"'), + path: z + .string() + .optional() + .describe("Virtual path prefix to scope search, e.g. Physics/ for items under Physics folder"), + }) + ), + execute: async ({ pattern, include, path: pathPrefix }) => { + if (!pattern?.trim()) { + return { success: false, message: "pattern is required", matches: 0, output: "" }; + } + + const accessResult = await loadStateForTool(ctx); + if (!accessResult.success) return accessResult; + + const { state } = accessResult; + let items: Item[] = state.items.filter((i) => i.type !== "folder"); + + if (include) { + const type = include.toLowerCase().trim(); + items = items.filter((i) => i.type === type); + } + + const normalizedPrefix = pathPrefix?.trim().replace(/\/+$/, ""); + if (normalizedPrefix) { + items = items.filter((item) => { + const vp = getVirtualPath(item, state.items); + return vp.startsWith(normalizedPrefix) || vp.startsWith(normalizedPrefix + "/"); + }); + } + + const regex = buildRegex(pattern); + const matches: { path: string; itemName: string; itemType: string; lineNum: number; lineText: string }[] = []; + + for (const item of items) { + const text = extractSearchableText(item, state.items); + if (!text) continue; + + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (regex.test(line)) { + regex.lastIndex = 0; + const truncated = + line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line; + matches.push({ + path: getVirtualPath(item, state.items), + itemName: item.name, + itemType: item.type, + lineNum: i + 1, + lineText: truncated, + }); + } + } + } + + const truncated = matches.length > MAX_MATCHES; + const finalMatches = truncated ? matches.slice(0, MAX_MATCHES) : matches; + + if (finalMatches.length === 0) { + return { + success: true, + matches: 0, + truncated: false, + output: `No matches found for "${pattern}"${normalizedPrefix ? ` in ${normalizedPrefix}` : ""}`, + }; + } + + const outputLines: string[] = [ + `Found ${matches.length} matches${truncated ? ` (showing first ${MAX_MATCHES})` : ""}`, + ]; + let currentPath = ""; + for (const m of finalMatches) { + if (currentPath !== m.path) { + if (currentPath) outputLines.push(""); + currentPath = m.path; + outputLines.push(`${m.path}:`); + } + outputLines.push(` Line ${m.lineNum}: ${m.lineText}`); + } + if (truncated) { + outputLines.push(""); + outputLines.push( + `(Results truncated: showing ${MAX_MATCHES} of ${matches.length} matches. Consider using a more specific path or pattern.)` + ); + } + + return { + success: true, + matches: matches.length, + truncated, + output: outputLines.join("\n"), + }; + }, + }); +} diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index 08e0751d..ad3df2fb 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -21,6 +21,8 @@ import { createUpdatePdfContentTool } from "./pdf-tools"; import { createSearchYoutubeTool, createAddYoutubeVideoTool } from "./youtube-tools"; import { createSearchImagesTool, createAddImageTool } from "./image-tools"; import { createWebSearchTool } from "./web-search"; +import { createGrepWorkspaceTool } from "./grep-workspace"; +import { createReadWorkspaceTool } from "./read-workspace"; import { logger } from "@/lib/utils/logger"; export interface ChatToolsConfig { @@ -57,6 +59,8 @@ export function createChatTools(config: ChatToolsConfig): Record { // Search & code execution webSearch: createWebSearchTool(), executeCode: createExecuteCodeTool(), + grepWorkspace: createGrepWorkspaceTool(ctx), + readWorkspace: createReadWorkspaceTool(ctx), // Workspace operations createNote: createNoteTool(ctx), diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts new file mode 100644 index 00000000..7fb03f5e --- /dev/null +++ b/src/lib/ai/tools/read-workspace.ts @@ -0,0 +1,96 @@ +import { tool, zodSchema } from "ai"; +import { z } from "zod"; +import { loadStateForTool } from "./tool-utils"; +import { fuzzyMatchItem } from "./tool-utils"; +import { resolveItemByPath } from "./workspace-search-utils"; +import { formatItemContent } from "@/lib/utils/format-workspace-context"; +import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs"; +import type { WorkspaceToolContext } from "./workspace-tools"; + +export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { + return tool({ + description: + "Read the full content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Use path when items share the same name. Use when you need the complete content of a specific card to answer questions or perform updates.", + inputSchema: zodSchema( + z.object({ + path: z + .string() + .optional() + .describe( + "Virtual path (e.g. Physics/notes/Thermodynamics.md) — unambiguous when duplicates exist" + ), + itemName: z + .string() + .optional() + .describe( + "Name for fuzzy match — use when path unknown; if multiple items share the name, use path instead" + ), + }) + ), + execute: async ({ path, itemName }) => { + if (!path?.trim() && !itemName?.trim()) { + return { + success: false, + message: "Either path or itemName is required", + }; + } + + const accessResult = await loadStateForTool(ctx); + if (!accessResult.success) return accessResult; + + const { state } = accessResult; + const items = state.items; + + let item = null; + + if (path?.trim()) { + item = resolveItemByPath(items, path.trim()); + } + + if (!item && itemName?.trim()) { + const exactMatches = items.filter( + (i) => + i.type !== "folder" && + i.name.toLowerCase().trim() === itemName.toLowerCase().trim() + ); + if (exactMatches.length > 1) { + const paths = exactMatches.map((m) => getVirtualPath(m, items)).join(", "); + return { + success: false, + message: `Multiple items named "${itemName}". Use path to disambiguate: ${paths}`, + }; + } + item = fuzzyMatchItem(items, itemName.trim()); + } + + if (!item) { + const contentItems = items.filter((i) => i.type !== "folder"); + const sample = contentItems.slice(0, 5).map((i) => getVirtualPath(i, items)).join(", "); + return { + success: false, + message: `Item not found${itemName ? `: "${itemName}"` : ` at path: ${path}`}. ${ + sample ? `Example paths: ${sample}` : "Workspace may be empty. Use grep to search." + }`, + }; + } + + if (item.type === "folder") { + return { + success: false, + message: "Folders have no readable content. Use path to a note, flashcard, PDF, or quiz.", + }; + } + + const content = formatItemContent(item); + const vpath = getVirtualPath(item, items); + + return { + success: true, + itemName: item.name, + type: item.type, + path: vpath, + content, + }; + }, + }); +} diff --git a/src/lib/ai/tools/workspace-search-utils.ts b/src/lib/ai/tools/workspace-search-utils.ts new file mode 100644 index 00000000..5233e3b0 --- /dev/null +++ b/src/lib/ai/tools/workspace-search-utils.ts @@ -0,0 +1,114 @@ +/** + * Shared utilities for workspace grep and read tools. + */ + +import type { Item, NoteData, PdfData, FlashcardData, FlashcardItem, QuizData, AudioData } from "@/lib/workspace-state/types"; +import { serializeBlockNote } from "@/lib/utils/serialize-blocknote"; +import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs"; +import { type Block } from "@/components/editor/BlockNoteEditor"; + +/** + * Extract plain text from an item for searching (grep). + * Returns empty string for types with no searchable content. + */ +export function extractSearchableText(item: Item, items: Item[]): string { + switch (item.type) { + case "note": { + const data = item.data as NoteData; + if (data.blockContent) { + return serializeBlockNote(data.blockContent as Block[]); + } + return data.field1 ?? ""; + } + case "flashcard": { + const data = item.data as FlashcardData; + const cards: FlashcardItem[] = + data.cards?.length + ? data.cards + : data.front || data.back + ? [{ id: "legacy", front: data.front ?? "", back: data.back ?? "", frontBlocks: data.frontBlocks, backBlocks: data.backBlocks }] + : []; + return cards + .map((c) => { + const front = c.frontBlocks ? serializeBlockNote(c.frontBlocks as Block[]) : c.front; + const back = c.backBlocks ? serializeBlockNote(c.backBlocks as Block[]) : c.back; + return `${front}\n${back}`; + }) + .join("\n\n"); + } + case "pdf": { + const data = item.data as PdfData; + return data.textContent ?? ""; + } + case "quiz": { + const data = item.data as QuizData; + const questions = data.questions ?? []; + return questions + .map( + (q) => + `${q.questionText}\n${q.options?.join("\n") ?? ""}\n${q.explanation ?? ""}` + ) + .join("\n\n"); + } + case "audio": { + const data = item.data as AudioData; + const parts: string[] = []; + if (data.transcript) parts.push(data.transcript); + if (data.segments?.length) { + parts.push( + data.segments + .map((s) => `${s.content}${s.translation ? ` (${s.translation})` : ""}`) + .join("\n") + ); + } + return parts.join("\n"); + } + case "image": + case "youtube": + case "folder": + return item.name; + default: + return ""; + } +} + +/** + * Resolve an item by virtual path. + * Path format: "Physics/notes/Thermodynamics.md" or "notes/My Note.md" + */ +export function resolveItemByPath(items: Item[], pathInput: string): Item | null { + const normalized = pathInput.trim().replace(/\/+/g, "/").replace(/^\//, ""); + if (!normalized) return null; + + // Try exact match on getVirtualPath first + const contentItems = items.filter((i) => i.type !== "folder"); + const exact = contentItems.find((item) => getVirtualPath(item, items) === normalized); + if (exact) return exact; + + // Try path without extension (user might omit .md etc.) + const withoutExt = normalized.replace(/\.[^.]+$/, ""); + const byPathNoExt = contentItems.find((item) => { + const vp = getVirtualPath(item, items); + return vp.replace(/\.[^.]+$/, "") === withoutExt || vp === normalized; + }); + if (byPathNoExt) return byPathNoExt; + + // Try matching last segment as filename (e.g. "Thermodynamics.md" -> item named "Thermodynamics") + const segments = normalized.split("/").filter(Boolean); + const filename = segments[segments.length - 1]; + const nameWithoutExt = filename.replace(/\.[^.]+$/, ""); + + const candidates = contentItems.filter((item) => { + const vp = getVirtualPath(item, items); + return vp.endsWith(filename) || vp.endsWith(nameWithoutExt + ".md") || vp.endsWith(nameWithoutExt + ".pdf"); + }); + + if (candidates.length === 1) return candidates[0]; + if (candidates.length > 1) { + // Prefer path that matches most segments + const best = candidates.find((item) => getVirtualPath(item, items) === normalized); + return best ?? candidates[0]; + } + + return null; +} diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 76d3f657..fbd7465a 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { logger } from "@/lib/utils/logger"; import { workspaceWorker } from "@/lib/ai/workers"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; -import { formatSelectedCardsContext } from "@/lib/utils/format-workspace-context"; import type { Item } from "@/lib/workspace-state/types"; import { loadStateForTool, fuzzyMatchItem, getAvailableItemsList } from "./tool-utils"; @@ -308,9 +307,6 @@ export function createSelectCardsTool(ctx: WorkspaceToolContext) { } } - // Format the selected cards context - const context = formatSelectedCardsContext(selectedItems, state.items); - const addedCount = selectedItems.length; const notFoundCount = cardTitles.length - addedCount; @@ -318,13 +314,12 @@ export function createSelectCardsTool(ctx: WorkspaceToolContext) { if (notFoundCount > 0) { message += ` (${notFoundCount} not found)`; } - message += `: ${selectedItems.map(item => item.name).join(", ")}`; + message += `: ${selectedItems.map(item => item.name).join(", ")}. Use grepWorkspace or readWorkspace to fetch content when needed.`; return { success: true, message, addedCount, - context, // Return formatted context so AI has immediate access cardTitles: cardTitles, }; } catch (error: any) { @@ -332,7 +327,6 @@ export function createSelectCardsTool(ctx: WorkspaceToolContext) { return { success: false, message: `Failed to load workspace state: ${error?.message || String(error)}`, - context: "", }; } }, diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 2515e78f..35b48dff 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -11,6 +11,7 @@ import type { Item, NoteData, PdfData, QuizData, QuizQuestion } from "@/lib/work import { markdownToBlocks } from "@/lib/editor/markdown-to-blocks"; import { executeWorkspaceOperation } from "./common"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import { hasDuplicateName } from "@/lib/workspace/unique-name"; import type { WorkspaceEvent } from "@/lib/workspace/events"; /** Create params for a single item (used by create and bulkCreate). Exported for autogen. */ @@ -282,6 +283,13 @@ export async function workspaceWorker( // Handle different actions if (action === "create") { const item = await buildItemFromCreateParams(params); + const currentState = await loadWorkspaceState(params.workspaceId); + if (hasDuplicateName(currentState.items, item.name, item.type, item.folderId ?? null)) { + return { + success: false, + message: `A ${item.type} named "${item.name}" already exists in this folder`, + }; + } const event = createEvent("ITEM_CREATED", { id: item.id, item }, userId); // For create operations, retry on version conflicts since creates are independent @@ -502,6 +510,16 @@ export async function workspaceWorker( const currentState = await loadWorkspaceState(params.workspaceId); const existingItem = currentState.items.find((i: any) => i.id === params.itemId); const itemName = (changes as Partial).name ?? existingItem?.name; + const newFolderId = (changes as Partial).folderId ?? existingItem?.folderId ?? null; + + if (itemName && existingItem) { + if (hasDuplicateName(currentState.items, itemName, existingItem.type, newFolderId, params.itemId)) { + return { + success: false, + message: `A ${existingItem.type} named "${itemName}" already exists in this folder`, + }; + } + } logger.time("📝 [UPDATE-NOTE] Event creation"); const event = createEvent("ITEM_UPDATED", { id: params.itemId, changes, source: 'agent', name: itemName }, userId); @@ -614,6 +632,12 @@ export async function workspaceWorker( // Handle title update if provided if (params.title) { logger.debug("🎴 [UPDATE-FLASHCARD] Updating title:", params.title); + if (hasDuplicateName(currentState.items, params.title, existingItem.type, existingItem.folderId ?? null, params.itemId)) { + return { + success: false, + message: `A ${existingItem.type} named "${params.title}" already exists in this folder`, + }; + } changes.name = params.title; } @@ -708,6 +732,12 @@ export async function workspaceWorker( // Handle title update if provided if (params.title) { logger.debug("🎯 [UPDATE-QUIZ] Updating title:", params.title); + if (hasDuplicateName(currentState.items, params.title, existingItem.type, existingItem.folderId ?? null, params.itemId)) { + return { + success: false, + message: `A ${existingItem.type} named "${params.title}" already exists in this folder`, + }; + } changes.name = params.title; } @@ -806,6 +836,12 @@ export async function workspaceWorker( const changes: Partial = { data: updatedData }; if (params.title) { + if (hasDuplicateName(currentState.items, params.title, existingItem.type, existingItem.folderId ?? null, params.itemId)) { + return { + success: false, + message: `A ${existingItem.type} named "${params.title}" already exists in this folder`, + }; + } changes.name = params.title; } diff --git a/src/lib/chat/custom-thread-history-adapter.tsx b/src/lib/chat/custom-thread-history-adapter.tsx index 3928fa77..2f6a7d80 100644 --- a/src/lib/chat/custom-thread-history-adapter.tsx +++ b/src/lib/chat/custom-thread-history-adapter.tsx @@ -58,12 +58,19 @@ function sortParentsBeforeChildren< /** * AI SDK–only thread history adapter. * Uses withFormat(aiSDKV6FormatAdapter) for persistence via useExternalHistory. + * Base load/append are stubs since ExternalStoreRuntime uses withFormat for persistence. */ export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter { const aui = useAui(); return useMemo( () => ({ + async load() { + return { messages: [] }; + }, + async append() { + // No-op: ExternalStoreRuntime uses withFormat for persistence + }, withFormat( formatAdapter: MessageFormatAdapter ): GenericThreadHistoryAdapter { @@ -111,7 +118,10 @@ export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter { const filtered = messages.filter( (m: { format?: string }) => m.format === formatAdapter.format ); - const decoded = filtered.map( + type DecodedItem = MessageFormatItem & { + created_at: string; + }; + const decoded: DecodedItem[] = filtered.map( (m: { id: string; parent_id: string | null; @@ -133,10 +143,16 @@ export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter { ); // Topological sort: parents before children for MessageRepository.import() + // formatAdapter.decode returns messages with id (ai-sdk/v6 and similar formats) + type SortableItem = { + parentId: string | null; + message: { id: string }; + created_at: string; + }; const sorted = sortParentsBeforeChildren( - decoded, - (d) => d.created_at ?? new Date().toISOString() - ); + decoded as SortableItem[], + (d) => d.created_at + ) as DecodedItem[]; return { messages: sorted.map(({ parentId, message }) => ({ diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index a60fc077..de524956 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -91,9 +91,9 @@ Your knowledge cutoff date is January 2025. WORKSPACE (virtual file system): ${formatVirtualWorkspaceFS(state)} -When users say "this", they may mean information in the section. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCard tool. +When users say "this", they may mean the selected cards. Selected cards context provides paths and metadata only — use grepWorkspace or readWorkspace to fetch full content when needed. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCards tool. -When answering questions about selected cards or content in , rely only on the facts directly mentioned in that context. Do not invent or assume information not present. If the answer is not in the context, say so. +When answering questions about selected cards, use grepWorkspace to search or readWorkspace to read the item. Rely only on facts from the content you fetch. Do not invent or assume information not present. @@ -425,8 +425,49 @@ function formatRichContentSection(richContent: RichContent): string { * Formats a single selected card with FULL content (no truncation) */ /** - * Formats selected cards context for the assistant - * Used when cards are added to the context drawer + * Formats selected cards as metadata only (paths, names, types). + * Use when the AI has grep/read tools — it fetches content on demand. + */ +export function formatSelectedCardsMetadata(selectedItems: Item[], allItems?: Item[]): string { + if (selectedItems.length === 0) { + return ` +No cards selected. +`; + } + + let effectiveItems: Item[] = []; + const processedIds = new Set(); + + const processItem = (item: Item) => { + if (processedIds.has(item.id)) return; + processedIds.add(item.id); + + if (item.type === "folder") { + effectiveItems.push(item); + if (allItems) { + const children = allItems.filter((child) => child.folderId === item.id); + children.forEach((child) => processItem(child)); + } + } else { + effectiveItems.push(item); + } + }; + + selectedItems.forEach((item) => processItem(item)); + + const contentItems = effectiveItems.filter((i) => i.type !== "folder"); + const entries = contentItems.map((item) => formatItemMetadata(item, allItems ?? effectiveItems)); + + return ` +SELECTED CARDS (${contentItems.length}) — paths and metadata. Use grepWorkspace or readWorkspace to fetch content when needed. + +${entries.join("\n")} +`; +} + +/** + * Formats selected cards context for the assistant (FULL content). + * Used when cards are added to the context drawer — prefer formatSelectedCardsMetadata when grep/read tools exist. */ export function formatSelectedCardsContext(selectedItems: Item[], allItems?: Item[]): string { if (selectedItems.length === 0) { @@ -474,11 +515,15 @@ No cards selected. * Formats a single selected card with FULL content (no truncation) */ function formatSelectedCardFull(item: Item, index: number): string { - const lines = [ - `` - ]; + return formatItemContent(item); +} + +/** + * Format full content of a single item. Used by read tool and formatSelectedCardFull. + */ +export function formatItemContent(item: Item): string { + const lines = [``]; - // Add type-specific details with FULL content switch (item.type) { case "note": lines.push(...formatNoteDetailsFull(item.data as NoteData)); @@ -501,10 +546,11 @@ function formatSelectedCardFull(item: Item, index: number): string { case "audio": lines.push(...formatAudioDetailsFull(item.data as AudioData)); break; + default: + break; } lines.push(``); - return lines.join("\n"); } diff --git a/src/lib/workspace/unique-name.ts b/src/lib/workspace/unique-name.ts new file mode 100644 index 00000000..61b5af07 --- /dev/null +++ b/src/lib/workspace/unique-name.ts @@ -0,0 +1,33 @@ +import type { Item } from "@/lib/workspace-state/types"; + +/** + * Check if a name+type already exists among siblings (same folder). + * Returns true if there would be a duplicate. + * + * @param items - All workspace items + * @param name - Proposed name (case-insensitive check) + * @param type - Item type + * @param folderId - Parent folder (null = root) + * @param excludeItemId - When updating, exclude this item from the check + */ +export function hasDuplicateName( + items: Item[], + name: string, + type: Item["type"], + folderId: string | null, + excludeItemId?: string +): boolean { + const normalized = name.trim().toLowerCase(); + if (!normalized) return false; + + const siblings = items.filter((i) => { + if (i.type === "folder") return false; + if (excludeItemId && i.id === excludeItemId) return false; + const sameFolder = + (folderId == null && i.folderId == null) || + (folderId != null && i.folderId === folderId); + return sameFolder && i.type === type && i.name.trim().toLowerCase() === normalized; + }); + + return siblings.length > 0; +} From c7934a559d133617afa7cdf301c1fb545d6412c9 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 21 Feb 2026 00:29:54 -0500 Subject: [PATCH 09/56] feat: thread loading skeleton --- .env.example | 4 --- .gitignore | 2 ++ src/components/assistant-ui/thread.tsx | 40 +++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index ef1e9309..a2454351 100644 --- a/.env.example +++ b/.env.example @@ -34,10 +34,6 @@ SUPABASE_SERVICE_ROLE_KEY=eyJ... # Google AI GOOGLE_GENERATIVE_AI_API_KEY=AIza... -# Assistant UI -NEXT_PUBLIC_ASSISTANT_BASE_URL=https://... -ASSISTANT_API_KEY=sk_aui_... - # Firecrawl Web Scraping (Optional) # Get your API key from firecrawl.dev FIRECRAWL_API_KEY=fc_... diff --git a/.gitignore b/.gitignore index 36202e82..8a8d6139 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +.pnpm-store /.pnp .pnp.* .yarn/* @@ -22,6 +23,7 @@ # misc .DS_Store +assistant-ui-main/ *.pem # debug diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 82eb4453..ebee6cef 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -46,6 +46,7 @@ import * as m from "motion/react-m"; import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { DropdownMenu, DropdownMenuContent, @@ -213,7 +214,10 @@ export const Thread: FC = ({ items = [] }) => { autoScroll={false} className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll px-4" > - thread.isEmpty}> + thread.isLoading}> + + + thread.isEmpty && !thread.isLoading}> @@ -256,6 +260,40 @@ const ThreadScrollToBottom: FC = () => { ); }; +const ThreadLoadingSkeleton: FC = () => { + return ( +
+ {/* User message skeleton (right-aligned) */} +
+
+ +
+
+ {/* Assistant message skeleton (left-aligned) */} +
+ + + +
+ {/* User message skeleton */} +
+ +
+ {/* Assistant message skeleton - taller */} +
+ + + + +
+
+ ); +}; + const ThreadWelcome: FC = () => { return (
From 2c914ad5f110671b7f7322c8622591d3b283d792 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:11:57 -0500 Subject: [PATCH 10/56] more changes --- .../assistant-ui/GrepWorkspaceToolUI.tsx | 86 -------------- .../assistant-ui/ReadWorkspaceToolUI.tsx | 37 +++--- .../assistant-ui/SearchWorkspaceToolUI.tsx | 109 ++++++++++++++++++ src/components/assistant-ui/thread.tsx | 4 +- src/lib/ai/tools/index.ts | 4 +- src/lib/ai/tools/read-workspace.ts | 2 +- ...{grep-workspace.ts => search-workspace.ts} | 6 +- src/lib/ai/tools/workspace-tools.ts | 2 +- src/lib/utils/format-workspace-context.ts | 6 +- 9 files changed, 140 insertions(+), 116 deletions(-) delete mode 100644 src/components/assistant-ui/GrepWorkspaceToolUI.tsx create mode 100644 src/components/assistant-ui/SearchWorkspaceToolUI.tsx rename src/lib/ai/tools/{grep-workspace.ts => search-workspace.ts} (92%) diff --git a/src/components/assistant-ui/GrepWorkspaceToolUI.tsx b/src/components/assistant-ui/GrepWorkspaceToolUI.tsx deleted file mode 100644 index 57d43d5a..00000000 --- a/src/components/assistant-ui/GrepWorkspaceToolUI.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { Search } from "lucide-react"; -import { makeAssistantToolUI } from "@assistant-ui/react"; -import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; -import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; -import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; - -type GrepArgs = { pattern: string; include?: string; path?: string }; -type GrepResult = { success: boolean; matches?: number; output?: string; message?: string }; - -function GrepArgsSummary({ args }: { args?: GrepArgs }) { - if (!args?.pattern) return null; - const parts = [`pattern: "${args.pattern}"`]; - if (args.include) parts.push(`type: ${args.include}`); - if (args.path) parts.push(`path: ${args.path}`); - return ( -
- {parts.join(" · ")} -
- ); -} - -export const GrepWorkspaceToolUI = makeAssistantToolUI({ - toolName: "grepWorkspace", - render: ({ args, status, result }) => { - let content: React.ReactNode = null; - - if (status.type === "running") { - content = ( -
- - -
- ); - } else if (status.type === "complete" && result) { - if (!result.success && result.message) { - content = ( -
- - -
- ); - } else if (result.success && result.output) { - content = ( -
- -
- - {result.matches != null && ( - - {result.matches} match - {result.matches !== 1 ? "es" : ""} - - )} -
-
-                            {result.output}
-                        
-
- ); - } - } else if (status.type === "incomplete" && status.reason === "error") { - content = ( -
- - -
- ); - } - - return ( - - {content} - - ); - }, -}); diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx index 52522f86..289327da 100644 --- a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -2,7 +2,6 @@ import { FileText } from "lucide-react"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { StandaloneMarkdown } from "@/components/assistant-ui/standalone-markdown"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; @@ -17,6 +16,10 @@ type ReadResult = { message?: string; }; +function stripExtension(s: string): string { + return s.replace(/\.[^.]+$/, ""); +} + export const ReadWorkspaceToolUI = makeAssistantToolUI({ toolName: "readWorkspace", render: ({ args, status, result }) => { @@ -24,7 +27,7 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({ if (status.type === "running") { const label = args?.path - ? `Reading ${args.path}` + ? `Reading ${stripExtension(args.path)}` : args?.itemName ? `Reading "${args.itemName}"` : "Reading workspace item..."; @@ -37,23 +40,21 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({ message={result.message} /> ); - } else if (result.success && result.content) { + } else if (result.success) { content = ( -
-
- - - {result.path ?? result.itemName} - {result.type && ( - - {result.type} - - )} - -
-
- {result.content} -
+
+ + + Read -{" "} + {result.path + ? stripExtension(result.path) + : result.itemName} + {result.type && ( + + {result.type} + + )} +
); } diff --git a/src/components/assistant-ui/SearchWorkspaceToolUI.tsx b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx new file mode 100644 index 00000000..253ab5de --- /dev/null +++ b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Search } from "lucide-react"; +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; +import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; +import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; + +type GrepArgs = { pattern: string; include?: string; path?: string }; +type GrepResult = { success: boolean; matches?: number; output?: string; message?: string }; + +const MAX_EXCERPTS = 5; +const MAX_EXCERPT_LEN = 80; + +function stripExtension(s: string): string { + return s.replace(/\.[^.]+$/, ""); +} + +function parseExcerpts(output: string): { path: string; excerpt: string }[] { + const items: { path: string; excerpt: string }[] = []; + const lines = output.split("\n"); + let currentPath = ""; + + for (const line of lines) { + if (line.match(/^Found \d+ matches/) || line === "" || line.startsWith("(Results truncated")) continue; + if (line.endsWith(":") && !line.startsWith(" ")) { + currentPath = stripExtension(line.slice(0, -1).trim()); + } else if (line.startsWith(" Line ") && currentPath) { + const match = line.match(/^ Line \d+: (.+)$/); + const excerpt = match ? match[1].trim() : line.replace(/^ Line \d+: /, "").trim(); + const truncated = excerpt.length > MAX_EXCERPT_LEN ? excerpt.slice(0, MAX_EXCERPT_LEN) + "…" : excerpt; + items.push({ path: currentPath, excerpt: truncated }); + if (items.length >= MAX_EXCERPTS) break; + } + } + return items; +} + +export const SearchWorkspaceToolUI = makeAssistantToolUI({ + toolName: "searchWorkspace", + render: ({ status, result }) => { + let content: React.ReactNode = null; + + if (status.type === "running") { + content = ; + } else if (status.type === "complete" && result) { + if (!result.success && result.message) { + content = ( + + ); + } else if (result.success && result.output) { + const isNoMatches = result.output.startsWith("No matches found"); + const excerpts = isNoMatches ? [] : parseExcerpts(result.output); + + content = ( +
+
+ + + Search -{" "} + {result.matches != null + ? `${result.matches} match${result.matches !== 1 ? "es" : ""}` + : "completed"} + +
+ {excerpts.length > 0 && ( +
    + {excerpts.map((item, i) => ( +
  • + {item.path} + {item.excerpt} +
  • + ))} +
+ )} +
+ ); + } else if (result.success) { + content = ( +
+ + + Search -{" "} + {result.matches != null + ? `${result.matches} match${result.matches !== 1 ? "es" : ""}` + : "completed"} + +
+ ); + } + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } + + return ( + + {content} + + ); + }, +}); diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index ebee6cef..54baa535 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -76,7 +76,7 @@ import { URLContextToolUI } from "@/components/assistant-ui/URLContextToolUI"; import { UpdateNoteToolUI } from "@/components/assistant-ui/UpdateNoteToolUI"; import { WebSearchToolUI } from "@/components/assistant-ui/WebSearchToolUI"; import { UpdatePdfContentToolUI } from "@/components/assistant-ui/UpdatePdfContentToolUI"; -import { GrepWorkspaceToolUI } from "@/components/assistant-ui/GrepWorkspaceToolUI"; +import { SearchWorkspaceToolUI } from "@/components/assistant-ui/SearchWorkspaceToolUI"; import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI"; import { DeleteCardToolUI } from "@/components/assistant-ui/DeleteCardToolUI"; @@ -200,7 +200,7 @@ export const Thread: FC = ({ items = [] }) => { {/* */} - + { // Search & code execution webSearch: createWebSearchTool(), executeCode: createExecuteCodeTool(), - grepWorkspace: createGrepWorkspaceTool(ctx), + searchWorkspace: createSearchWorkspaceTool(ctx), readWorkspace: createReadWorkspaceTool(ctx), // Workspace operations diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts index 7fb03f5e..d2eb4d8f 100644 --- a/src/lib/ai/tools/read-workspace.ts +++ b/src/lib/ai/tools/read-workspace.ts @@ -69,7 +69,7 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { return { success: false, message: `Item not found${itemName ? `: "${itemName}"` : ` at path: ${path}`}. ${ - sample ? `Example paths: ${sample}` : "Workspace may be empty. Use grep to search." + sample ? `Example paths: ${sample}` : "Workspace may be empty. Use searchWorkspace to search." }`, }; } diff --git a/src/lib/ai/tools/grep-workspace.ts b/src/lib/ai/tools/search-workspace.ts similarity index 92% rename from src/lib/ai/tools/grep-workspace.ts rename to src/lib/ai/tools/search-workspace.ts index 67e80a8c..ee4a6143 100644 --- a/src/lib/ai/tools/grep-workspace.ts +++ b/src/lib/ai/tools/search-workspace.ts @@ -22,13 +22,13 @@ function buildRegex(pattern: string): RegExp { return new RegExp(escaped, "gi"); } -export function createGrepWorkspaceTool(ctx: WorkspaceToolContext) { +export function createSearchWorkspaceTool(ctx: WorkspaceToolContext) { return tool({ description: - "Search for a pattern in workspace item content (notes, flashcards, PDFs, quizzes, audio transcripts). Use when you need to find where a term or phrase appears across the user's materials.", + "Grep-like text search across workspace content (notes, flashcards, PDFs, quizzes, audio transcripts). Use when you need to find where a term or phrase appears. Pass the search pattern as `pattern` (plain text or regex).", inputSchema: zodSchema( z.object({ - pattern: z.string().describe("The regex or plain text pattern to search for"), + pattern: z.string().describe("Search pattern (plain text or regex) — the term/phrase to find in workspace content"), include: z .string() .optional() diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index fbd7465a..29675632 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -314,7 +314,7 @@ export function createSelectCardsTool(ctx: WorkspaceToolContext) { if (notFoundCount > 0) { message += ` (${notFoundCount} not found)`; } - message += `: ${selectedItems.map(item => item.name).join(", ")}. Use grepWorkspace or readWorkspace to fetch content when needed.`; + message += `: ${selectedItems.map(item => item.name).join(", ")}. Use searchWorkspace or readWorkspace to fetch content when needed.`; return { success: true, diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index de524956..3bb4e874 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -91,9 +91,9 @@ Your knowledge cutoff date is January 2025. WORKSPACE (virtual file system): ${formatVirtualWorkspaceFS(state)} -When users say "this", they may mean the selected cards. Selected cards context provides paths and metadata only — use grepWorkspace or readWorkspace to fetch full content when needed. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCards tool. +When users say "this", they may mean the selected cards. Selected cards context provides paths and metadata only — use searchWorkspace or readWorkspace to fetch full content when needed. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCards tool. -When answering questions about selected cards, use grepWorkspace to search or readWorkspace to read the item. Rely only on facts from the content you fetch. Do not invent or assume information not present. +When answering questions about selected cards, use searchWorkspace to search or readWorkspace to read the item. Rely only on facts from the content you fetch. Do not invent or assume information not present. @@ -459,7 +459,7 @@ No cards selected. const entries = contentItems.map((item) => formatItemMetadata(item, allItems ?? effectiveItems)); return ` -SELECTED CARDS (${contentItems.length}) — paths and metadata. Use grepWorkspace or readWorkspace to fetch content when needed. +SELECTED CARDS (${contentItems.length}) — paths and metadata. Use searchWorkspace or readWorkspace to fetch content when needed. ${entries.join("\n")} `; From edd3e11e21df3969c491ece9f366519be4d39039 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:37:30 -0500 Subject: [PATCH 11/56] feat: diff based updating --- package.json | 2 + src/app/api/deep-research/finalize/route.ts | 3 +- .../assistant-ui/ReadWorkspaceToolUI.tsx | 34 +- .../assistant-ui/UpdateNoteToolUI.tsx | 109 +-- src/components/assistant-ui/diff-viewer.tsx | 625 ++++++++++++++++++ src/lib/ai/tools/workspace-tools.ts | 84 ++- src/lib/ai/workers/workspace-worker.ts | 81 ++- src/lib/utils/edit-replace.ts | 478 ++++++++++++++ src/lib/utils/format-workspace-context.ts | 33 +- 9 files changed, 1341 insertions(+), 108 deletions(-) create mode 100644 src/components/assistant-ui/diff-viewer.tsx create mode 100644 src/lib/utils/edit-replace.ts diff --git a/package.json b/package.json index 3fb7ddfe..7cf2549e 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.1.1", + "diff": "^8.0.3", "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", "embla-carousel-react": "^8.6.0", @@ -113,6 +114,7 @@ "motion": "^12.34.0", "next": "16.1.6", "next-themes": "^0.4.6", + "parse-diff": "^0.11.1", "postgres": "^3.4.7", "posthog-js": "^1.345.0", "posthog-node": "^5.24.14", diff --git a/src/app/api/deep-research/finalize/route.ts b/src/app/api/deep-research/finalize/route.ts index a5f988dd..9112b88d 100644 --- a/src/app/api/deep-research/finalize/route.ts +++ b/src/app/api/deep-research/finalize/route.ts @@ -46,7 +46,8 @@ export async function POST(req: NextRequest) { const result = await workspaceWorker("update", { workspaceId, itemId, - content: report, // This will be converted via markdownToBlocks + oldString: "", + newString: report, }); if (!result.success) { diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx index 289327da..8616e889 100644 --- a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -1,6 +1,6 @@ "use client"; -import { FileText } from "lucide-react"; +import { Eye } from "lucide-react"; import { makeAssistantToolUI } from "@assistant-ui/react"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; @@ -36,32 +36,34 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({ if (!result.success && result.message) { content = ( ); } else if (result.success) { content = ( -
- - - Read -{" "} - {result.path - ? stripExtension(result.path) - : result.itemName} - {result.type && ( - - {result.type} - - )} - +
+
+ + + Read -{" "} + {result.path + ? stripExtension(result.path) + : result.itemName} + {result.type && ( + + {result.type} + + )} + +
); } } else if (status.type === "incomplete" && status.reason === "error") { content = ( ); diff --git a/src/components/assistant-ui/UpdateNoteToolUI.tsx b/src/components/assistant-ui/UpdateNoteToolUI.tsx index a32b67db..140e71f4 100644 --- a/src/components/assistant-ui/UpdateNoteToolUI.tsx +++ b/src/components/assistant-ui/UpdateNoteToolUI.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { makeAssistantToolUI } from "@assistant-ui/react"; import { useOptimisticToolUpdate } from "@/hooks/ai/use-optimistic-tool-update"; @@ -14,14 +14,20 @@ import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; +import { DiffViewer } from "@/components/assistant-ui/diff-viewer"; import type { WorkspaceResult } from "@/lib/ai/tool-result-schemas"; import { parseWorkspaceResult } from "@/lib/ai/tool-result-schemas"; -type UpdateNoteArgs = { noteName: string; content: string }; +type UpdateNoteArgs = { noteName: string; oldString: string; newString: string; replaceAll?: boolean }; + +interface UpdateNoteResult extends WorkspaceResult { + diff?: string; + filediff?: { additions: number; deletions: number }; +} interface UpdateNoteReceiptProps { args: UpdateNoteArgs; - result: WorkspaceResult; + result: UpdateNoteResult; status: any; } @@ -45,52 +51,67 @@ const UpdateNoteReceipt = ({ args, result, status }: UpdateNoteReceiptProps) => } }; + const hasDiff = result.diff && result.diff.trim().length > 0; + return ( -
-
-
- {status?.type === "complete" ? ( - - ) : ( - - )} -
-
- - {status?.type === "complete" ? (card?.name || "Card Updated") : "Update Cancelled"} - - {status?.type === "complete" && ( - - Note updated +
+
+
+
+ {status?.type === "complete" ? ( + + ) : ( + + )} +
+
+ + {status?.type === "complete" ? (card?.name || "Card Updated") : "Update Cancelled"} + {status?.type === "complete" && ( + Note updated + )} +
+
+ +
+ {status?.type === "complete" && result.itemId && ( + )}
-
- {status?.type === "complete" && result.itemId && ( - - )} -
+ {hasDiff && ( + + )}
); }; @@ -117,7 +138,7 @@ export const UpdateNoteToolUI = makeAssistantToolUI; + content = ; } else if (status.type === "running") { content = ; } else if (status.type === "complete" && parsed && !parsed.success) { diff --git a/src/components/assistant-ui/diff-viewer.tsx b/src/components/assistant-ui/diff-viewer.tsx new file mode 100644 index 00000000..336ce988 --- /dev/null +++ b/src/components/assistant-ui/diff-viewer.tsx @@ -0,0 +1,625 @@ +"use client"; + +import type { ComponentProps } from "react"; +import type { SyntaxHighlighterProps } from "@assistant-ui/react-markdown"; +import { cva, type VariantProps } from "class-variance-authority"; +import { diffLines } from "diff"; +import parseDiff from "parse-diff"; +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { StreamdownMarkdown } from "@/components/ui/streamdown-markdown"; + +type DiffLineType = "add" | "del" | "normal"; + +interface ParsedLine { + type: DiffLineType; + content: string; + oldLineNumber?: number; + newLineNumber?: number; +} + +interface ParsedFile { + oldName?: string | undefined; + newName?: string | undefined; + lines: ParsedLine[]; + additions: number; + deletions: number; +} + +interface SplitLinePair { + left: ParsedLine | null; + right: ParsedLine | null; +} + +function parsePatch(patch: string): ParsedFile[] { + const files = parseDiff(patch); + return files.map((file) => { + const lines: ParsedLine[] = []; + let additions = 0; + let deletions = 0; + for (const chunk of file.chunks) { + let oldLine = chunk.oldStart; + let newLine = chunk.newStart; + for (const change of chunk.changes) { + if (change.type === "add") { + additions++; + lines.push({ + type: "add", + content: change.content.slice(1), + newLineNumber: newLine++, + }); + } else if (change.type === "del") { + deletions++; + lines.push({ + type: "del", + content: change.content.slice(1), + oldLineNumber: oldLine++, + }); + } else { + lines.push({ + type: "normal", + content: change.content.slice(1), + oldLineNumber: oldLine++, + newLineNumber: newLine++, + }); + } + } + } + return { + oldName: file.from, + newName: file.to, + lines, + additions, + deletions, + }; + }); +} + +function computeDiff( + oldContent: string, + newContent: string, +): { lines: ParsedLine[]; additions: number; deletions: number } { + const changes = diffLines(oldContent, newContent); + const lines: ParsedLine[] = []; + let oldLine = 1; + let newLine = 1; + let additions = 0; + let deletions = 0; + + for (const change of changes) { + const contentLines = change.value.replace(/\n$/, "").split("\n"); + for (const content of contentLines) { + if (change.added) { + additions++; + lines.push({ type: "add", content, newLineNumber: newLine++ }); + } else if (change.removed) { + deletions++; + lines.push({ type: "del", content, oldLineNumber: oldLine++ }); + } else { + lines.push({ + type: "normal", + content, + oldLineNumber: oldLine++, + newLineNumber: newLine++, + }); + } + } + } + return { lines, additions, deletions }; +} + +function pairLinesForSplit(lines: ParsedLine[]): SplitLinePair[] { + const pairs: SplitLinePair[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]!; + if (line.type === "normal") { + pairs.push({ left: line, right: line }); + i++; + } else if (line.type === "del") { + const deletions: ParsedLine[] = []; + while (i < lines.length && lines[i]!.type === "del") { + deletions.push(lines[i]!); + i++; + } + const additions: ParsedLine[] = []; + while (i < lines.length && lines[i]!.type === "add") { + additions.push(lines[i]!); + i++; + } + const maxLen = Math.max(deletions.length, additions.length); + for (let j = 0; j < maxLen; j++) { + pairs.push({ + left: deletions[j] ?? null, + right: additions[j] ?? null, + }); + } + } else { + pairs.push({ left: null, right: line }); + i++; + } + } + return pairs; +} + +const diffViewerVariants = cva( + "aui-diff-viewer overflow-hidden rounded-lg font-sans text-sm", + { + variants: { + variant: { + default: "border bg-background", + ghost: "bg-transparent", + muted: "border border-muted-foreground/20 bg-muted", + }, + size: { + xs: "text-[11px]", + sm: "text-xs", + default: "text-sm", + lg: "text-base", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +const diffLineVariants = cva("flex", { + variants: { + type: { + add: "bg-[var(--diff-add-bg,_rgba(46,160,67,0.15))]", + del: "bg-[var(--diff-del-bg,_rgba(248,81,73,0.15))]", + normal: "", + empty: "", + }, + }, + defaultVariants: { + type: "normal", + }, +}); + +const diffLineTextVariants = cva("", { + variants: { + type: { + add: "text-[var(--diff-add-text,_#1a7f37)] dark:text-[var(--diff-add-text-dark,_#3fb950)]", + del: "text-[var(--diff-del-text,_#cf222e)] dark:text-[var(--diff-del-text-dark,_#f85149)]", + normal: "", + empty: "", + }, + }, + defaultVariants: { + type: "normal", + }, +}); + +function getFileExtension(filename?: string): string { + const ext = filename?.split(".").pop()?.toLowerCase(); + if (!ext) return ""; + return ext.toUpperCase(); +} + +function DiffViewerFileBadge({ filename }: { filename?: string | undefined }) { + const ext = getFileExtension(filename); + if (!ext) return null; + + return ( + + {ext} + + ); +} + +function DiffViewerStats({ + additions, + deletions, +}: { + additions: number; + deletions: number; +}) { + return ( + + +{additions} + -{deletions} + + ); +} + +function DiffViewerFile({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +function DiffViewerContent({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +interface DiffViewerHeaderProps extends ComponentProps<"div"> { + oldName?: string | undefined; + newName?: string | undefined; + additions?: number; + deletions?: number; + showIcon?: boolean; + showStats?: boolean; +} + +function DiffViewerHeader({ + oldName, + newName, + additions = 0, + deletions = 0, + showIcon = true, + showStats = true, + className, + ...props +}: DiffViewerHeaderProps) { + if (!oldName && !newName) return null; + + const displayName = newName || oldName; + + return ( +
+ {showIcon && } + + {oldName && newName && oldName !== newName ? ( + <> + {oldName} + {" → "} + + {newName} + + + ) : ( + displayName + )} + + {showStats && (additions > 0 || deletions > 0) && ( + + )} +
+ ); +} + +interface DiffViewerLineProps extends ComponentProps<"div"> { + line: ParsedLine; + showLineNumbers?: boolean; +} + +function DiffViewerLine({ + line, + showLineNumbers = true, + className, + ...props +}: DiffViewerLineProps) { + const indicator = line.type === "add" ? "+" : line.type === "del" ? "-" : " "; + + return ( +
+ {showLineNumbers && ( + + {line.type === "del" + ? line.oldLineNumber + : line.type === "add" + ? line.newLineNumber + : line.oldLineNumber} + + )} + + {indicator} + + + {line.content} + +
+ ); +} + +interface DiffViewerSplitLineProps extends ComponentProps<"div"> { + pair: SplitLinePair; + showLineNumbers?: boolean; +} + +function DiffViewerSplitLine({ + pair, + showLineNumbers = true, + className, + ...props +}: DiffViewerSplitLineProps) { + const { left, right } = pair; + + return ( +
+
+ {showLineNumbers && ( + + {left?.oldLineNumber ?? ""} + + )} + + {left ? (left.type === "del" ? "-" : " ") : ""} + + + {left?.content ?? ""} + +
+
+ {showLineNumbers && ( + + {right?.newLineNumber ?? ""} + + )} + + {right ? (right.type === "add" ? "+" : " ") : ""} + + + {right?.content ?? ""} + +
+
+ ); +} + +export type DiffViewerProps = Partial & + VariantProps & { + patch?: string; + oldFile?: { content: string; name?: string }; + newFile?: { content: string; name?: string }; + viewMode?: "split" | "unified"; + showLineNumbers?: boolean; + showIcon?: boolean; + showStats?: boolean; + /** Render content as markdown (math, code, etc.) via Streamdown */ + renderMarkdown?: boolean; + className?: string; + }; + +/** Group consecutive lines of same type into chunks (for markdown rendering) */ +function groupLinesByType(lines: ParsedLine[]): Array<{ type: DiffLineType; content: string }> { + const chunks: Array<{ type: DiffLineType; content: string }> = []; + if (lines.length === 0) return chunks; + + let current: { type: DiffLineType; lines: string[] } = { + type: lines[0].type, + lines: [lines[0].content], + }; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.type === current.type) { + current.lines.push(line.content); + } else { + chunks.push({ type: current.type, content: current.lines.join("\n") }); + current = { type: line.type, lines: [line.content] }; + } + } + chunks.push({ type: current.type, content: current.lines.join("\n") }); + return chunks; +} + +function DiffViewer({ + code, + patch, + oldFile, + newFile, + viewMode = "unified", + showLineNumbers = true, + showIcon = true, + showStats = true, + renderMarkdown = false, + variant, + size, + className, +}: DiffViewerProps) { + const diffPatch = patch ?? code; + + const parsedFiles = useMemo(() => { + if (diffPatch) { + return parsePatch(diffPatch); + } + if (oldFile && newFile) { + const { lines, additions, deletions } = computeDiff( + oldFile.content, + newFile.content, + ); + return [ + { + oldName: oldFile.name, + newName: newFile.name, + lines, + additions, + deletions, + }, + ]; + } + return []; + }, [diffPatch, oldFile, newFile]); + + if (parsedFiles.length === 0) { + return ( +
+        No diff content provided
+      
+ ); + } + + return ( +
+ {parsedFiles.map((file, fileIndex) => ( +
+ +
+ {renderMarkdown && viewMode === "unified" ? ( + groupLinesByType(file.lines).map((chunk, chunkIndex) => { + const chunkLabels = { + add: "Added", + del: "Removed", + normal: "Unchanged", + } as const; + const label = chunkLabels[chunk.type]; + return ( +
+
+ {label} +
+ + {chunk.content} + +
+ ); + }) + ) : viewMode === "split" ? ( + pairLinesForSplit(file.lines).map((pair, pairIndex) => ( + + )) + ) : ( + file.lines.map((line, lineIndex) => ( + + )) + )} +
+
+ ))} +
+ ); +} + +DiffViewer.displayName = "DiffViewer"; + +export type { ParsedLine, ParsedFile, SplitLinePair }; + +export { + DiffViewer, + DiffViewerFile, + DiffViewerHeader, + DiffViewerContent, + DiffViewerLine, + DiffViewerSplitLine, + DiffViewerFileBadge, + DiffViewerStats, + diffViewerVariants, + diffLineVariants, + diffLineTextVariants, + parsePatch, + computeDiff, +}; diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 29675632..0de4f982 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -68,28 +68,48 @@ export function createNoteTool(ctx: WorkspaceToolContext) { /** * Create the updateNote tool + * Cline convention: oldString='' = full rewrite, oldString!='' = targeted edit */ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { return tool({ - description: "Update the content and/or title of an existing note.", + description: + "Update a note. Full rewrite: oldString='', newString=entire note content. Targeted edit: oldString=exact text to find (from readWorkspace), newString=replacement. Include enough context in oldString to make it unique.", inputSchema: zodSchema( - z.object({ - noteName: z.string().describe("The name of the note to update (will be matched using fuzzy search)"), - content: z.string().describe("The full note body ONLY (do not include the title as a header)."), - title: z.string().optional().describe("New title for the note. If not provided, the existing title will be preserved."), - sources: z.array( - z.object({ - title: z.string().describe("Title of the source page"), - url: z.string().describe("URL of the source"), - favicon: z.string().optional().describe("Optional favicon URL"), - }) - ).optional().describe("Optional sources from web search or user-provided URLs"), - }).passthrough() + z + .object({ + noteName: z.string().describe("The name of the note to update (will be matched using fuzzy search)"), + oldString: z + .string() + .describe( + "Text to find. Use empty string '' for full rewrite; otherwise exact text from readWorkspace for targeted edit." + ), + newString: z + .string() + .describe("Replacement text (entire note if oldString is empty)"), + replaceAll: z.boolean().optional().default(false), + title: z.string().optional().describe("New title for the note. If not provided, the existing title will be preserved."), + sources: z + .array( + z.object({ + title: z.string().describe("Title of the source page"), + url: z.string().describe("URL of the source"), + favicon: z.string().optional().describe("Optional favicon URL"), + }) + ) + .optional() + .describe("Optional sources from web search or user-provided URLs"), + }) + .passthrough() ), - execute: async (input: { noteName: string; content: string; title?: string; sources?: Array<{ title: string; url: string; favicon?: string }> }) => { - const noteName = input.noteName; - const content = input.content; - const title = input.title; + execute: async (input: { + noteName: string; + oldString: string; + newString: string; + replaceAll?: boolean; + title?: string; + sources?: Array<{ title: string; url: string; favicon?: string }>; + }) => { + const { noteName, oldString, newString, replaceAll, title } = input; if (!noteName) { return { @@ -98,10 +118,24 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { }; } - if (content === undefined || content === null) { + if (oldString === undefined || oldString === null) { + return { + success: false, + message: "oldString is required. Use '' for full rewrite.", + }; + } + + if (newString === undefined || newString === null) { return { success: false, - message: "Content is required.", + message: "newString is required.", + }; + } + + if (oldString === newString) { + return { + success: false, + message: "No changes to apply: oldString and newString are identical.", }; } @@ -113,22 +147,19 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { } try { - // Load workspace state (security is enforced by workspace-worker) const accessResult = await loadStateForTool(ctx); if (!accessResult.success) { return accessResult; } const { state } = accessResult; - - // Fuzzy match the note by name const matchedNote = fuzzyMatchItem(state.items, noteName, "note"); if (!matchedNote) { const availableNotes = getAvailableItemsList(state.items, "note"); return { success: false, - message: `Could not find note "${noteName}". ${availableNotes ? `Available notes: ${availableNotes}` : 'No notes found in workspace.'}`, + message: `Could not find note "${noteName}". ${availableNotes ? `Available notes: ${availableNotes}` : "No notes found in workspace."}`, }; } @@ -141,8 +172,11 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { const workerResult = await workspaceWorker("update", { workspaceId: ctx.workspaceId, itemId: matchedNote.id, - content: content, - title: title, + itemName: matchedNote.name, + oldString, + newString, + replaceAll, + title, sources: input.sources, }); diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 35b48dff..e6d5ca28 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -1,4 +1,5 @@ import { headers } from "next/headers"; +import { createPatch, diffLines } from "diff"; import { auth } from "@/lib/auth"; import { db, workspaces } from "@/lib/db/client"; import { workspaceCollaborators } from "@/lib/db/schema"; @@ -13,6 +14,8 @@ import { executeWorkspaceOperation } from "./common"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import { hasDuplicateName } from "@/lib/workspace/unique-name"; import type { WorkspaceEvent } from "@/lib/workspace/events"; +import { replace as applyReplace, trimDiff, normalizeLineEndings } from "@/lib/utils/edit-replace"; +import { getNoteContentAsMarkdown } from "@/lib/utils/format-workspace-context"; /** Create params for a single item (used by create and bulkCreate). Exported for autogen. */ export type CreateItemParams = { @@ -183,8 +186,14 @@ export async function workspaceWorker( /** For bulkCreate: array of create params (no workspaceId). Items are built and appended as one BULK_ITEMS_CREATED event. */ items?: CreateItemParams[]; title?: string; - content?: string; // For notes + content?: string; // For create + /** Cline convention: oldString+newString. oldString='' = full rewrite, else targeted edit (update only) */ + oldString?: string; + newString?: string; + replaceAll?: boolean; itemId?: string; + /** Display name for diff header (e.g. note title) */ + itemName?: string; itemType?: "note" | "flashcard" | "quiz" | "youtube" | "image" | "audio" | "pdf"; // Defaults to "note" if undefined pdfData?: { @@ -471,18 +480,61 @@ export async function workspaceWorker( changes.name = titleStr; } - // Update content if provided (allow empty string to clear content) - if (params.content !== undefined) { - const contentStr = typeof params.content === 'string' ? params.content : String(params.content); + // Update content: Cline convention (oldString + newString) + let contentOld = ""; + let contentNew = ""; + let diffOutput = ""; + let filediffAdditions = 0; + let filediffDeletions = 0; + + if (params.oldString !== undefined && params.newString !== undefined) { + const oldStr = typeof params.oldString === "string" ? params.oldString : String(params.oldString); + const newStr = typeof params.newString === "string" ? params.newString : String(params.newString); + const replaceAll = !!params.replaceAll; + + if (oldStr === newStr) { + throw new Error("No changes to apply: oldString and newString are identical."); + } + + if (oldStr === "") { + // Full rewrite: newString is entire note content + contentOld = ""; + contentNew = newStr; + } else { + // Targeted edit: load note, find and replace + const currentState = await loadWorkspaceState(params.workspaceId); + const existingItem = currentState.items.find((i: Item) => i.id === params.itemId); + if (!existingItem) { + throw new Error(`Note not found with ID: ${params.itemId}`); + } + if (existingItem.type !== "note") { + throw new Error(`Item "${existingItem.name}" is not a note (type: ${existingItem.type})`); + } + contentOld = getNoteContentAsMarkdown(existingItem.data as NoteData); + contentNew = applyReplace(contentOld, oldStr, newStr, replaceAll); + } logger.time("📝 [UPDATE-NOTE] markdownToBlocks conversion"); - const blockContent = await markdownToBlocks(contentStr); + const blockContent = await markdownToBlocks(contentNew); logger.timeEnd("📝 [UPDATE-NOTE] markdownToBlocks conversion"); changes.data = { - field1: contentStr, - blockContent: blockContent, + field1: contentNew, + blockContent, } as NoteData; + + const patchName = params.itemName ?? "note"; + diffOutput = trimDiff( + createPatch( + patchName, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew) + ) + ); + for (const change of diffLines(contentOld, contentNew)) { + if (change.added) filediffAdditions += change.count || 0; + if (change.removed) filediffDeletions += change.count || 0; + } } // Update sources if provided @@ -564,13 +616,26 @@ export async function workspaceWorker( logger.group("✅ [UPDATE-NOTE] Update completed successfully", true); logger.groupEnd(); - return { + const result: { + success: boolean; + itemId?: string; + message: string; + event?: WorkspaceEvent; + version?: number; + diff?: string; + filediff?: { additions: number; deletions: number }; + } = { success: true, itemId: params.itemId, message: `Updated note successfully`, event, version: appendResult.version, }; + if (diffOutput) { + result.diff = diffOutput; + result.filediff = { additions: filediffAdditions, deletions: filediffDeletions }; + } + return result; } catch (error: any) { logger.group("❌ [UPDATE-NOTE] Error during update operation", false); logger.error("Error type:", error?.constructor?.name || typeof error); diff --git a/src/lib/utils/edit-replace.ts b/src/lib/utils/edit-replace.ts new file mode 100644 index 00000000..ece52819 --- /dev/null +++ b/src/lib/utils/edit-replace.ts @@ -0,0 +1,478 @@ +/** + * Robust edit/replace utilities for note editing. + * Approaches sourced from Cline and Gemini CLI: + * - https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts + * - https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts + * - https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts + */ + +export function normalizeLineEndings(text: string): string { + return text.replaceAll("\r\n", "\n"); +} + +export type Replacer = (content: string, find: string) => Generator; + +// Similarity thresholds for block anchor fallback matching +const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0; +const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3; + +function levenshtein(a: string, b: string): number { + if (a === "" || b === "") { + return Math.max(a.length, b.length); + } + const matrix = Array.from({ length: a.length + 1 }, (_, i) => + Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)) + ); + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost); + } + } + return matrix[a.length][b.length]; +} + +export const SimpleReplacer: Replacer = function* (_content, find) { + yield find; +}; + +export const LineTrimmedReplacer: Replacer = function* (content, find) { + const originalLines = content.split("\n"); + const searchLines = find.split("\n"); + + if (searchLines[searchLines.length - 1] === "") { + searchLines.pop(); + } + + for (let i = 0; i <= originalLines.length - searchLines.length; i++) { + let matches = true; + + for (let j = 0; j < searchLines.length; j++) { + const originalTrimmed = originalLines[i + j].trim(); + const searchTrimmed = searchLines[j].trim(); + + if (originalTrimmed !== searchTrimmed) { + matches = false; + break; + } + } + + if (matches) { + let matchStartIndex = 0; + for (let k = 0; k < i; k++) { + matchStartIndex += originalLines[k].length + 1; + } + + let matchEndIndex = matchStartIndex; + for (let k = 0; k < searchLines.length; k++) { + matchEndIndex += originalLines[i + k].length; + if (k < searchLines.length - 1) { + matchEndIndex += 1; + } + } + + yield content.substring(matchStartIndex, matchEndIndex); + } + } +}; + +export const BlockAnchorReplacer: Replacer = function* (content, find) { + const originalLines = content.split("\n"); + const searchLines = find.split("\n"); + + if (searchLines.length < 3) { + return; + } + + if (searchLines[searchLines.length - 1] === "") { + searchLines.pop(); + } + + const firstLineSearch = searchLines[0].trim(); + const lastLineSearch = searchLines[searchLines.length - 1].trim(); + const searchBlockSize = searchLines.length; + + const candidates: Array<{ startLine: number; endLine: number }> = []; + for (let i = 0; i < originalLines.length; i++) { + if (originalLines[i].trim() !== firstLineSearch) { + continue; + } + + for (let j = i + 2; j < originalLines.length; j++) { + if (originalLines[j].trim() === lastLineSearch) { + candidates.push({ startLine: i, endLine: j }); + break; + } + } + } + + if (candidates.length === 0) { + return; + } + + if (candidates.length === 1) { + const { startLine, endLine } = candidates[0]; + const actualBlockSize = endLine - startLine + 1; + + let similarity = 0; + const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2); + + if (linesToCheck > 0) { + for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) { + const originalLine = originalLines[startLine + j].trim(); + const searchLine = searchLines[j].trim(); + const maxLen = Math.max(originalLine.length, searchLine.length); + if (maxLen === 0) { + continue; + } + const distance = levenshtein(originalLine, searchLine); + similarity += (1 - distance / maxLen) / linesToCheck; + + if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) { + break; + } + } + } else { + similarity = 1.0; + } + + if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) { + let matchStartIndex = 0; + for (let k = 0; k < startLine; k++) { + matchStartIndex += originalLines[k].length + 1; + } + let matchEndIndex = matchStartIndex; + for (let k = startLine; k <= endLine; k++) { + matchEndIndex += originalLines[k].length; + if (k < endLine) { + matchEndIndex += 1; + } + } + yield content.substring(matchStartIndex, matchEndIndex); + } + return; + } + + let bestMatch: { startLine: number; endLine: number } | null = null; + let maxSimilarity = -1; + + for (const candidate of candidates) { + const { startLine, endLine } = candidate; + const actualBlockSize = endLine - startLine + 1; + + let similarity = 0; + const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2); + + if (linesToCheck > 0) { + for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) { + const originalLine = originalLines[startLine + j].trim(); + const searchLine = searchLines[j].trim(); + const maxLen = Math.max(originalLine.length, searchLine.length); + if (maxLen === 0) { + continue; + } + const distance = levenshtein(originalLine, searchLine); + similarity += 1 - distance / maxLen; + } + similarity /= linesToCheck; + } else { + similarity = 1.0; + } + + if (similarity > maxSimilarity) { + maxSimilarity = similarity; + bestMatch = candidate; + } + } + + if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) { + const { startLine, endLine } = bestMatch; + let matchStartIndex = 0; + for (let k = 0; k < startLine; k++) { + matchStartIndex += originalLines[k].length + 1; + } + let matchEndIndex = matchStartIndex; + for (let k = startLine; k <= endLine; k++) { + matchEndIndex += originalLines[k].length; + if (k < endLine) { + matchEndIndex += 1; + } + } + yield content.substring(matchStartIndex, matchEndIndex); + } +}; + +export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) { + const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim(); + const normalizedFind = normalizeWhitespace(find); + + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (normalizeWhitespace(line) === normalizedFind) { + yield line; + } else { + const normalizedLine = normalizeWhitespace(line); + if (normalizedLine.includes(normalizedFind)) { + const words = find.trim().split(/\s+/); + if (words.length > 0) { + const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+"); + try { + const regex = new RegExp(pattern); + const match = line.match(regex); + if (match) { + yield match[0]; + } + } catch { + // Invalid regex pattern, skip + } + } + } + } + } + + const findLines = find.split("\n"); + if (findLines.length > 1) { + for (let i = 0; i <= lines.length - findLines.length; i++) { + const block = lines.slice(i, i + findLines.length); + if (normalizeWhitespace(block.join("\n")) === normalizedFind) { + yield block.join("\n"); + } + } + } +}; + +export const IndentationFlexibleReplacer: Replacer = function* (content, find) { + const removeIndentation = (text: string) => { + const lines = text.split("\n"); + const nonEmptyLines = lines.filter((line) => line.trim().length > 0); + if (nonEmptyLines.length === 0) return text; + + const minIndent = Math.min( + ...nonEmptyLines.map((line) => { + const match = line.match(/^(\s*)/); + return match ? match[1].length : 0; + }) + ); + + return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n"); + }; + + const normalizedFind = removeIndentation(find); + const contentLines = content.split("\n"); + const findLines = find.split("\n"); + + for (let i = 0; i <= contentLines.length - findLines.length; i++) { + const block = contentLines.slice(i, i + findLines.length).join("\n"); + if (removeIndentation(block) === normalizedFind) { + yield block; + } + } +}; + +export const EscapeNormalizedReplacer: Replacer = function* (content, find) { + const unescapeString = (str: string): string => { + return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar: string) => { + switch (capturedChar) { + case "n": + return "\n"; + case "t": + return "\t"; + case "r": + return "\r"; + case "'": + return "'"; + case '"': + return '"'; + case "`": + return "`"; + case "\\": + return "\\"; + case "\n": + return "\n"; + case "$": + return "$"; + default: + return match; + } + }); + }; + + const unescapedFind = unescapeString(find); + + if (content.includes(unescapedFind)) { + yield unescapedFind; + } + + const lines = content.split("\n"); + const findLines = unescapedFind.split("\n"); + + for (let i = 0; i <= lines.length - findLines.length; i++) { + const block = lines.slice(i, i + findLines.length).join("\n"); + const unescapedBlock = unescapeString(block); + + if (unescapedBlock === unescapedFind) { + yield block; + } + } +}; + +export const MultiOccurrenceReplacer: Replacer = function* (content, find) { + let startIndex = 0; + + while (true) { + const index = content.indexOf(find, startIndex); + if (index === -1) break; + + yield find; + startIndex = index + find.length; + } +}; + +export const TrimmedBoundaryReplacer: Replacer = function* (content, find) { + const trimmedFind = find.trim(); + + if (trimmedFind === find) { + return; + } + + if (content.includes(trimmedFind)) { + yield trimmedFind; + } + + const lines = content.split("\n"); + const findLines = find.split("\n"); + + for (let i = 0; i <= lines.length - findLines.length; i++) { + const block = lines.slice(i, i + findLines.length).join("\n"); + + if (block.trim() === trimmedFind) { + yield block; + } + } +}; + +export const ContextAwareReplacer: Replacer = function* (content, find) { + const findLines = find.split("\n"); + if (findLines.length < 3) { + return; + } + + if (findLines[findLines.length - 1] === "") { + findLines.pop(); + } + + const contentLines = content.split("\n"); + const firstLine = findLines[0].trim(); + const lastLine = findLines[findLines.length - 1].trim(); + + for (let i = 0; i < contentLines.length; i++) { + if (contentLines[i].trim() !== firstLine) continue; + + for (let j = i + 2; j < contentLines.length; j++) { + if (contentLines[j].trim() === lastLine) { + const blockLines = contentLines.slice(i, j + 1); + const block = blockLines.join("\n"); + + if (blockLines.length === findLines.length) { + let matchingLines = 0; + let totalNonEmptyLines = 0; + + for (let k = 1; k < blockLines.length - 1; k++) { + const blockLine = blockLines[k].trim(); + const findLine = findLines[k].trim(); + + if (blockLine.length > 0 || findLine.length > 0) { + totalNonEmptyLines++; + if (blockLine === findLine) { + matchingLines++; + } + } + } + + if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) { + yield block; + break; + } + } + break; + } + } + } +}; + +export function trimDiff(diff: string): string { + const lines = diff.split("\n"); + const contentLines = lines.filter( + (line) => + (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && + !line.startsWith("---") && + !line.startsWith("+++") + ); + + if (contentLines.length === 0) return diff; + + let min = Infinity; + for (const line of contentLines) { + const content = line.slice(1); + if (content.trim().length > 0) { + const match = content.match(/^(\s*)/); + if (match) min = Math.min(min, match[1].length); + } + } + if (min === Infinity || min === 0) return diff; + const trimmedLines = lines.map((line) => { + if ( + (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && + !line.startsWith("---") && + !line.startsWith("+++") + ) { + const prefix = line[0]; + const content = line.slice(1); + return prefix + content.slice(min); + } + return line; + }); + + return trimmedLines.join("\n"); +} + +export function replace(content: string, oldString: string, newString: string, replaceAll = false): string { + if (oldString === newString) { + throw new Error("No changes to apply: oldString and newString are identical."); + } + + let notFound = true; + + for (const replacer of [ + SimpleReplacer, + LineTrimmedReplacer, + BlockAnchorReplacer, + WhitespaceNormalizedReplacer, + IndentationFlexibleReplacer, + EscapeNormalizedReplacer, + TrimmedBoundaryReplacer, + ContextAwareReplacer, + MultiOccurrenceReplacer, + ]) { + for (const search of replacer(content, oldString)) { + const index = content.indexOf(search); + if (index === -1) continue; + notFound = false; + if (replaceAll) { + return content.replaceAll(search, newString); + } + const lastIndex = content.lastIndexOf(search); + if (index !== lastIndex) continue; + return content.substring(0, index) + newString + content.substring(index + search.length); + } + } + + if (notFound) { + throw new Error( + "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings." + ); + } + throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique."); +} diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 3bb4e874..022c0a10 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -124,6 +124,11 @@ Rules: - Never make up or hallucinate URLs - Include article dates in responses when available +NOTE EDITING (updateNote, Cline convention): +- Full rewrite: oldString="", newString=entire note content. +- Targeted edit: readWorkspace first, then oldString=exact text to find, newString=replacement. Include enough context in oldString to make it unique. +- oldString must match exactly including whitespace, or use enough surrounding lines for uniqueness. + INLINE CITATIONS (optional): Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation may optionally include a quote; omit the quote if you do not have the exact text from the source. @@ -555,26 +560,26 @@ export function formatItemContent(item: Item): string { } /** - * Formats note details with FULL content (no truncation) + * Extracts the note content as markdown. Uses same source as readWorkspace + * (blockContent serialized, or field1 fallback) so edits match what the AI sees. */ -function formatNoteDetailsFull(data: NoteData): string[] { - const lines: string[] = []; - - // OPTIMIZED: Prioritize blockContent for rich markdown serialization +export function getNoteContentAsMarkdown(data: NoteData): string { if (data.blockContent) { - // Use the markdown serializer to preserve structure and formatting const content = serializeBlockNote(data.blockContent as Block[]); - if (content) { - lines.push(` - Content:\n${content}`); - return lines; // Return early if successful - } + if (content) return content; } + return data.field1 ?? ""; +} - // Fallback to field1 (plain text) if blockContent is missing or empty - if (data.field1) { - lines.push(` - Content: ${data.field1}`); +/** + * Formats note details with FULL content (no truncation) + */ +function formatNoteDetailsFull(data: NoteData): string[] { + const lines: string[] = []; + const content = getNoteContentAsMarkdown(data); + if (content) { + lines.push(` - Content:\n${content}`); } - return lines; } From dfd06a41dbd0b0f5d0199353faf9018abc23051e Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 21 Feb 2026 10:58:51 -0500 Subject: [PATCH 12/56] feat: enforce read before write --- drizzle/0003_elite_harrier.sql | 17 + drizzle/meta/0003_snapshot.json | 1918 +++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/api/chat/route.ts | 3 + src/lib/ai/tools/index.ts | 2 + src/lib/ai/tools/read-workspace.ts | 29 +- src/lib/ai/tools/workspace-tools.ts | 27 +- src/lib/db/relations.ts | 9 + src/lib/db/schema.ts | 34 + src/lib/db/workspace-item-reads.ts | 79 + src/lib/utils/format-workspace-context.ts | 9 +- src/lib/utils/thread-id.ts | 16 + 12 files changed, 2144 insertions(+), 6 deletions(-) create mode 100644 drizzle/0003_elite_harrier.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 src/lib/db/workspace-item-reads.ts create mode 100644 src/lib/utils/thread-id.ts diff --git a/drizzle/0003_elite_harrier.sql b/drizzle/0003_elite_harrier.sql new file mode 100644 index 00000000..36963ed3 --- /dev/null +++ b/drizzle/0003_elite_harrier.sql @@ -0,0 +1,17 @@ +CREATE TABLE "workspace_item_reads" ( + "thread_id" uuid NOT NULL, + "item_id" text NOT NULL, + "last_modified" bigint NOT NULL, + "read_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_item_reads_thread_item_key" UNIQUE("thread_id","item_id") +); +--> statement-breakpoint +ALTER TABLE "workspace_item_reads" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "workspace_item_reads" ADD CONSTRAINT "workspace_item_reads_thread_id_chat_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."chat_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_workspace_item_reads_thread_item" ON "workspace_item_reads" USING btree ("thread_id" uuid_ops,"item_id" text_ops);--> statement-breakpoint +CREATE POLICY "Users can manage reads for threads in their workspaces" ON "workspace_item_reads" AS PERMISSIVE FOR ALL TO "authenticated" USING ((EXISTS ( SELECT 1 FROM chat_threads ct + JOIN workspaces w ON w.id = ct.workspace_id + WHERE ((ct.id = workspace_item_reads.thread_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) + OR (EXISTS ( SELECT 1 FROM chat_threads ct + JOIN workspace_collaborators c ON c.workspace_id = ct.workspace_id + WHERE ((ct.id = workspace_item_reads.thread_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..f74c0463 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1918 @@ +{ + "id": "4ff977d6-4f70-49fe-a07e-c39bf93e134e", + "prevId": "208b0e93-f789-49c6-abc8-e9328aab88b5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_account_user_id": { + "name": "idx_account_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_messages_thread": { + "name": "idx_chat_messages_thread", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_messages_thread_created": { + "name": "idx_chat_messages_thread_created", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_threads_workspace": { + "name": "idx_chat_threads_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_user": { + "name": "idx_chat_threads_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_last_message": { + "name": "idx_chat_threads_last_message", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can manage threads in their workspaces": { + "name": "Users can manage threads in their workspaces", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1 FROM workspaces w\n WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM workspace_collaborators c\n WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_session_user_id": { + "name": "idx_session_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_token": { + "name": "idx_session_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_email": { + "name": "idx_user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_user_profiles_user_id": { + "name": "idx_user_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profiles_user_id_key": { + "name": "user_profiles_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": { + "Users can insert their own profile": { + "name": "Users can insert their own profile", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can update their own profile": { + "name": "Users can update their own profile", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ] + }, + "Users can view their own profile": { + "name": "Users can view their own profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_verification_identifier": { + "name": "idx_verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_collaborators": { + "name": "workspace_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_collaborators_lookup": { + "name": "idx_workspace_collaborators_lookup", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_workspace": { + "name": "idx_workspace_collaborators_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_last_opened_at": { + "name": "idx_workspace_collaborators_last_opened_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_collaborators_workspace_id_fkey": { + "name": "workspace_collaborators_workspace_id_fkey", + "tableFrom": "workspace_collaborators", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_collaborators_invite_token_unique": { + "name": "workspace_collaborators_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + }, + "workspace_collaborators_workspace_user_unique": { + "name": "workspace_collaborators_workspace_user_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "policies": { + "Owners can manage collaborators": { + "name": "Owners can manage collaborators", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Collaborators can view their access": { + "name": "Collaborators can view their access", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "(user_id = (auth.jwt() ->> 'sub'::text))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_events": { + "name": "workspace_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspace_events_event_id": { + "name": "idx_workspace_events_event_id", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_timestamp": { + "name": "idx_workspace_events_timestamp", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_user_name": { + "name": "idx_workspace_events_user_name", + "columns": [ + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_workspace": { + "name": "idx_workspace_events_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_events_workspace_id_fkey": { + "name": "workspace_events_workspace_id_fkey", + "tableFrom": "workspace_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_events_event_id_key": { + "name": "workspace_events_event_id_key", + "nullsNotDistinct": false, + "columns": [ + "event_id" + ] + } + }, + "policies": { + "Users can insert workspace events they have write access to": { + "name": "Users can insert workspace events they have write access to", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + }, + "Users can read workspace events they have access to": { + "name": "Users can read workspace events they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_invites_token": { + "name": "idx_workspace_invites_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_email": { + "name": "idx_workspace_invites_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_workspace": { + "name": "idx_workspace_invites_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_fkey": { + "name": "workspace_invites_workspace_id_fkey", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invites_token_key": { + "name": "workspace_invites_token_key", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": { + "Public can view invite by token": { + "name": "Public can view invite by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "true" + }, + "Users can insert invites for workspaces they own/edit": { + "name": "Users can insert invites for workspaces they own/edit", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_invites.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_invites.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_reads": { + "name": "workspace_item_reads", + "schema": "", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_item_reads_thread_item": { + "name": "idx_workspace_item_reads_thread_item", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_reads_thread_id_chat_threads_id_fk": { + "name": "workspace_item_reads_thread_id_chat_threads_id_fk", + "tableFrom": "workspace_item_reads", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_item_reads_thread_item_key": { + "name": "workspace_item_reads_thread_item_key", + "nullsNotDistinct": false, + "columns": [ + "thread_id", + "item_id" + ] + } + }, + "policies": { + "Users can manage reads for threads in their workspaces": { + "name": "Users can manage reads for threads in their workspaces", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1 FROM chat_threads ct\n JOIN workspaces w ON w.id = ct.workspace_id\n WHERE ((ct.id = workspace_item_reads.thread_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM chat_threads ct\n JOIN workspace_collaborators c ON c.workspace_id = ct.workspace_id\n WHERE ((ct.id = workspace_item_reads.thread_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_share_links": { + "name": "workspace_share_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_workspace_share_links_token": { + "name": "idx_workspace_share_links_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_share_links_workspace": { + "name": "idx_workspace_share_links_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_share_links_workspace_id_fkey": { + "name": "workspace_share_links_workspace_id_fkey", + "tableFrom": "workspace_share_links", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_share_links_token_key": { + "name": "workspace_share_links_token_key", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + }, + "workspace_share_links_workspace_key": { + "name": "workspace_share_links_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "workspace_id" + ] + } + }, + "policies": { + "Public can view share link by token": { + "name": "Public can view share link by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "true" + }, + "Owners and editors can manage share links": { + "name": "Owners and editors can manage share links", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "authenticated" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_share_links.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_snapshots": { + "name": "workspace_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "snapshot_version": { + "name": "snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event_count": { + "name": "event_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_snapshots_version": { + "name": "idx_workspace_snapshots_version", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "snapshot_version", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_snapshots_workspace": { + "name": "idx_workspace_snapshots_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_snapshots_workspace_id_fkey": { + "name": "workspace_snapshots_workspace_id_fkey", + "tableFrom": "workspace_snapshots", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_snapshots_workspace_id_snapshot_version_key": { + "name": "workspace_snapshots_workspace_id_snapshot_version_key", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "snapshot_version" + ] + } + }, + "policies": { + "Service role can insert workspace snapshots": { + "name": "Service role can insert workspace snapshots", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "true" + }, + "Users can read workspace snapshots they have access to": { + "name": "Users can read workspace snapshots they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'blank'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspaces_created_at": { + "name": "idx_workspaces_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_slug": { + "name": "idx_workspaces_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_id": { + "name": "idx_workspaces_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_slug": { + "name": "idx_workspaces_user_slug", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_sort_order": { + "name": "idx_workspaces_user_sort_order", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_last_opened_at": { + "name": "idx_workspaces_last_opened_at", + "columns": [ + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can delete their own workspaces": { + "name": "Users can delete their own workspaces", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can insert their own workspaces": { + "name": "Users can insert their own workspaces", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ] + }, + "Users can update their own workspaces": { + "name": "Users can update their own workspaces", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ] + }, + "Users can view their own workspaces": { + "name": "Users can view their own workspaces", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 222c9751..e0009c66 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1771631691687, "tag": "0002_deep_shadowcat", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1771686859689, + "tag": "0003_elite_harrier", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 9392bdc3..7d920fb3 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -190,6 +190,8 @@ export async function POST(req: Request) { const system = body.system || ""; workspaceId = extractWorkspaceId(body); activeFolderId = body.activeFolderId; + // AssistantChatTransport passes thread remoteId as body.id (see assistant-ui react-ai-sdk) + const threadId = body.id ?? body.threadId ?? null; // Convert messages let convertedMessages; @@ -261,6 +263,7 @@ export async function POST(req: Request) { workspaceId, userId, activeFolderId, + threadId, clientTools: body.tools, enableDeepResearch: false, }); diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index 2f180aa9..e0a79b4e 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -29,6 +29,7 @@ export interface ChatToolsConfig { workspaceId: string | null; userId: string | null; activeFolderId?: string; + threadId?: string | null; clientTools?: Record; enableDeepResearch?: boolean; } @@ -41,6 +42,7 @@ export function createChatTools(config: ChatToolsConfig): Record { workspaceId: config.workspaceId, userId: config.userId, activeFolderId: config.activeFolderId, + threadId: config.threadId ?? null, }; // Safeguard frontendTools diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts index d2eb4d8f..68546642 100644 --- a/src/lib/ai/tools/read-workspace.ts +++ b/src/lib/ai/tools/read-workspace.ts @@ -5,12 +5,15 @@ import { fuzzyMatchItem } from "./tool-utils"; import { resolveItemByPath } from "./workspace-search-utils"; import { formatItemContent } from "@/lib/utils/format-workspace-context"; import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs"; +import { recordWorkspaceItemRead } from "@/lib/db/workspace-item-reads"; +import { logger } from "@/lib/utils/logger"; +import { isValidThreadIdForDb } from "@/lib/utils/thread-id"; import type { WorkspaceToolContext } from "./workspace-tools"; export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { return tool({ description: - "Read the full content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Use path when items share the same name. Use when you need the complete content of a specific card to answer questions or perform updates.", + "Read the full content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. REQUIRED before targeted updateNote edits — the edit tool will error otherwise. Use path when items share the same name. When editing notes, use exact text from the Content section only (not the wrapper).", inputSchema: zodSchema( z.object({ path: z @@ -84,6 +87,30 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { const content = formatItemContent(item); const vpath = getVirtualPath(item, items); + // Record read for read-before-write enforcement (targeted edits) + // Skip when threadId is a placeholder (e.g. DEFAULT_THREAD_ID before thread is created) + logger.debug("[readWorkspace] Recording read:", { + threadId: ctx.threadId, + threadIdType: typeof ctx.threadId, + isValidForDb: isValidThreadIdForDb(ctx.threadId), + itemId: item.id, + lastModified: item.lastModified ?? 0, + }); + if (isValidThreadIdForDb(ctx.threadId)) { + try { + await recordWorkspaceItemRead( + ctx.threadId, + item.id, + item.lastModified ?? 0 + ); + logger.debug("[readWorkspace] Recorded read successfully"); + } catch (err) { + logger.warn("[readWorkspace] Failed to record read:", err); + } + } else { + logger.debug("[readWorkspace] Skipping record (invalid threadId for DB)"); + } + return { success: true, itemName: item.name, diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 0de4f982..9960c3f8 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -5,11 +5,14 @@ import { workspaceWorker } from "@/lib/ai/workers"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import type { Item } from "@/lib/workspace-state/types"; import { loadStateForTool, fuzzyMatchItem, getAvailableItemsList } from "./tool-utils"; +import { assertWorkspaceItemRead } from "@/lib/db/workspace-item-reads"; +import { isValidThreadIdForDb } from "@/lib/utils/thread-id"; export interface WorkspaceToolContext { workspaceId: string | null; userId: string | null; activeFolderId?: string; + threadId?: string | null; } /** @@ -73,7 +76,7 @@ export function createNoteTool(ctx: WorkspaceToolContext) { export function createUpdateNoteTool(ctx: WorkspaceToolContext) { return tool({ description: - "Update a note. Full rewrite: oldString='', newString=entire note content. Targeted edit: oldString=exact text to find (from readWorkspace), newString=replacement. Include enough context in oldString to make it unique.", + "Update a note. You MUST use readWorkspace at least once before targeted edits — the tool will error otherwise. Full rewrite: oldString='', newString=entire note. Targeted edit: readWorkspace first, then oldString=exact text from the Content section (never include wrapper), newString=replacement. Preserve exact whitespace/indentation. Fails if oldString not found or matches multiple times — include more context or use replaceAll.", inputSchema: zodSchema( z .object({ @@ -81,12 +84,12 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { oldString: z .string() .describe( - "Text to find. Use empty string '' for full rewrite; otherwise exact text from readWorkspace for targeted edit." + "Text to find. Use '' for full rewrite. For targeted edit: exact text from readWorkspace Content section — match exactly including whitespace. Include enough context to make it unique, or use replaceAll to change every instance." ), newString: z .string() .describe("Replacement text (entire note if oldString is empty)"), - replaceAll: z.boolean().optional().default(false), + replaceAll: z.boolean().optional().default(false).describe("Replace every occurrence of oldString; use for renaming or changing repeated text."), title: z.string().optional().describe("New title for the note. If not provided, the existing title will be preserved."), sources: z .array( @@ -169,6 +172,24 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { matchedId: matchedNote.id, }); + // Read-before-write: for targeted edits, assert item was read + // Skip assert when threadId is a placeholder (e.g. DEFAULT_THREAD_ID before thread exists) + const isTargetedEdit = oldString.trim().length > 0; + if (isTargetedEdit && isValidThreadIdForDb(ctx.threadId)) { + const currentLastModified = matchedNote.lastModified ?? 0; + const assert = await assertWorkspaceItemRead( + ctx.threadId, + matchedNote.id, + currentLastModified + ); + if (!assert.ok) { + return { + success: false, + message: assert.message, + }; + } + } + const workerResult = await workspaceWorker("update", { workspaceId: ctx.workspaceId, itemId: matchedNote.id, diff --git a/src/lib/db/relations.ts b/src/lib/db/relations.ts index b442377d..1bf2a106 100644 --- a/src/lib/db/relations.ts +++ b/src/lib/db/relations.ts @@ -5,6 +5,7 @@ import { workspaceEvents, chatThreads, chatMessages, + workspaceItemReads, } from "./schema"; // workspace_shares removed - sharing is now fork-based (users import copies) @@ -34,6 +35,14 @@ export const chatThreadsRelations = relations(chatThreads, ({ one, many }) => ({ references: [workspaces.id], }), messages: many(chatMessages), + workspaceItemReads: many(workspaceItemReads), +})); + +export const workspaceItemReadsRelations = relations(workspaceItemReads, ({ one }) => ({ + thread: one(chatThreads, { + fields: [workspaceItemReads.threadId], + references: [chatThreads.id], + }), })); export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({ diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 32b96800..96c15e7b 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -335,3 +335,37 @@ export const chatMessages = pgTable("chat_messages", { table.createdAt.asc().nullsLast().op("timestamptz_ops") ), ]); + +// Read-before-write tracking for workspace items (FileTime pattern) +export const workspaceItemReads = pgTable( + "workspace_item_reads", + { + threadId: uuid("thread_id") + .notNull() + .references(() => chatThreads.id, { onDelete: "cascade" }), + itemId: text("item_id").notNull(), + lastModified: bigint("last_modified", { mode: "number" }).notNull(), + readAt: timestamp("read_at", { withTimezone: true, mode: "string" }) + .defaultNow() + .notNull(), + }, + (table) => [ + unique("workspace_item_reads_thread_item_key").on(table.threadId, table.itemId), + index("idx_workspace_item_reads_thread_item").using( + "btree", + table.threadId.asc().nullsLast().op("uuid_ops"), + table.itemId.asc().nullsLast().op("text_ops") + ), + pgPolicy("Users can manage reads for threads in their workspaces", { + as: "permissive", + for: "all", + to: ["authenticated"], + using: sql`(EXISTS ( SELECT 1 FROM chat_threads ct + JOIN workspaces w ON w.id = ct.workspace_id + WHERE ((ct.id = workspace_item_reads.thread_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) + OR (EXISTS ( SELECT 1 FROM chat_threads ct + JOIN workspace_collaborators c ON c.workspace_id = ct.workspace_id + WHERE ((ct.id = workspace_item_reads.thread_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))`, + }), + ] +); diff --git a/src/lib/db/workspace-item-reads.ts b/src/lib/db/workspace-item-reads.ts new file mode 100644 index 00000000..2711f199 --- /dev/null +++ b/src/lib/db/workspace-item-reads.ts @@ -0,0 +1,79 @@ +/** + * Read-before-write tracking for workspace items (FileTime pattern). + * Records when a thread reads an item; asserts before targeted edits. + */ + +import { db, workspaceItemReads } from "@/lib/db/client"; +import { eq, and } from "drizzle-orm"; +import { logger } from "@/lib/utils/logger"; + +/** + * Record that a thread has read an item at the given lastModified. + * Upserts: if thread+item exists, updates lastModified and readAt. + */ +export async function recordWorkspaceItemRead( + threadId: string, + itemId: string, + lastModified: number +): Promise { + logger.debug("[workspace_item_reads] Writing to DB:", { + threadId, + itemId, + lastModified, + op: "upsert", + }); + await db + .insert(workspaceItemReads) + .values({ + threadId, + itemId, + lastModified, + }) + .onConflictDoUpdate({ + target: [workspaceItemReads.threadId, workspaceItemReads.itemId], + set: { + lastModified, + readAt: new Date().toISOString(), + }, + }); +} + +/** + * Assert that the thread has read the item and the lastModified matches. + * Used before targeted edits (oldString !== ''). + * @returns true if assertion passes + * @throws never - returns an error object on failure + */ +export async function assertWorkspaceItemRead( + threadId: string, + itemId: string, + currentLastModified: number +): Promise<{ ok: true } | { ok: false; message: string }> { + const [row] = await db + .select() + .from(workspaceItemReads) + .where( + and( + eq(workspaceItemReads.threadId, threadId), + eq(workspaceItemReads.itemId, itemId) + ) + ) + .limit(1); + + if (!row) { + return { + ok: false, + message: + "Read required before targeted edit. Use readWorkspace to read the note first, then retry updateNote with exact text from the content.", + }; + } + + if (row.lastModified !== currentLastModified) { + return { + ok: false, + message: `Note was modified since last read. Please use readWorkspace to get the latest content, then retry the edit.`, + }; + } + + return { ok: true }; +} diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 022c0a10..beea00e2 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -125,9 +125,14 @@ Rules: - Include article dates in responses when available NOTE EDITING (updateNote, Cline convention): +- You MUST use readWorkspace at least once before a targeted edit. The tool will error if you edit without reading. - Full rewrite: oldString="", newString=entire note content. -- Targeted edit: readWorkspace first, then oldString=exact text to find, newString=replacement. Include enough context in oldString to make it unique. -- oldString must match exactly including whitespace, or use enough surrounding lines for uniqueness. +- Targeted edit: readWorkspace first, then oldString=exact text to find (from the Content section only), newString=replacement. Extract oldString from the note body — never include the wrapper or " - Content:" prefix. +- When editing from readWorkspace output, preserve exact indentation and whitespace. Match the text as it appears in the Content section. +- The edit will FAIL if oldString is not found with "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings." +- The edit will FAIL if oldString matches multiple times with "Found multiple matches for oldString. Provide more surrounding context to make the match unique." Use more surrounding lines in oldString or use replaceAll to change every instance. +- Use replaceAll for replacing/renaming across the entire note (e.g., rename a term). +- Only use emojis if the user explicitly requests them. Avoid adding emojis unless asked. INLINE CITATIONS (optional): Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation may optionally include a quote; omit the quote if you do not have the exact text from the source. diff --git a/src/lib/utils/thread-id.ts b/src/lib/utils/thread-id.ts new file mode 100644 index 00000000..de64fa10 --- /dev/null +++ b/src/lib/utils/thread-id.ts @@ -0,0 +1,16 @@ +/** + * Thread ID validation for read-before-write enforcement. + * + * assistant-ui uses "DEFAULT_THREAD_ID" (from ExternalStoreThreadListRuntimeCore) + * as a placeholder before a thread is persisted. The real UUID is set when: + * - User sends first message -> adapter.initialize() POSTs to /api/threads -> returns remoteId + * - AssistantChatTransport uses: id = (await mainItem.initialize())?.remoteId ?? options.id + * + * Until then, body.id can be "DEFAULT_THREAD_ID", which is not a valid UUID and will fail + * DB inserts (thread_id references chat_threads.id). We skip recording/assert when invalid. + */ +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isValidThreadIdForDb(threadId: string | null | undefined): threadId is string { + return !!threadId && UUID_REGEX.test(threadId); +} From ebf991386c6126ada3cf78c5d3cb27988e49a086 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:01:58 -0500 Subject: [PATCH 13/56] fix: pdf highlight color --- src/components/pdf/AppPdfViewer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx index 68c568dd..325e6a5f 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -970,6 +970,7 @@ const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName, ( )} From 494302384881d69711abb9146cfcb54161472128 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:09:26 -0500 Subject: [PATCH 14/56] feat: durable recordings --- .gitignore | 1 + next.config.ts | 3 +- package.json | 1 + src/app/api/audio/process/route.ts | 171 ++++-------------- src/app/api/audio/process/status/route.ts | 69 +++++++ .../workspace-canvas/AudioCardContent.tsx | 26 +-- .../WorkspaceCanvasDropzone.tsx | 19 +- .../workspace-canvas/WorkspaceHeader.tsx | 24 +-- .../workspace-canvas/WorkspaceSection.tsx | 21 +-- src/lib/audio/poll-audio-processing.ts | 40 ++++ src/workflows/audio-transcribe/index.ts | 21 +++ .../steps/download-and-upload.ts | 52 ++++++ src/workflows/audio-transcribe/steps/index.ts | 2 + .../audio-transcribe/steps/transcribe.ts | 96 ++++++++++ 14 files changed, 360 insertions(+), 186 deletions(-) create mode 100644 src/app/api/audio/process/status/route.ts create mode 100644 src/lib/audio/poll-audio-processing.ts create mode 100644 src/workflows/audio-transcribe/index.ts create mode 100644 src/workflows/audio-transcribe/steps/download-and-upload.ts create mode 100644 src/workflows/audio-transcribe/steps/index.ts create mode 100644 src/workflows/audio-transcribe/steps/transcribe.ts diff --git a/.gitignore b/.gitignore index 8a8d6139..81dc4e73 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # misc .DS_Store +tmp/ assistant-ui-main/ *.pem diff --git a/next.config.ts b/next.config.ts index 938792cc..f8861d2d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { withWorkflow } from "workflow/next"; const nextConfig: NextConfig = { reactCompiler: true, @@ -37,4 +38,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withWorkflow(nextConfig); diff --git a/package.json b/package.json index 7cf2549e..0549d1ea 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "streamdown": "^2.2.0", "tailwind-merge": "^3.4.0", "tw-shimmer": "^0.4.6", + "workflow": "4.1.0-beta.60", "zod": "^4.3.6", "zustand": "^5.0.11" }, diff --git a/src/app/api/audio/process/route.ts b/src/app/api/audio/process/route.ts index d1cfbf48..d1a111c3 100644 --- a/src/app/api/audio/process/route.ts +++ b/src/app/api/audio/process/route.ts @@ -1,25 +1,18 @@ -import { - GoogleGenAI, - Type, - createPartFromUri, - createUserContent, -} from "@google/genai"; import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { start } from "workflow/api"; +import { audioTranscribeWorkflow } from "@/workflows/audio-transcribe"; export const dynamic = "force-dynamic"; -const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; - /** * POST /api/audio/process - * Receives an audio file URL, downloads it, uploads to Gemini Files API, - * and returns a structured transcript + summary. + * Receives an audio file URL, runs a durable workflow to download, upload to Gemini, + * and transcribe. Returns structured transcript + summary. */ export async function POST(req: NextRequest) { try { - // Auth check const session = await auth.api.getSession({ headers: await headers(), }); @@ -27,7 +20,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (!apiKey) { + if (!process.env.GOOGLE_GENERATIVE_AI_API_KEY) { return NextResponse.json( { error: "GOOGLE_GENERATIVE_AI_API_KEY is not set" }, { status: 500 } @@ -35,7 +28,7 @@ export async function POST(req: NextRequest) { } const body = await req.json(); - const { fileUrl, filename, mimeType } = body; + const { fileUrl, filename, mimeType, itemId } = body; if (!fileUrl) { return NextResponse.json( @@ -44,25 +37,35 @@ export async function POST(req: NextRequest) { ); } + if (!itemId || typeof itemId !== "string") { + return NextResponse.json( + { error: "itemId is required for polling" }, + { status: 400 } + ); + } + // Validate URL origin to prevent SSRF - const allowedHosts = [ - process.env.NEXT_PUBLIC_SUPABASE_URL - ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname - : null, - ].filter(Boolean) as string[]; + const allowedHosts: string[] = []; + if (process.env.NEXT_PUBLIC_SUPABASE_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname); + } + if (process.env.NEXT_PUBLIC_APP_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname); + } + allowedHosts.push("localhost"); // local storage in dev let parsedUrl: URL; try { parsedUrl = new URL(fileUrl); } catch { - return NextResponse.json( - { error: "Invalid fileUrl" }, - { status: 400 } - ); + return NextResponse.json({ error: "Invalid fileUrl" }, { status: 400 }); } if ( - !allowedHosts.some((host) => parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`)) + !allowedHosts.some( + (host) => + parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`) + ) ) { return NextResponse.json( { error: "fileUrl origin is not allowed" }, @@ -70,123 +73,25 @@ export async function POST(req: NextRequest) { ); } - // Determine MIME type const audioMimeType = mimeType || guessMimeType(filename || fileUrl); - // Download the audio file from storage - const audioResponse = await fetch(fileUrl, { redirect: "error" }); - if (!audioResponse.ok) { - return NextResponse.json( - { error: "Failed to download audio" }, - { status: 500 } - ); - } - - // Enforce a 200 MB size limit before buffering into memory - const MAX_AUDIO_SIZE = 200 * 1024 * 1024; - const contentLength = Number(audioResponse.headers.get("content-length") || "0"); - if (contentLength > MAX_AUDIO_SIZE) { - return NextResponse.json( - { error: "Audio file exceeds the 200 MB size limit" }, - { status: 400 } - ); - } - - const audioBuffer = await audioResponse.arrayBuffer(); - if (audioBuffer.byteLength > MAX_AUDIO_SIZE) { - return NextResponse.json( - { error: "Audio file exceeds the 200 MB size limit" }, - { status: 400 } - ); - } - - const client = new GoogleGenAI({ apiKey }); - - // Upload audio to Gemini Files API (supports up to 2 GB, avoids 20 MB inline limit) - const audioBlob = new Blob([audioBuffer], { type: audioMimeType }); - const uploadedFile = await client.files.upload({ - file: audioBlob, - config: { mimeType: audioMimeType }, - }); - - if (!uploadedFile.uri || !uploadedFile.mimeType) { - return NextResponse.json( - { error: "Failed to upload audio to Gemini" }, - { status: 500 } - ); - } - - const prompt = `Process this audio file and generate a detailed transcription and summary. - -Requirements: -1. Provide a comprehensive summary of the entire audio content. -2. Identify distinct speakers (e.g., Speaker 1, Speaker 2, or names if context allows). -3. Provide accurate timestamps for each segment (Format: MM:SS). -4. Provide the total duration of the audio in seconds (a single number, e.g. 180.5 for 3 minutes).`; - - const response = await client.models.generateContent({ - model: "gemini-2.5-flash-lite", - contents: createUserContent([ - createPartFromUri(uploadedFile.uri, uploadedFile.mimeType), - prompt, - ]), - config: { - responseMimeType: "application/json", - responseSchema: { - type: Type.OBJECT, - properties: { - summary: { - type: Type.STRING, - description: "A concise summary of the audio content.", - }, - duration: { - type: Type.NUMBER, - description: "Total duration of the audio in seconds.", - }, - segments: { - type: Type.ARRAY, - description: - "List of transcribed segments with speaker and timestamp.", - items: { - type: Type.OBJECT, - properties: { - speaker: { type: Type.STRING }, - timestamp: { type: Type.STRING }, - content: { type: Type.STRING }, - }, - required: [ - "speaker", - "timestamp", - "content", - ], - }, - }, - }, - required: ["summary", "segments"], - }, - }, - }); - - const resultText = response.text; - if (!resultText) { - return NextResponse.json( - { error: "No response from Gemini" }, - { status: 500 } - ); - } - - const result = JSON.parse(resultText); + // Start durable workflow; return immediately for client to poll + const run = await start(audioTranscribeWorkflow, [ + fileUrl, + audioMimeType, + ]); return NextResponse.json({ - success: true, - summary: result.summary, - segments: result.segments, - duration: typeof result.duration === "number" && result.duration > 0 ? result.duration : undefined, + runId: run.runId, + itemId, }); } catch (error: unknown) { console.error("[AUDIO_PROCESS] Error:", error); return NextResponse.json( - { error: "Failed to process audio" }, + { + error: + error instanceof Error ? error.message : "Failed to process audio", + }, { status: 500 } ); } @@ -202,5 +107,5 @@ function guessMimeType(filenameOrUrl: string): string { if (lower.endsWith(".aiff")) return "audio/aiff"; if (lower.endsWith(".webm")) return "audio/webm"; if (lower.endsWith(".m4a")) return "audio/mp4"; - return "audio/mp3"; // Default fallback + return "audio/mp3"; } diff --git a/src/app/api/audio/process/status/route.ts b/src/app/api/audio/process/status/route.ts new file mode 100644 index 00000000..a03d4d7a --- /dev/null +++ b/src/app/api/audio/process/status/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { getRun } from "workflow/api"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/audio/process/status?runId=xxx + * Poll workflow status. Returns { status, result? } when completed or { status, error? } when failed. + */ +export async function GET(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const runId = req.nextUrl.searchParams.get("runId"); + if (!runId) { + return NextResponse.json( + { error: "runId is required" }, + { status: 400 } + ); + } + + const run = getRun(runId); + const status = await run.status; + + if (status === "completed") { + const result = await run.returnValue; + return NextResponse.json({ + status: "completed", + result: { + summary: result.summary, + segments: result.segments, + duration: result.duration, + }, + }); + } + + if (status === "failed") { + let errorMessage = "Processing failed"; + try { + await run.returnValue; + } catch (err) { + errorMessage = + err instanceof Error ? err.message : String(err ?? "Processing failed"); + } + return NextResponse.json({ + status: "failed", + error: errorMessage, + }); + } + + return NextResponse.json({ status: "running" }); + } catch (error: unknown) { + console.error("[AUDIO_PROCESS_STATUS] Error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to get status", + }, + { status: 500 } + ); + } +} diff --git a/src/components/workspace-canvas/AudioCardContent.tsx b/src/components/workspace-canvas/AudioCardContent.tsx index b9de6580..bfe8da50 100644 --- a/src/components/workspace-canvas/AudioCardContent.tsx +++ b/src/components/workspace-canvas/AudioCardContent.tsx @@ -80,22 +80,22 @@ export function AudioCardContent({ item, isCompact = false, isScrollLocked = fal fileUrl: audioData.fileUrl, filename: audioData.filename, mimeType: audioData.mimeType || "audio/webm", + itemId: item.id, }), }) .then((res) => res.json()) - .then((result) => { - window.dispatchEvent( - new CustomEvent("audio-processing-complete", { - detail: result.success - ? { - itemId: item.id, - summary: result.summary, - segments: result.segments, - duration: result.duration, - } - : { itemId: item.id, error: result.error || "Processing failed" }, - }) - ); + .then((data) => { + if (data.runId && data.itemId) { + import("@/lib/audio/poll-audio-processing").then(({ pollAudioProcessing }) => + pollAudioProcessing(data.runId, data.itemId) + ); + } else { + window.dispatchEvent( + new CustomEvent("audio-processing-complete", { + detail: { itemId: item.id, error: data.error || "Processing failed" }, + }) + ); + } }) .catch((err) => { window.dispatchEvent( diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx index af82924b..359155b1 100644 --- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx +++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx @@ -331,17 +331,22 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro fileUrl: result.fileUrl, filename: result.filename, mimeType: result.originalFile.type || 'audio/mpeg', + itemId, }), }) .then(res => res.json()) .then(data => { - window.dispatchEvent( - new CustomEvent('audio-processing-complete', { - detail: data.success - ? { itemId, summary: data.summary, segments: data.segments, duration: data.duration } - : { itemId, error: data.error || 'Processing failed' }, - }) - ); + if (data.runId && data.itemId) { + import('@/lib/audio/poll-audio-processing').then(({ pollAudioProcessing }) => + pollAudioProcessing(data.runId, data.itemId) + ); + } else { + window.dispatchEvent( + new CustomEvent('audio-processing-complete', { + detail: { itemId, error: data.error || 'Processing failed' }, + }) + ); + } }) .catch(err => { window.dispatchEvent( diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index 78733e4d..f494d2b4 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -369,7 +369,7 @@ export function WorkspaceHeader({ toast.dismiss(loadingToastId); toast.success("Audio uploaded — analyzing with Gemini..."); - // Kick off Gemini processing in the background + // Kick off durable workflow and poll for completion fetch("/api/audio/process", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -377,29 +377,19 @@ export function WorkspaceHeader({ fileUrl, filename: file.name, mimeType: file.type || "audio/webm", + itemId, }), }) .then((res) => res.json()) - .then((result) => { - if (result.success) { - // Dispatch a custom event to update the audio card data - window.dispatchEvent( - new CustomEvent("audio-processing-complete", { - detail: { - itemId, - summary: result.summary, - segments: result.segments, - duration: result.duration, - }, - }) + .then((data) => { + if (data.runId && data.itemId) { + import("@/lib/audio/poll-audio-processing").then(({ pollAudioProcessing }) => + pollAudioProcessing(data.runId, data.itemId) ); } else { window.dispatchEvent( new CustomEvent("audio-processing-complete", { - detail: { - itemId, - error: result.error || "Processing failed", - }, + detail: { itemId, error: data.error || "Processing failed" }, }) ); } diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 4eab3256..c1589d2a 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -549,28 +549,19 @@ export function WorkspaceSection({ fileUrl, filename: file.name, mimeType: file.type || "audio/webm", + itemId, }), }) .then((res) => res.json()) - .then((result) => { - if (result.success) { - window.dispatchEvent( - new CustomEvent("audio-processing-complete", { - detail: { - itemId, - summary: result.summary, - segments: result.segments, - duration: result.duration, - }, - }) + .then((data) => { + if (data.runId && data.itemId) { + import("@/lib/audio/poll-audio-processing").then(({ pollAudioProcessing }) => + pollAudioProcessing(data.runId, data.itemId) ); } else { window.dispatchEvent( new CustomEvent("audio-processing-complete", { - detail: { - itemId, - error: result.error || "Processing failed", - }, + detail: { itemId, error: data.error || "Processing failed" }, }) ); } diff --git a/src/lib/audio/poll-audio-processing.ts b/src/lib/audio/poll-audio-processing.ts new file mode 100644 index 00000000..ad1e0d67 --- /dev/null +++ b/src/lib/audio/poll-audio-processing.ts @@ -0,0 +1,40 @@ +const POLL_INTERVAL_MS = 10_000; + +/** + * Polls the audio processing status endpoint until the workflow completes or fails. + * Dispatches audio-processing-complete when done. + */ +export async function pollAudioProcessing(runId: string, itemId: string): Promise { + while (true) { + const res = await fetch(`/api/audio/process/status?runId=${encodeURIComponent(runId)}`); + const data = await res.json(); + + if (data.status === "completed") { + window.dispatchEvent( + new CustomEvent("audio-processing-complete", { + detail: { + itemId, + summary: data.result.summary, + segments: data.result.segments, + duration: data.result.duration, + }, + }) + ); + return; + } + + if (data.status === "failed") { + window.dispatchEvent( + new CustomEvent("audio-processing-complete", { + detail: { + itemId, + error: data.error || "Processing failed", + }, + }) + ); + return; + } + + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } +} diff --git a/src/workflows/audio-transcribe/index.ts b/src/workflows/audio-transcribe/index.ts new file mode 100644 index 00000000..eb773319 --- /dev/null +++ b/src/workflows/audio-transcribe/index.ts @@ -0,0 +1,21 @@ +import { downloadAndUploadToGemini, transcribeWithGemini } from "./steps"; + +/** + * Durable workflow for audio transcription. + * Steps are retriable and survive restarts. + * + * @param fileUrl - URL of the audio file (must be from allowed hosts) + * @param mimeType - MIME type of the audio + */ +export async function audioTranscribeWorkflow(fileUrl: string, mimeType: string) { + "use workflow"; + + const { fileUri, mimeType: geminiMimeType } = await downloadAndUploadToGemini( + fileUrl, + mimeType + ); + + const result = await transcribeWithGemini(fileUri, geminiMimeType); + + return result; +} diff --git a/src/workflows/audio-transcribe/steps/download-and-upload.ts b/src/workflows/audio-transcribe/steps/download-and-upload.ts new file mode 100644 index 00000000..e5b3369d --- /dev/null +++ b/src/workflows/audio-transcribe/steps/download-and-upload.ts @@ -0,0 +1,52 @@ +import { + GoogleGenAI, +} from "@google/genai"; + +const MAX_AUDIO_SIZE = 200 * 1024 * 1024; + +/** + * Step: Download audio from URL and upload to Gemini Files API. + * Returns the file URI for use in transcription. + */ +export async function downloadAndUploadToGemini( + fileUrl: string, + mimeType: string +): Promise<{ fileUri: string; mimeType: string }> { + "use step"; + + const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; + if (!apiKey) { + throw new Error("GOOGLE_GENERATIVE_AI_API_KEY is not set"); + } + + const audioResponse = await fetch(fileUrl, { redirect: "error" }); + if (!audioResponse.ok) { + throw new Error("Failed to download audio"); + } + + const contentLength = Number(audioResponse.headers.get("content-length") || "0"); + if (contentLength > MAX_AUDIO_SIZE) { + throw new Error("Audio file exceeds the 200 MB size limit"); + } + + const audioBuffer = await audioResponse.arrayBuffer(); + if (audioBuffer.byteLength > MAX_AUDIO_SIZE) { + throw new Error("Audio file exceeds the 200 MB size limit"); + } + + const client = new GoogleGenAI({ apiKey }); + const audioBlob = new Blob([audioBuffer], { type: mimeType }); + const uploadedFile = await client.files.upload({ + file: audioBlob, + config: { mimeType }, + }); + + if (!uploadedFile.uri || !uploadedFile.mimeType) { + throw new Error("Failed to upload audio to Gemini"); + } + + return { + fileUri: uploadedFile.uri, + mimeType: uploadedFile.mimeType, + }; +} diff --git a/src/workflows/audio-transcribe/steps/index.ts b/src/workflows/audio-transcribe/steps/index.ts new file mode 100644 index 00000000..6cc18b1a --- /dev/null +++ b/src/workflows/audio-transcribe/steps/index.ts @@ -0,0 +1,2 @@ +export { downloadAndUploadToGemini } from "./download-and-upload"; +export { transcribeWithGemini, type TranscribeResult } from "./transcribe"; diff --git a/src/workflows/audio-transcribe/steps/transcribe.ts b/src/workflows/audio-transcribe/steps/transcribe.ts new file mode 100644 index 00000000..c1766d3c --- /dev/null +++ b/src/workflows/audio-transcribe/steps/transcribe.ts @@ -0,0 +1,96 @@ +import { + GoogleGenAI, + Type, + createPartFromUri, + createUserContent, +} from "@google/genai"; + +export interface TranscribeResult { + summary: string; + segments: Array<{ speaker: string; timestamp: string; content: string }>; + duration?: number; +} + +/** + * Step: Call Gemini to transcribe audio and generate summary + segments. + */ +export async function transcribeWithGemini( + fileUri: string, + mimeType: string +): Promise { + "use step"; + + const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; + if (!apiKey) { + throw new Error("GOOGLE_GENERATIVE_AI_API_KEY is not set"); + } + + const client = new GoogleGenAI({ apiKey }); + + const prompt = `Process this audio file and generate a detailed transcription and summary. + +Requirements: +1. Provide a comprehensive summary of the entire audio content. +2. Identify distinct speakers (e.g., Speaker 1, Speaker 2, or names if context allows). +3. Provide accurate timestamps for each segment (Format: MM:SS). +4. Provide the total duration of the audio in seconds (a single number, e.g. 180.5 for 3 minutes).`; + + const response = await client.models.generateContent({ + model: "gemini-2.5-flash-lite", + contents: createUserContent([ + createPartFromUri(fileUri, mimeType), + prompt, + ]), + config: { + responseMimeType: "application/json", + responseSchema: { + type: Type.OBJECT, + properties: { + summary: { + type: Type.STRING, + description: "A concise summary of the audio content.", + }, + duration: { + type: Type.NUMBER, + description: "Total duration of the audio in seconds.", + }, + segments: { + type: Type.ARRAY, + description: + "List of transcribed segments with speaker and timestamp.", + items: { + type: Type.OBJECT, + properties: { + speaker: { type: Type.STRING }, + timestamp: { type: Type.STRING }, + content: { type: Type.STRING }, + }, + required: ["speaker", "timestamp", "content"], + }, + }, + }, + required: ["summary", "segments"], + }, + }, + }); + + const resultText = response.text; + if (!resultText) { + throw new Error("No response from Gemini"); + } + + const result = JSON.parse(resultText) as { + summary: string; + segments: Array<{ speaker: string; timestamp: string; content: string }>; + duration?: number; + }; + + return { + summary: result.summary, + segments: result.segments, + duration: + typeof result.duration === "number" && result.duration > 0 + ? result.duration + : undefined, + }; +} From 29234fc8f6f364a4b795e9fe4dbc196f5a640af4 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:51:49 -0500 Subject: [PATCH 15/56] fix: citations --- .../ai-elements/inline-citation.tsx | 29 ++++++ src/components/assistant-ui/markdown-text.tsx | 99 ++++++++----------- src/hooks/ai/use-citations-from-message.ts | 43 -------- src/lib/ai/citations/extract-from-inline.ts | 47 --------- src/lib/ai/citations/types.ts | 17 ---- src/lib/utils/format-workspace-context.ts | 32 +++--- src/lib/utils/preprocess-latex.ts | 59 +++++++---- 7 files changed, 124 insertions(+), 202 deletions(-) delete mode 100644 src/hooks/ai/use-citations-from-message.ts delete mode 100644 src/lib/ai/citations/extract-from-inline.ts delete mode 100644 src/lib/ai/citations/types.ts diff --git a/src/components/ai-elements/inline-citation.tsx b/src/components/ai-elements/inline-citation.tsx index eee46a23..4f5af2cb 100644 --- a/src/components/ai-elements/inline-citation.tsx +++ b/src/components/ai-elements/inline-citation.tsx @@ -1,6 +1,7 @@ "use client"; import type { ComponentProps, ReactNode } from "react"; +import { ExternalLink } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { @@ -169,3 +170,31 @@ export const InlineCitationQuote = ({ {children} ); + +function extractDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** + * SurfSense-style URL citation: clickable badge with domain, opens in new tab. + * Used for [citation:https://...] refs. + */ +export function UrlCitation({ url }: { url: string }) { + const domain = extractDomain(url); + return ( + + + {domain} + + ); +} diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index db3bc9cf..884a865f 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -8,12 +8,10 @@ import { createMathPlugin } from "@streamdown/math"; import { useMessagePartText, useAuiState } from "@assistant-ui/react"; import { Children, - createContext, isValidElement, memo, useRef, useEffect, - useContext, type ReactNode, } from "react"; import type { @@ -28,24 +26,23 @@ import { InlineCitationCardBody, InlineCitationSource, InlineCitationQuote, + UrlCitation, } from "@/components/ai-elements/inline-citation"; import { MarkdownLink } from "@/components/ui/markdown-link"; -import { useCitationsFromMessage } from "@/hooks/ai/use-citations-from-message"; import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { useUIStore } from "@/lib/stores/ui-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import type { Citation } from "@/lib/ai/citations/types"; +import { getCitationUrl } from "@/lib/utils/preprocess-latex"; import { preprocessLatex } from "@/lib/utils/preprocess-latex"; import { cn } from "@/lib/utils"; const math = createMathPlugin({ singleDollarTextMath: true }); const code = createCodePlugin({ themes: ["one-dark-pro", "one-dark-pro"] }); -const CitationContext = createContext([]); -/** Recursively extract all text from children (handles nested elements from markdown parsing). */ -function extractAllText(children: ReactNode): string { - if (typeof children === "string") return children; +/** 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 ""; const arr = Children.toArray(children); return arr @@ -53,58 +50,54 @@ function extractAllText(children: ReactNode): string { if (typeof child === "string") return child; if (isValidElement(child)) { const nested = (child.props as { children?: ReactNode }).children; - if (nested != null) return extractAllText(nested); + if (nested != null) return extractCitationText(nested); } return ""; }) - .join(""); -} - -/** Parse "N | quote" or legacy "N" from citation element content. */ -function parseCitationContent(children: ReactNode): { number: string; quote?: string } { - const text = extractAllText(children).trim(); - if (!text) return { number: "1" }; - // New format: "N | quote" — same source can have different quotes per use - const pipeIdx = text.indexOf(" | "); - if (pipeIdx !== -1) { - return { - number: text.slice(0, pipeIdx).trim() || "1", - quote: text.slice(pipeIdx + 3).trim() || undefined, - }; - } - // Legacy: just "N" - return { number: text }; + .join("") + .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 citations = useContext(CitationContext); - const { number: idStr, quote: instanceQuote } = parseCitationContent(children); - const index = parseInt(idStr, 10); - const citation = !isNaN(index) && index >= 1 ? citations[index - 1] : null; - // Prefer instance-level quote (new format); fall back to source quote (legacy) - const quote = instanceQuote ?? citation?.quote; - const effectiveCitation = citation ?? { - number: idStr, - title: `Source ${idStr}`, - }; + const ref = extractCitationText(children); + if (!ref) return null; + + // URL placeholder (from preprocess): render as direct link + const url = getCitationUrl(ref); + 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 or Title|quote + const pipeIdx = ref.indexOf(" | "); + const title = pipeIdx !== -1 ? ref.slice(0, pipeIdx).trim() : ref; + const quote = pipeIdx !== -1 ? ref.slice(pipeIdx + 3).trim() : undefined; const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); const { state: workspaceState } = useWorkspaceState(workspaceId); const navigateToItem = useNavigateToItem(); const setOpenModalItemId = useUIStore((s) => s.setOpenModalItemId); - const titleNorm = (s: string) => s.trim().toLowerCase(); const setCitationHighlightQuery = useUIStore((s) => s.setCitationHighlightQuery); + const handleWorkspaceItemClick = () => { - if (!workspaceState?.items || !effectiveCitation.title) return; + if (!workspaceState?.items || !title) return; const item = workspaceState.items.find( (i) => (i.type === "note" || i.type === "pdf") && - titleNorm(i.name) === titleNorm(effectiveCitation.title) + titleNorm(i.name) === titleNorm(title) ); if (!item) return; - // Set highlight query first (works when item is already open) if (quote?.trim()) { setCitationHighlightQuery({ itemId: item.id, query: quote.trim() }); } @@ -112,29 +105,24 @@ const CitationRenderer = memo( setOpenModalItemId(item.id); }; - const hasWorkspaceItem = - !effectiveCitation.url && - workspaceState?.items?.some( - (i) => - (i.type === "note" || i.type === "pdf") && - titleNorm(i.name) === titleNorm(effectiveCitation.title) - ); + const hasWorkspaceItem = workspaceState?.items?.some( + (i) => + (i.type === "note" || i.type === "pdf") && + titleNorm(i.name) === titleNorm(title) + ); return ( 20 ? "…" : "")} /> {quote && {quote}} @@ -149,7 +137,6 @@ CitationRenderer.displayName = "CitationRenderer"; const MarkdownTextImpl = () => { // Get the text content from assistant-ui context const { text } = useMessagePartText(); - const citations = useCitationsFromMessage(); // Get thread and message ID for unique key per message const threadId = useAuiState(({ threads }) => (threads as any)?.mainThreadId); @@ -231,7 +218,6 @@ const MarkdownTextImpl = () => { return (
- { > {preprocessLatex(text)} -
); }; diff --git a/src/hooks/ai/use-citations-from-message.ts b/src/hooks/ai/use-citations-from-message.ts deleted file mode 100644 index b08e9694..00000000 --- a/src/hooks/ai/use-citations-from-message.ts +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { useMessage, useAuiState } from "@assistant-ui/react"; -import { useMemo } from "react"; -import type { Citation } from "@/lib/ai/citations/types"; -import { extractCitationsFromInlineData } from "@/lib/ai/citations/extract-from-inline"; - -/** Extracts citations from the model-generated ... block in message text. */ -const CITATION_EXTRACTORS = [extractCitationsFromInlineData]; - -/** - * Extracts citations from the current message. - * Citations come from the optional model-generated block appended to the message. - * Returns a stable array; citations are 1-indexed by number. - */ -export function useCitationsFromMessage(): Citation[] { - const message = useMessage(); - const messages = useAuiState( - (s) => (s.thread as unknown as { messages?: unknown[] } | undefined)?.messages ?? [] - ); - const thread = useMemo(() => ({ messages }), [messages]); - - return useMemo(() => { - const msg = { - id: (message as unknown as { id?: string }).id, - role: (message as unknown as { role?: string }).role, - content: (message as unknown as { content?: unknown[] }).content, - }; - - // Extract from model-generated block - for (const extract of CITATION_EXTRACTORS) { - const citations = extract(msg, thread); - if (citations.length > 0) { - return citations.map((c, i) => ({ - ...c, - number: String(i + 1), - })); - } - } - - return []; - }, [message, thread]); -} diff --git a/src/lib/ai/citations/extract-from-inline.ts b/src/lib/ai/citations/extract-from-inline.ts deleted file mode 100644 index db36f49e..00000000 --- a/src/lib/ai/citations/extract-from-inline.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Citation } from "./types"; - -const CITATIONS_BLOCK_REGEX = /([\s\S]*?)<\/citations>/i; - -/** - * Parses the optional model-generated ... block from message text. - * The model outputs sources at the beginning (no quotes — quotes are per-instance in inline elements). - */ -export function extractCitationsFromInlineData( - message: { id?: string; role?: string; content?: unknown[] }, - _thread?: { messages?: unknown[] } -): Citation[] { - const content = message?.content; - if (!Array.isArray(content)) return []; - - const textParts = content - .filter((p): p is { type: string; text?: string } => (p as any)?.type === "text" && typeof (p as any).text === "string") - .map((p) => (p as { text: string }).text); - - const fullText = textParts.join("\n"); - const match = fullText.match(CITATIONS_BLOCK_REGEX); - if (!match?.[1]) return []; - - let parsed: unknown; - try { - parsed = JSON.parse(match[1].trim()); - } catch { - return []; - } - - if (!Array.isArray(parsed)) return []; - - const citations: Citation[] = []; - for (const item of parsed) { - const c = item as Record; - const number = String(c?.number ?? ""); - const title = String(c?.title ?? "Source"); - const url = typeof c?.url === "string" ? c.url : undefined; - const quote = typeof c?.quote === "string" ? c.quote : undefined; - - if (number) { - citations.push({ number, title, url, quote }); - } - } - - return citations; -} diff --git a/src/lib/ai/citations/types.ts b/src/lib/ai/citations/types.ts deleted file mode 100644 index cf7d3849..00000000 --- a/src/lib/ai/citations/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Generic citation type for inline citations. - * Source-agnostic: can represent web search, URL context, workspace content, etc. - * Quote is per-instance (in inline element), not per-source. - */ -export type Citation = { - number: string; // "1", "2", ... (1-based index for display) - title: string; - url?: string; // optional — workspace items may not have URLs - quote?: string; // optional at source level; use instance quote from inline element -}; - -/** Extractor function: takes a message (and optional thread) and returns citations */ -export type CitationExtractor = ( - message: { id?: string; role?: string; content?: unknown[] }, - thread?: { messages?: unknown[] } -) => Citation[]; diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index beea00e2..fdb5999b 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -135,29 +135,25 @@ NOTE EDITING (updateNote, Cline convention): - Only use emojis if the user explicitly requests them. Avoid adding emojis unless asked. INLINE CITATIONS (optional): -Put the citation data block at the very BEGINNING of your response (sources only, no quotes). Each inline citation may optionally include a quote; omit the quote if you do not have the exact text from the source. +Use inline brackets only — no separate block. Format: [citation:REF] where REF is one of: -Sources block (no quotes — quotes go in each inline use): -[{"number":"1","title":"Source title","url":"https://example.com"}] +- Web URL: [citation:https://example.com/article] — for web sources +- Workspace note: [citation:Note Title] — use exact note title from workspace +- Workspace + quote: [citation:Note Title | exact excerpt] — pipe with spaces before quote; only when you have the exact text -For workspace items: omit url. Example: -[{"number":"1","title":"My Calculus Notes"}] +Examples: +- [citation:https://en.wikipedia.org/wiki/Supply_chain] +- [citation:My Calculus Notes] +- [citation:My Calculus Notes | The derivative of x^2 is 2x] -Inline format: N or N | exact excerpt -- Without quote: 1 — use when you cannot cite an exact excerpt. -- With quote: 1 | exact excerpt from source — use only when you have the exact text from the source. Use pipe with spaces to separate. +NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt from the source. If unsure, use [citation:Title] without a quote. Never fabricate or paraphrase. -NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt from the source (tool response, context, or workspace content). If unsure or you do not have the exact text, use N without a quote. Never fabricate or paraphrase quotes. +When quoting: Use ONLY plain text — no math, code blocks, or special formatting. Use surrounding prose or omit the quote instead. -When quoting: Use ONLY plain text — no math ($$...$$), code blocks, or special formatting. Use surrounding prose or omit the quote instead. - -CRITICAL — Punctuation placement: End the sentence/clause with the period or comma BEFORE the citation. The citation always comes after the punctuation, never before it. -Correct: "...flow of goods and services." 1 | comprehensive administration of the flow -Correct: "demand forecasting." 1 -Wrong: "...flow of goods and services" 1. (do NOT put the period after the citation) -- Sources block: number, title, url (for web) or omit (workspace) -- Inline: N (no quote) or N | quote (quote optional; only when you have exact text) -Omit the block entirely if you have no citations. You may invent credible source metadata when not from a tool. +CRITICAL — Punctuation: Put the period or comma BEFORE the citation. The citation always comes after the punctuation. +Correct: "...flow of goods and services." [citation:Source Title | comprehensive administration] +Correct: "demand forecasting." [citation:Source Title] +Wrong: "...flow of goods and services" [citation:Source Title]. (do NOT put the period after the citation) diff --git a/src/lib/utils/preprocess-latex.ts b/src/lib/utils/preprocess-latex.ts index 5ffbf1a3..1efc7f14 100644 --- a/src/lib/utils/preprocess-latex.ts +++ b/src/lib/utils/preprocess-latex.ts @@ -2,32 +2,51 @@ * Preprocesses markdown content to normalize LaTeX delimiters for Streamdown/remark-math. * * Handles: - * 0. Strips optional model-generated ... block (for display only; extracted separately) + * 0. Converts SurfSense-style [citation:X] to X (inline-only, no block) * 1. Protects currency values ($19.99, $5, $1,000) from being parsed as math * 2. Converts \(...\) → $...$ and \[...\] → $$...$$ (remark-math doesn't support these) * * Preserves code blocks (``` and inline `) so their contents are never modified. */ -// Match complete citations block at start (model generates sources first so they're ready during streaming) -const CITATIONS_BLOCK_AT_START_REGEX = /^\s*[\s\S]*?<\/citations>\s*/i; -// Fallback: match complete block at end if model still outputs there (backwards compatible) -const CITATIONS_BLOCK_AT_END_REGEX = /[\s\S]*?<\/citations>\s*$/i; -// During streaming: incomplete block at start (... without ) — hide from to end so user never sees raw JSON -const CITATIONS_BLOCK_INCOMPLETE_AT_START_REGEX = /^\s*[\s\S]*$/i; +// Storage for URL citations replaced during preprocess to avoid GFM autolink interference. +// Populated in preprocessCitations, consumed by CitationRenderer. +let _pendingUrlCitations = new Map(); +let _urlCiteIdx = 0; -/** Strips the optional ... block from markdown so it doesn't render. Also hides in-progress block during streaming. */ -export function stripCitationsBlock(markdown: string): string { +/** Get a stored URL by placeholder key (urlcite0, urlcite1, etc.). */ +export function getCitationUrl(placeholder: string): string | undefined { + return _pendingUrlCitations.get(placeholder); +} + +/** + * Converts [citation:X] to X. + * For URLs: replaces with placeholder to avoid GFM autolinks; stores URL in _pendingUrlCitations. + * Supports: [citation:https://...], [citation:Title], [citation:Title|quote] + */ +function preprocessCitations(markdown: string): string { if (!markdown) return markdown; - let out = markdown; - // 1. Strip complete block at start (or in-progress block at start — hide raw JSON during streaming) - if (CITATIONS_BLOCK_AT_START_REGEX.test(out)) { - out = out.replace(CITATIONS_BLOCK_AT_START_REGEX, "").trimStart(); - } else if (CITATIONS_BLOCK_INCOMPLETE_AT_START_REGEX.test(out)) { - out = out.replace(CITATIONS_BLOCK_INCOMPLETE_AT_START_REGEX, "").trimStart(); - } - // 2. Strip complete block at end - out = out.replace(CITATIONS_BLOCK_AT_END_REGEX, "").trimEnd(); + _pendingUrlCitations = new Map(); + _urlCiteIdx = 0; + + // Replace URL citations with placeholders BEFORE markdown parsing + // GFM autolinks would otherwise convert https://... into , breaking our pattern + let out = markdown.replace( + /\[citation:\s*(https?:\/\/[^\]\u200B]+)\s*\]/g, + (_, url: string) => { + const key = `urlcite${_urlCiteIdx++}`; + _pendingUrlCitations.set(key, url.trim()); + return `${key}`; + } + ); + + // Replace remaining [citation:Title] or [citation:Title|quote] with ... + // Content can be: workspace title (spaces ok), or title|quote + out = out.replace(/\[citation:\s*([^\]]+)\s*\]/g, (_, content: string) => { + const trimmed = content.trim(); + return trimmed ? `${trimmed}` : ""; + }); + return out; } @@ -38,8 +57,8 @@ const CURRENCY_REGEX = /(?X (SurfSense-style inline) + markdown = preprocessCitations(markdown); // 1. Protect code blocks and inline code from modification const preserved: string[] = []; From 788678560699665fa53e67b1985fb8e6bed10d9a Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:42:16 -0500 Subject: [PATCH 16/56] feat: pdf ocr --- .env.example | 6 + package.json | 1 + src/app/api/pdf/ocr/route.ts | 136 ++++++++++++ src/app/api/pdf/upload-and-ocr/route.ts | 165 +++++++++++++++ src/app/dashboard/page.tsx | 70 ++++--- .../assistant-ui/FileProcessingToolUI.tsx | 29 ++- .../assistant-ui/UpdatePdfContentToolUI.tsx | 8 - src/components/assistant-ui/thread.tsx | 2 - .../WorkspaceCanvasDropzone.tsx | 152 +++++++++----- .../workspace-canvas/WorkspaceSection.tsx | 63 +++--- src/lib/ai/tools/index.ts | 4 - src/lib/ai/tools/pdf-tools.ts | 86 -------- src/lib/ai/tools/process-files.ts | 191 ++++++++++++++++- src/lib/ai/tools/read-workspace.ts | 46 ++++- src/lib/ai/tools/workspace-search-utils.ts | 3 + src/lib/ai/workers/workspace-worker.ts | 15 +- src/lib/pdf/azure-ocr.ts | 194 ++++++++++++++++++ src/lib/utils/format-workspace-context.ts | 88 ++++++-- src/lib/workspace-state/search.ts | 7 +- src/lib/workspace-state/types.ts | 11 + 20 files changed, 1037 insertions(+), 240 deletions(-) create mode 100644 src/app/api/pdf/ocr/route.ts create mode 100644 src/app/api/pdf/upload-and-ocr/route.ts delete mode 100644 src/components/assistant-ui/UpdatePdfContentToolUI.tsx delete mode 100644 src/lib/ai/tools/pdf-tools.ts create mode 100644 src/lib/pdf/azure-ocr.ts diff --git a/.env.example b/.env.example index a2454351..a114bc06 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,9 @@ FIRECRAWL_API_KEY=fc_... # - direct-only (Force Direct Fetch only) SCRAPING_MODE=hybrid +# Azure Document AI (Mistral OCR) - for PDF upload OCR and scripts/ocr-pdf-from-url.sh +AZURE_DOCUMENT_AI_API_KEY=your-api-key-from-azure-deployment +AZURE_DOCUMENT_AI_ENDPOINT=https://chakrabortyurjit-7873-resource.services.ai.azure.com/providers/mistral/azure/ocr +# Optional: AZURE_DOCUMENT_AI_MODEL (default: mistral-document-ai-2512) +# Optional: OCR_INCLUDE_IMAGES=false to skip extracting images (smaller OCR; pdfImageRefs won't work) + diff --git a/package.json b/package.json index 0549d1ea..6a6369fb 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "next": "16.1.6", "next-themes": "^0.4.6", "parse-diff": "^0.11.1", + "pdf-lib": "^1.17.1", "postgres": "^3.4.7", "posthog-js": "^1.345.0", "posthog-node": "^5.24.14", diff --git a/src/app/api/pdf/ocr/route.ts b/src/app/api/pdf/ocr/route.ts new file mode 100644 index 00000000..6b7d4432 --- /dev/null +++ b/src/app/api/pdf/ocr/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { ocrPdfFromBuffer } from "@/lib/pdf/azure-ocr"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; +const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB + +/** + * POST /api/pdf/ocr + * Receives a PDF file URL (Supabase or local), fetches it, runs Azure Mistral Document AI OCR, + * returns extracted text and page data. + */ +export async function POST(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { fileUrl } = body; + + if (!fileUrl || typeof fileUrl !== "string") { + return NextResponse.json( + { error: "fileUrl is required" }, + { status: 400 } + ); + } + + logger.info("[PDF_OCR] Route fired", { + fileUrl: fileUrl.slice(0, 80) + (fileUrl.length > 80 ? "…" : ""), + userId: session.user?.id, + }); + + // Validate URL origin to prevent SSRF + const allowedHosts: string[] = []; + if (process.env.NEXT_PUBLIC_SUPABASE_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname); + } + if (process.env.NEXT_PUBLIC_APP_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname); + } + allowedHosts.push("localhost", "127.0.0.1"); + + let parsedUrl: URL; + try { + parsedUrl = new URL(fileUrl); + } catch { + return NextResponse.json({ error: "Invalid fileUrl" }, { status: 400 }); + } + + if ( + !allowedHosts.some( + (host) => + parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`) + ) + ) { + return NextResponse.json( + { error: "fileUrl origin is not allowed" }, + { status: 400 } + ); + } + + const res = await fetch(fileUrl); + if (!res.ok) { + return NextResponse.json( + { error: `Failed to fetch PDF: ${res.status} ${res.statusText}` }, + { status: 400 } + ); + } + + const contentType = res.headers.get("content-type") ?? ""; + if ( + !contentType.includes("application/pdf") && + !fileUrl.toLowerCase().includes(".pdf") + ) { + return NextResponse.json( + { error: "URL does not point to a PDF file" }, + { status: 400 } + ); + } + + const contentLength = res.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_PDF_SIZE_BYTES) { + return NextResponse.json( + { + error: `PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`, + }, + { status: 400 } + ); + } + + const arrayBuffer = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + logger.debug("[PDF_OCR] Fetched PDF", { + sizeBytes: buffer.length, + sizeMB: (buffer.length / (1024 * 1024)).toFixed(2), + }); + + if (buffer.length > MAX_PDF_SIZE_BYTES) { + return NextResponse.json( + { + error: `PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`, + }, + { status: 400 } + ); + } + + const result = await ocrPdfFromBuffer(buffer); + + logger.info("[PDF_OCR] OCR complete", { + pageCount: result.pages.length, + textContentLength: result.textContent.length, + textContentPreview: result.textContent.slice(0, 100) + (result.textContent.length > 100 ? "…" : ""), + }); + + return NextResponse.json({ + textContent: result.textContent, + ocrPages: result.pages, + }); + } catch (error: unknown) { + logger.error("[PDF_OCR] Error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "OCR processing failed", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/pdf/upload-and-ocr/route.ts b/src/app/api/pdf/upload-and-ocr/route.ts new file mode 100644 index 00000000..10e2be32 --- /dev/null +++ b/src/app/api/pdf/upload-and-ocr/route.ts @@ -0,0 +1,165 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { createClient } from "@supabase/supabase-js"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; +import { ocrPdfFromBuffer } from "@/lib/pdf/azure-ocr"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 120; // OCR can be slow for large PDFs +const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB + +const getStorageType = (): "supabase" | "local" => { + const storageType = process.env.STORAGE_TYPE || "supabase"; + return storageType === "local" ? "local" : "supabase"; +}; + +async function saveFileLocally(buffer: Buffer, filename: string): Promise { + const uploadsDir = process.env.UPLOADS_DIR || join(process.cwd(), "uploads"); + if (!existsSync(uploadsDir)) { + await mkdir(uploadsDir, { recursive: true }); + } + const filePath = join(uploadsDir, filename); + await writeFile(filePath, buffer); + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + return `${baseUrl}/api/files/${filename}`; +} + +/** + * POST /api/pdf/upload-and-ocr + * Accepts a PDF file via multipart form, uploads to storage, runs OCR on the buffer, + * returns fileUrl + OCR result. Single request — no fetch-back from storage. + */ +export async function POST(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + + if (!file) { + return NextResponse.json( + { error: "No file provided" }, + { status: 400 } + ); + } + + if ( + file.type !== "application/pdf" && + !file.name.toLowerCase().endsWith(".pdf") + ) { + return NextResponse.json( + { error: "File must be a PDF" }, + { status: 400 } + ); + } + + if (file.size > MAX_PDF_SIZE_BYTES) { + return NextResponse.json( + { + error: `PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`, + }, + { status: 400 } + ); + } + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 15); + const sanitizedName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const filename = `${timestamp}-${random}-${sanitizedName}`; + + const storageType = getStorageType(); + let fileUrl: string; + + if (storageType === "local") { + fileUrl = await saveFileLocally(buffer, filename); + } else { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !serviceRoleKey) { + return NextResponse.json( + { error: "Server configuration error: Supabase not configured" }, + { status: 500 } + ); + } + + const supabase = createClient(supabaseUrl, serviceRoleKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + const { error } = await supabase.storage + .from("file-upload") + .upload(filename, buffer, { + cacheControl: "3600", + upsert: false, + contentType: "application/pdf", + }); + + if (error) { + logger.error("[PDF_UPLOAD_OCR] Supabase upload failed:", error); + return NextResponse.json( + { error: `Failed to upload: ${error.message}` }, + { status: 500 } + ); + } + + const { data: urlData } = supabase.storage + .from("file-upload") + .getPublicUrl(filename); + fileUrl = urlData.publicUrl; + } + + logger.info("[PDF_UPLOAD_OCR] Upload complete, running OCR", { + filename, + sizeMB: (buffer.length / (1024 * 1024)).toFixed(2), + }); + + let ocrResult: Awaited>; + let ocrStatus: "complete" | "failed" = "complete"; + let ocrError: string | undefined; + + try { + ocrResult = await ocrPdfFromBuffer(buffer); + logger.info("[PDF_UPLOAD_OCR] OCR complete", { + pageCount: ocrResult.pages.length, + textContentLength: ocrResult.textContent.length, + }); + } catch (err) { + ocrStatus = "failed"; + ocrError = err instanceof Error ? err.message : "OCR failed"; + logger.warn("[PDF_UPLOAD_OCR] OCR failed, returning file without content:", ocrError); + ocrResult = { pages: [], textContent: "" }; + } + + return NextResponse.json({ + fileUrl, + filename: file.name, + fileSize: file.size, + textContent: ocrResult.textContent, + ocrPages: ocrResult.pages, + ocrStatus, + ...(ocrError && { ocrError }), + }); + } catch (error: unknown) { + logger.error("[PDF_UPLOAD_OCR] Error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Upload and OCR failed", + }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index ca9a6d5f..cd39c4f5 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -43,7 +43,6 @@ import { useWorkspaceInstructionModal } from "@/hooks/workspace/use-workspace-in import { InviteGuard } from "@/components/workspace/InviteGuard"; import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; -import { uploadFileDirect } from "@/lib/uploads/client-upload"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import { useFolderUrl } from "@/hooks/ui/use-folder-url"; @@ -344,31 +343,50 @@ function DashboardContent({ return; } - const uploadPromises = unprotectedFiles.map(async (file) => { - const { url: fileUrl, filename } = await uploadFileDirect(file); - - return { - fileUrl, - filename: filename || file.name, - fileSize: file.size, - name: file.name.replace(/\.pdf$/i, ""), - }; - }); - - const uploadResults = await Promise.all(uploadPromises); - const pdfCardDefinitions = uploadResults.map((result) => { - const pdfData: Partial = { - fileUrl: result.fileUrl, - filename: result.filename, - fileSize: result.fileSize, - }; - - return { - type: "pdf" as const, - name: result.name, - initialData: pdfData, - }; - }); + const ocrToastId = toast.loading( + `Uploading and extracting text from ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? "s" : ""}...`, + { style: { color: "#fff" } } + ); + + const pdfCardDefinitions = ( + await Promise.all( + unprotectedFiles.map(async (file) => { + try { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/pdf/upload-and-ocr", { + method: "POST", + body: formData, + }); + const json = await res.json(); + if (!res.ok || json.error) { + throw new Error(json.error || "Upload and OCR failed"); + } + const pdfData: Partial = { + fileUrl: json.fileUrl, + filename: json.filename, + fileSize: json.fileSize, + textContent: json.textContent, + ocrPages: json.ocrPages, + ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? "complete" : "failed"), + ...(json.ocrError && { ocrError: json.ocrError }), + }; + return { + type: "pdf" as const, + name: file.name.replace(/\.pdf$/i, ""), + initialData: pdfData, + }; + } catch (err) { + toast.error(`Failed to process ${file.name}: ${err instanceof Error ? err.message : "Unknown error"}`); + return null; + } + }) + ) + ).filter((r): r is NonNullable => r !== null); + + toast.dismiss(ocrToastId); + + if (pdfCardDefinitions.length === 0) return; // Create all PDF cards and navigate to the first one const createdIds = operations.createItems(pdfCardDefinitions); diff --git a/src/components/assistant-ui/FileProcessingToolUI.tsx b/src/components/assistant-ui/FileProcessingToolUI.tsx index 69e7d1f1..f8bd7880 100644 --- a/src/components/assistant-ui/FileProcessingToolUI.tsx +++ b/src/components/assistant-ui/FileProcessingToolUI.tsx @@ -221,6 +221,7 @@ function getFileType(url: string): { type: 'video' | 'pdf' | 'image' | 'document export const FileProcessingToolUI = makeAssistantToolUI<{ urls?: string[]; fileNames?: string[]; + pdfImageRefs?: Array<{ pdfName: string; imageId: string }>; instruction?: string; forceReprocess?: boolean; }, string>({ @@ -243,17 +244,21 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ // Parse args let urls: string[] = []; + let pdfImageRefs: Array<{ pdfName: string; imageId: string }> = []; let instruction: string | undefined; // Use the actual tool parameters if (args?.urls && Array.isArray(args.urls)) { urls = args.urls; } + if (args?.pdfImageRefs && Array.isArray(args.pdfImageRefs)) { + pdfImageRefs = args.pdfImageRefs; + } if (args?.instruction) { instruction = args.instruction; } - const fileCount = urls.length; + const fileCount = urls.length + (args?.fileNames?.length ?? 0) + pdfImageRefs.length; // Debug parsed data if (typeof window !== 'undefined') { @@ -302,7 +307,7 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ const fileInfo = getFileType(url); const filename = url.split('/').pop() || url; return ( - )} diff --git a/src/components/assistant-ui/UpdatePdfContentToolUI.tsx b/src/components/assistant-ui/UpdatePdfContentToolUI.tsx deleted file mode 100644 index 1c158f00..00000000 --- a/src/components/assistant-ui/UpdatePdfContentToolUI.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; - -import { makeAssistantToolUI } from "@assistant-ui/react"; - -export const UpdatePdfContentToolUI = makeAssistantToolUI({ - toolName: "updatePdfContent", - render: () => null, -}); diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 54baa535..3014f456 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -75,7 +75,6 @@ import { URLContextToolUI } from "@/components/assistant-ui/URLContextToolUI"; // import { DeepResearchToolUI } from "@/components/assistant-ui/DeepResearchToolUI"; import { UpdateNoteToolUI } from "@/components/assistant-ui/UpdateNoteToolUI"; import { WebSearchToolUI } from "@/components/assistant-ui/WebSearchToolUI"; -import { UpdatePdfContentToolUI } from "@/components/assistant-ui/UpdatePdfContentToolUI"; import { SearchWorkspaceToolUI } from "@/components/assistant-ui/SearchWorkspaceToolUI"; import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI"; @@ -199,7 +198,6 @@ export const Thread: FC = ({ items = [] }) => { {/* */} - { - try { - const { url, filename } = await uploadFileToStorage(file); - return { - fileUrl: url, - filename: file.name, - fileSize: file.size, - name: file.name.replace(/\.pdf$/i, ''), // Remove .pdf extension for card name - originalFile: file, - }; - } catch (error) { - console.error("Failed to upload file:", error); - // Remove from processing set on error so it can be retried - const fileKey = getFileKey(file); - processingFilesRef.current.delete(fileKey); - return null; - } - }); - - const uploadResults = await Promise.all(uploadPromises); - - // Filter out any null results (files that couldn't be processed) - const validResults = uploadResults.filter((result): result is NonNullable => result !== null); - - // Dismiss loading toast - toast.dismiss(loadingToastId); + // Separate PDFs from other files — PDFs use upload-and-ocr (single request) + const pdfFiles = filteredFiles.filter((f) => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')); + const nonPdfFiles = filteredFiles.filter((f) => f.type !== 'application/pdf' && !f.name.toLowerCase().endsWith('.pdf')); + + // PDFs: upload + OCR in one request per file + const pdfResults: Array<{ + fileUrl: string; + filename: string; + fileSize: number; + name: string; + pdfData: Partial; + }> = []; + if (pdfFiles.length > 0) { + const pdfToastId = toast.loading( + `Uploading and extracting text from ${pdfFiles.length} PDF${pdfFiles.length > 1 ? 's' : ''}...`, + { style: { color: '#fff' } } + ); - if (validResults.length > 0) { - // Separate files by type using the original file reference (avoids index misalignment) - const pdfResults: typeof validResults = []; - const imageResults: typeof validResults = []; - const audioResults: typeof validResults = []; - - validResults.forEach((result) => { - const fileType = result.originalFile.type; - if (fileType === 'application/pdf') { - pdfResults.push(result); - } else if (fileType.startsWith('audio/')) { - audioResults.push(result); - } else { - imageResults.push(result); + const pdfPromises = pdfFiles.map(async (file) => { + try { + const formData = new FormData(); + formData.append('file', file); + const res = await fetch('/api/pdf/upload-and-ocr', { + method: 'POST', + body: formData, + }); + const json = await res.json(); + if (!res.ok || json.error) { + throw new Error(json.error || 'Upload and OCR failed'); + } + return { + fileUrl: json.fileUrl, + filename: json.filename, + fileSize: json.fileSize, + name: file.name.replace(/\.pdf$/i, ''), + pdfData: { + fileUrl: json.fileUrl, + filename: json.filename, + fileSize: json.fileSize, + textContent: json.textContent, + ocrPages: json.ocrPages, + ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? 'complete' : 'failed'), + ...(json.ocrError && { ocrError: json.ocrError }), + } as Partial, + }; + } catch (err) { + const fileKey = getFileKey(file); + processingFilesRef.current.delete(fileKey); + console.error('PDF upload-and-OCR failed:', err); + return null; } }); - // Create PDF cards - if (pdfResults.length > 0) { - const pdfCardDefinitions = pdfResults.map((result) => { - const pdfData: Partial = { - fileUrl: result.fileUrl, - filename: result.filename, - fileSize: result.fileSize, - }; + const results = await Promise.all(pdfPromises); + pdfResults.push(...results.filter((r): r is NonNullable => r !== null)); + toast.dismiss(pdfToastId); + } + // Non-PDFs: upload only + const nonPdfResults: Array<{ + fileUrl: string; + filename: string; + fileSize: number; + name: string; + originalFile: File; + }> = []; + if (nonPdfFiles.length > 0) { + const uploadPromises = nonPdfFiles.map(async (file) => { + try { + const { url, filename } = await uploadFileToStorage(file); return { - type: 'pdf' as const, - name: result.name, - initialData: pdfData, + fileUrl: url, + filename: file.name, + fileSize: file.size, + name: file.name.replace(/\.pdf$/i, ''), + originalFile: file, }; - }); + } catch (error) { + console.error('Failed to upload file:', error); + const fileKey = getFileKey(file); + processingFilesRef.current.delete(fileKey); + return null; + } + }); + const results = await Promise.all(uploadPromises); + nonPdfResults.push(...results.filter((r): r is NonNullable => r !== null)); + } + const validResults = [...pdfResults, ...nonPdfResults]; + if (validResults.length > 0) { + const imageResults: typeof nonPdfResults = []; + const audioResults: typeof nonPdfResults = []; + nonPdfResults.forEach((r) => { + if (r.originalFile.type.startsWith('audio/')) audioResults.push(r); + else imageResults.push(r); + }); + + // Create PDF cards (OCR data already included) + if (pdfResults.length > 0) { + const pdfCardDefinitions = pdfResults.map((r) => ({ + type: 'pdf' as const, + name: r.name, + initialData: r.pdfData, + })); const pdfCreatedIds = operations.createItems(pdfCardDefinitions); - // Use shared hook to handle navigation/selection for PDFs handleCreatedItems(pdfCreatedIds); } + toast.dismiss(loadingToastId); + // Create image cards with aspect ratio detection if (imageResults.length > 0) { // Helper to get image dimensions diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index c1589d2a..2115d284 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -461,38 +461,49 @@ export function WorkspaceSection({ return; } - // Upload all PDFs first - const uploadPromises = unprotectedFiles.map(async (file) => { - const { url: fileUrl, filename } = await uploadFileDirect(file); - - return { - fileUrl, - filename: filename || file.name, - fileSize: file.size, - name: file.name.replace(/\.pdf$/i, ''), - }; - }); - - const uploadResults = await Promise.all(uploadPromises); - - // Filter out any null results (files that couldn't be processed) - const validResults = uploadResults.filter((result): result is NonNullable => result !== null); - - if (validResults.length > 0) { - // Collect all PDF card data and create in a single batch event - const pdfCardDefinitions = validResults.map((result) => { + const ocrToastId = toast.loading( + `Uploading and extracting text from ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? 's' : ''}...`, + { style: { color: '#fff' } } + ); + + const uploadAndOcrPromises = unprotectedFiles.map(async (file) => { + try { + const formData = new FormData(); + formData.append('file', file); + const res = await fetch('/api/pdf/upload-and-ocr', { + method: 'POST', + body: formData, + }); + const json = await res.json(); + if (!res.ok || json.error) { + throw new Error(json.error || 'Upload and OCR failed'); + } const pdfData: Partial = { - fileUrl: result.fileUrl, - filename: result.filename, - fileSize: result.fileSize, + fileUrl: json.fileUrl, + filename: json.filename, + fileSize: json.fileSize, + textContent: json.textContent, + ocrPages: json.ocrPages, + ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? 'complete' : 'failed'), + ...(json.ocrError && { ocrError: json.ocrError }), }; - return { type: 'pdf' as const, - name: result.name, + name: file.name.replace(/\.pdf$/i, ''), initialData: pdfData, }; - }); + } catch (err) { + toast.error(`Failed to process ${file.name}: ${err instanceof Error ? err.message : 'Unknown error'}`); + return null; + } + }); + + const pdfCardDefinitions = (await Promise.all(uploadAndOcrPromises)) + .filter((r): r is NonNullable => r !== null); + + toast.dismiss(ocrToastId); + + if (pdfCardDefinitions.length > 0) { // Create all PDF cards atomically in a single event const createdIds = operations.createItems(pdfCardDefinitions); diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index e0a79b4e..28f8c383 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -17,7 +17,6 @@ import { import { createFlashcardsTool, createUpdateFlashcardsTool } from "./flashcard-tools"; import { createQuizTool, createUpdateQuizTool } from "./quiz-tools"; import { createDeepResearchTool } from "./deep-research"; -import { createUpdatePdfContentTool } from "./pdf-tools"; import { createSearchYoutubeTool, createAddYoutubeVideoTool } from "./youtube-tools"; import { createSearchImagesTool, createAddImageTool } from "./image-tools"; import { createWebSearchTool } from "./web-search"; @@ -71,9 +70,6 @@ export function createChatTools(config: ChatToolsConfig): Record { deleteItem: createDeleteItemTool(ctx), selectCards: createSelectCardsTool(ctx), - // PDF content caching - updatePdfContent: createUpdatePdfContentTool(ctx), - // Flashcards createFlashcards: createFlashcardsTool(ctx), updateFlashcards: createUpdateFlashcardsTool(ctx), diff --git a/src/lib/ai/tools/pdf-tools.ts b/src/lib/ai/tools/pdf-tools.ts deleted file mode 100644 index 5ecf09c0..00000000 --- a/src/lib/ai/tools/pdf-tools.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { tool, zodSchema } from "ai"; -import { z } from "zod"; -import { logger } from "@/lib/utils/logger"; -import { workspaceWorker } from "@/lib/ai/workers"; -import { loadStateForTool, fuzzyMatchItem, getAvailableItemsList } from "./tool-utils"; -import type { WorkspaceToolContext } from "./workspace-tools"; -import type { PdfData } from "@/lib/workspace-state/types"; - -/** - * Create the updatePdfContent tool - * Allows the agent to cache extracted text content on a PDF workspace item - * without re-processing the file through Gemini. - * - * Primary use case: after Gemini reads a PDF inline (via composer file attachment), - * the agent calls this to persist its understanding so future interactions - * can reference the content without reprocessing. - */ -export function createUpdatePdfContentTool(ctx: WorkspaceToolContext) { - return tool({ - description: "Cache/update the extracted text content of a PDF in the workspace. Use this after you've read a PDF (e.g. from a file attachment) to save your understanding so it doesn't need to be reprocessed later. Also used by processFiles to auto-cache results.", - inputSchema: zodSchema( - z.object({ - pdfName: z.string().describe("The name of the PDF item in the workspace (fuzzy matched)"), - textContent: z.string().describe("The extracted/summarized text content of the PDF to cache"), - title: z.string().optional().describe("Optional new title for the PDF item"), - }) - ), - execute: async ({ pdfName, textContent, title }) => { - if (!pdfName) { - return { success: false, message: "PDF name is required." }; - } - if (!textContent) { - return { success: false, message: "Text content is required." }; - } - if (!ctx.workspaceId) { - return { success: false, message: "No workspace context available" }; - } - - try { - const accessResult = await loadStateForTool(ctx); - if (!accessResult.success) { - return accessResult; - } - - const { state } = accessResult; - const matchedPdf = fuzzyMatchItem(state.items, pdfName, "pdf"); - - if (!matchedPdf) { - const availablePdfs = getAvailableItemsList(state.items, "pdf"); - return { - success: false, - message: `Could not find PDF "${pdfName}". ${availablePdfs ? `Available PDFs: ${availablePdfs}` : 'No PDFs found in workspace.'}`, - }; - } - - logger.debug("📄 [UPDATE-PDF-CONTENT] Found PDF via fuzzy match:", { - searchedName: pdfName, - matchedName: matchedPdf.name, - matchedId: matchedPdf.id, - }); - - const result = await workspaceWorker("updatePdfContent", { - workspaceId: ctx.workspaceId, - itemId: matchedPdf.id, - pdfTextContent: textContent, - title, - }); - - if (result.success) { - return { - ...result, - pdfName: matchedPdf.name, - }; - } - - return result; - } catch (error) { - logger.error("Error updating PDF content:", error); - return { - success: false, - message: `Error updating PDF content: ${error instanceof Error ? error.message : String(error)}`, - }; - } - }, - }); -} diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts index 327bc721..24fd9a88 100644 --- a/src/lib/ai/tools/process-files.ts +++ b/src/lib/ai/tools/process-files.ts @@ -5,10 +5,12 @@ import { logger } from "@/lib/utils/logger"; import { readFile } from "fs/promises"; import { join } from "path"; import { existsSync } from "fs"; +import { headers } from "next/headers"; import { loadStateForTool, fuzzyMatchItem } from "./tool-utils"; import type { WorkspaceToolContext } from "./workspace-tools"; import type { Item, PdfData } from "@/lib/workspace-state/types"; import { workspaceWorker } from "@/lib/ai/workers"; +import { formatOcrPagesAsMarkdown } from "@/lib/utils/format-workspace-context"; type FileInfo = { fileUrl: string; filename: string; mediaType: string }; @@ -218,6 +220,118 @@ async function processSupabaseFiles( return batchAnalysis; } +/** + * Run OCR on a PDF via the /api/pdf/ocr endpoint. + * Reuses the same upload+extract logic as workspace dropzone/upload flows. + * Returns extracted text and pages, or null on failure. + */ +async function runOcrForPdfUrl(fileUrl: string): Promise<{ + textContent: string; + ocrPages: PdfData["ocrPages"]; +} | null> { + const baseUrl = + process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + let cookie: string | undefined; + try { + const headersList = await headers(); + cookie = headersList.get("cookie") ?? undefined; + } catch { + // No request context (e.g. background job) + } + + const res = await fetch(`${baseUrl}/api/pdf/ocr`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(cookie && { cookie }), + }, + body: JSON.stringify({ fileUrl }), + }); + + const json = await res.json(); + if (!res.ok || json.error || !json.textContent) { + logger.warn("📁 [FILE_TOOL] OCR failed for PDF:", { + url: fileUrl.slice(0, 80), + error: json.error || res.statusText, + }); + return null; + } + + return { + textContent: json.textContent, + ocrPages: json.ocrPages ?? undefined, + }; +} + +type PdfImageRef = { pdfName: string; imageId: string }; + +/** + * Process images extracted from PDF OCR (resolve placeholder refs like img-0.jpeg to base64). + */ +async function processPdfImages( + pdfImageRefs: PdfImageRef[], + stateItems: Item[], + instruction?: string +): Promise { + const fileInfos: Array<{ filename: string; mediaType: string; data: string }> = []; + + for (const ref of pdfImageRefs) { + const pdfItem = fuzzyMatchItem(stateItems, ref.pdfName); + if (!pdfItem || pdfItem.type !== "pdf") continue; + + const pdfData = pdfItem.data as PdfData; + const ocrPages = pdfData.ocrPages ?? []; + + for (const page of ocrPages) { + const images = (page.images ?? []) as Array<{ id?: string; image_base64?: string; imageBase64?: string }>; + const img = images.find((i) => i.id === ref.imageId); + if (!img) continue; + + const base64 = img.image_base64 ?? img.imageBase64; + if (!base64 || typeof base64 !== "string") continue; + + const dataUrl = + base64.startsWith("data:") ? base64 : `data:image/png;base64,${base64}`; + const mediaType = ref.imageId.toLowerCase().match(/\.(jpe?g|png|gif|webp)$/) + ? (ref.imageId.endsWith(".png") ? "image/png" + : ref.imageId.match(/\.jpe?g$/i) ? "image/jpeg" + : ref.imageId.endsWith(".gif") ? "image/gif" + : "image/webp") + : "image/png"; + + fileInfos.push({ filename: ref.imageId, mediaType, data: dataUrl }); + break; + } + } + + if (fileInfos.length === 0) { + return "No PDF images could be found. Ensure the PDF was OCR'd with images (OCR_INCLUDE_IMAGES not false) and the imageId matches the placeholder (e.g. img-0.jpeg)."; + } + + const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename} (from PDF)`).join("\n"); + const { defaultInstruction, outputFormat } = buildFileProcessingPrompt(fileInfos); + const batchPrompt = instruction + ? `Analyze the following ${fileInfos.length} image(s) extracted from PDFs:\n${fileListText}\n\n${instruction}\n\n${outputFormat}` + : `Analyze the following ${fileInfos.length} image(s) extracted from PDFs:\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`; + + const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [ + { type: "text", text: batchPrompt }, + ...fileInfos.map((f) => ({ + type: "file" as const, + data: f.data, + mediaType: f.mediaType, + filename: f.filename, + })), + ]; + + const { text: batchAnalysis } = await generateText({ + model: google("gemini-2.5-flash-lite"), + messages: [{ role: "user", content: messageContent }], + }); + + return batchAnalysis; +} + /** * Process a YouTube video using Gemini's native video support */ @@ -255,16 +369,20 @@ async function processYouTubeVideo( */ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { return tool({ - description: "Process and analyze files including PDFs, images, documents, and videos. Handles local file URLs (/api/files/...), Supabase storage URLs (files uploaded to your workspace), and YouTube videos. Files are downloaded and analyzed directly by Gemini. You can provide custom instructions for what to extract or focus on. Use this for file URLs, video URLs, OR by providing the names of files/videos existing in the workspace (fuzzy matched). If a PDF has cached content it will be returned automatically — set forceReprocess to true to bypass the cache and re-analyze with a custom instruction.", + description: "Process and analyze files including PDFs, images, documents, and videos. Handles local file URLs (/api/files/...), Supabase storage URLs (files uploaded to your workspace), YouTube videos, and images extracted from PDFs (use pdfImageRefs for placeholders like img-0.jpeg from readWorkspace). Use processFiles for file URLs, video URLs, workspace file names (fuzzy matched), OR pdfImageRefs to analyze images inside PDFs. For workspace PDFs without OCR content (ocr=none), processFiles runs OCR first and caches the result; if OCR fails it falls back to Gemini via the file URL. If a PDF has cached content it will be returned automatically — set forceReprocess to true to bypass the cache.", inputSchema: zodSchema( z.object({ urls: z.array(z.string()).optional().describe("Array of file/video URLs to process (Supabase storage URLs, local /api/files/ URLs, or YouTube URLs)"), fileNames: z.array(z.string()).optional().describe("Array of workspace item names to look up via fuzzy match (e.g. 'Annual Report')"), + pdfImageRefs: z.array(z.object({ + pdfName: z.string().describe("Name of the PDF workspace item (fuzzy matched)"), + imageId: z.string().describe("Image placeholder ID from OCR output (e.g. img-0.jpeg)"), + })).optional().describe("Images extracted from PDFs during OCR — map placeholder names to base64 for analysis"), instruction: z.string().optional().describe("Custom instruction for what to extract or focus on during analysis"), forceReprocess: z.boolean().optional().describe("Set to true to bypass cached PDF content and re-analyze the file"), }) ), - execute: async ({ urls, fileNames: fileNamesInput, instruction, forceReprocess: forceReprocessInput }) => { + execute: async ({ urls, fileNames: fileNamesInput, pdfImageRefs, instruction, forceReprocess: forceReprocessInput }) => { let urlList = urls || []; const fileNames = fileNamesInput || []; const forceReprocess = forceReprocessInput === true; @@ -290,17 +408,42 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { if (matchedItem.type === 'pdf') { const pdfData = matchedItem.data as PdfData; - // Check for cached text content first (skip if forceReprocess) - if (pdfData.textContent && !forceReprocess) { - logger.debug(`📁 [FILE_TOOL] Using cached text content for "${name}" (${pdfData.textContent.length} chars)`); - cachedResults.push(`**${matchedItem.name}** (cached):\n\n${pdfData.textContent}`); + // Use cached content only when we have actual OCR (ocrPages); textContent alone may be from Gemini/summary + if (pdfData.ocrPages?.length && pdfData.textContent && !forceReprocess) { + const formatted = formatOcrPagesAsMarkdown(pdfData.ocrPages); + logger.debug(`📁 [FILE_TOOL] Using cached OCR content for "${name}" (${formatted.length} chars)`); + cachedResults.push(`**${matchedItem.name}** (cached):\n\n${formatted}`); continue; // Skip adding to urlList — no reprocessing needed } if (pdfData.fileUrl) { + // Try OCR first (reuses upload+extract logic); fall back to Supabase URL if OCR fails + const ocrResult = await runOcrForPdfUrl(pdfData.fileUrl); + if (ocrResult) { + const formatted = ocrResult.ocrPages?.length + ? formatOcrPagesAsMarkdown(ocrResult.ocrPages) + : ocrResult.textContent; + logger.debug(`📁 [FILE_TOOL] OCR extracted content for "${name}" (${formatted.length} chars)`); + cachedResults.push(`**${matchedItem.name}** (OCR):\n\n${formatted}`); + // Persist OCR result so future calls use cached content + try { + await workspaceWorker("updatePdfContent", { + workspaceId: ctx.workspaceId!, + itemId: matchedItem.id, + pdfTextContent: ocrResult.textContent, + pdfOcrPages: ocrResult.ocrPages, + pdfOcrStatus: "complete", + }); + logger.debug(`📁 [FILE_TOOL] Persisted OCR content for PDF "${matchedItem.name}"`); + } catch (cacheErr) { + logger.warn(`📁 [FILE_TOOL] Failed to persist OCR for "${matchedItem.name}":`, cacheErr); + } + continue; // Don't add to urlList + } + // OCR failed — fall back to Supabase URL (Gemini) urlList.push(pdfData.fileUrl); matchedPdfItems.set(pdfData.fileUrl, matchedItem); - logger.debug(`📁 [FILE_TOOL] Resolved file name "${name}" to URL: ${pdfData.fileUrl}`); + logger.debug(`📁 [FILE_TOOL] Resolved file name "${name}" to URL (Supabase fallback): ${pdfData.fileUrl}`); } else { notFoundData.push(`Item "${name}" found but has no file URL.`); } @@ -331,8 +474,27 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { } } - // If all requested files had cached content, return early - if (cachedResults.length > 0 && urlList.length === 0) { + // Handle PDF image refs (placeholders like img-0.jpeg from OCR) + const pdfImageResults: string[] = []; + if (pdfImageRefs && pdfImageRefs.length > 0 && ctx?.workspaceId) { + try { + const accessResult = await loadStateForTool(ctx); + if (accessResult.success) { + const result = await processPdfImages( + pdfImageRefs, + accessResult.state.items, + instruction + ); + pdfImageResults.push(result); + } + } catch (e) { + logger.error("📁 [FILE_TOOL] Error processing PDF images:", e); + pdfImageResults.push(`Error processing PDF images: ${e instanceof Error ? e.message : String(e)}`); + } + } + + // If all requested files had cached content and no other work, return early + if (cachedResults.length > 0 && urlList.length === 0 && pdfImageResults.length === 0) { return cachedResults.join('\n\n---\n\n'); } @@ -340,8 +502,13 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { return "Error: 'urls' must be an array."; } + // If only pdfImageRefs were provided and we processed them, return those + if (urlList.length === 0 && pdfImageResults.length > 0) { + return pdfImageResults.join("\n\n---\n\n"); + } + if (urlList.length === 0) { - return "No file URLs provided (and no file names could be resolved)."; + return "No file URLs provided (and no file names or pdfImageRefs could be resolved)."; } if (urlList.length > 20) { @@ -432,6 +599,10 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { fileResults.unshift(...cachedResults); } + if (pdfImageResults.length > 0) { + fileResults.push(...pdfImageResults); + } + if (fileResults.length === 0) { return "No files were successfully processed"; } diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts index 68546642..d5f7ecb6 100644 --- a/src/lib/ai/tools/read-workspace.ts +++ b/src/lib/ai/tools/read-workspace.ts @@ -10,10 +10,14 @@ import { logger } from "@/lib/utils/logger"; import { isValidThreadIdForDb } from "@/lib/utils/thread-id"; import type { WorkspaceToolContext } from "./workspace-tools"; +const DEFAULT_LIMIT = 500; +const MAX_LIMIT = 2000; +const MAX_LINE_LENGTH = 2000; + export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { return tool({ description: - "Read the full content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. REQUIRED before targeted updateNote edits — the edit tool will error otherwise. Use path when items share the same name. When editing notes, use exact text from the Content section only (not the wrapper).", + "Read content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Usage: By default returns up to 500 lines from the start. The lineStart parameter is the 1-indexed line number to start from — call again with a larger lineStart to read later sections. Use searchWorkspace to find specific content in large items. Contents are returned with each line prefixed by its line number as : . Any line longer than 2000 characters is truncated. Avoid tiny repeated slices (e.g. 30-line chunks); read a larger window. REQUIRED before targeted updateNote edits — the tool will error otherwise.", inputSchema: zodSchema( z.object({ path: z @@ -28,9 +32,22 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { .describe( "Name for fuzzy match — use when path unknown; if multiple items share the name, use path instead" ), + lineStart: z + .number() + .int() + .min(1) + .optional() + .describe("1-based line number to start from (default 1). Use with limit for pagination."), + limit: z + .number() + .int() + .min(1) + .max(MAX_LIMIT) + .optional() + .describe(`Max lines to return (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}). Use with lineStart for pagination.`), }) ), - execute: async ({ path, itemName }) => { + execute: async ({ path, itemName, lineStart = 1, limit = DEFAULT_LIMIT }) => { if (!path?.trim() && !itemName?.trim()) { return { success: false, @@ -84,7 +101,25 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { }; } - const content = formatItemContent(item); + const fullContent = formatItemContent(item); + const allLines = fullContent.split(/\r?\n/); + const totalLines = allLines.length; + const startIdx = Math.max(0, lineStart - 1); + const cappedLimit = Math.min(limit, MAX_LIMIT); + const slice = allLines.slice(startIdx, startIdx + cappedLimit); + const content = slice + .map((line, i) => { + const lineNum = startIdx + 1 + i; + const truncated = + line.length > MAX_LINE_LENGTH + ? line.substring(0, MAX_LINE_LENGTH) + "..." + : line; + return `${lineNum}: ${truncated}`; + }) + .join("\n"); + const lineEnd = startIdx + slice.length; + const hasMore = lineEnd < totalLines; + const vpath = getVirtualPath(item, items); // Record read for read-before-write enforcement (targeted edits) @@ -117,6 +152,11 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { type: item.type, path: vpath, content, + totalLines, + lineStart: startIdx + 1, + lineEnd, + hasMore, + ...(hasMore && { nextLineStart: lineEnd + 1 }), }; }, }); diff --git a/src/lib/ai/tools/workspace-search-utils.ts b/src/lib/ai/tools/workspace-search-utils.ts index 5233e3b0..d6534cec 100644 --- a/src/lib/ai/tools/workspace-search-utils.ts +++ b/src/lib/ai/tools/workspace-search-utils.ts @@ -38,6 +38,9 @@ export function extractSearchableText(item: Item, items: Item[]): string { } case "pdf": { const data = item.data as PdfData; + if (data.ocrPages?.length) { + return data.ocrPages.map((p) => p.markdown).filter(Boolean).join("\n\n"); + } return data.textContent ?? ""; } case "quiz": { diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index e6d5ca28..eed05d42 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -202,6 +202,8 @@ export async function workspaceWorker( fileSize?: number; }; pdfTextContent?: string; // For caching extracted PDF text content + pdfOcrPages?: PdfData["ocrPages"]; // Full OCR page data from Azure Document AI + pdfOcrStatus?: "complete" | "failed"; // OCR run status flashcardData?: { cards?: { front: string; back: string }[]; // For creating flashcards cardsToAdd?: { front: string; back: string }[]; // For updating flashcards (appending) @@ -879,8 +881,8 @@ export async function workspaceWorker( if (!params.itemId) { throw new Error("Item ID required for PDF content update"); } - if (!params.pdfTextContent) { - throw new Error("Text content required for PDF content update"); + if (!params.pdfTextContent && !params.pdfOcrPages?.length) { + throw new Error("Text content or OCR pages required for PDF content update"); } const currentState = await loadWorkspaceState(params.workspaceId); @@ -893,9 +895,16 @@ export async function workspaceWorker( } const existingData = existingItem.data as PdfData; + const textContent = + params.pdfTextContent ?? + (params.pdfOcrPages?.length + ? params.pdfOcrPages.map((p) => p.markdown ?? "").filter(Boolean).join("\n\n") + : undefined); const updatedData: PdfData = { ...existingData, - textContent: params.pdfTextContent, + ...(textContent != null && { textContent }), + ...(params.pdfOcrPages != null && { ocrPages: params.pdfOcrPages }), + ...(params.pdfOcrStatus != null && { ocrStatus: params.pdfOcrStatus }), }; const changes: Partial = { data: updatedData }; diff --git a/src/lib/pdf/azure-ocr.ts b/src/lib/pdf/azure-ocr.ts new file mode 100644 index 00000000..9bbe6d0d --- /dev/null +++ b/src/lib/pdf/azure-ocr.ts @@ -0,0 +1,194 @@ +/** + * Azure Mistral Document AI OCR for PDFs. + * Splits large PDFs into 30-page batches (30 MB cap), processes in parallel, merges results. + */ + +import { PDFDocument } from "pdf-lib"; + +const MAX_PAGES_PER_BATCH = 30; +const MAX_BATCH_SIZE_BYTES = 30 * 1024 * 1024; // 30 MB +const DEFAULT_MODEL = "mistral-document-ai-2512"; + +/** Rich OCR page data from Azure Document AI (stored in PdfData.ocrPages). Dimensions omitted. */ +export interface OcrPage { + index: number; + markdown: string; + images?: unknown[]; + footer?: string | null; + header?: string | null; + hyperlinks?: unknown[]; + tables?: unknown[]; +} + +export interface OcrResult { + pages: OcrPage[]; + textContent: string; +} + +/** Azure OCR API response schema (pages array from Document AI) */ +interface AzureOcrResponsePage { + index?: number; + markdown?: string; + images?: unknown[]; + footer?: string | null; + header?: string | null; + hyperlinks?: unknown[]; + tables?: unknown[]; + dimensions?: { dpi?: number; height?: number; width?: number }; +} + +interface AzureOcrResponse { + pages?: AzureOcrResponsePage[]; + [key: string]: unknown; +} + +/** + * Call Azure Document AI OCR endpoint with a base64-encoded PDF chunk. + */ +async function ocrChunk(base64Pdf: string): Promise { + const apiKey = process.env.AZURE_DOCUMENT_AI_API_KEY; + const endpoint = process.env.AZURE_DOCUMENT_AI_ENDPOINT; + const model = + process.env.AZURE_DOCUMENT_AI_MODEL ?? DEFAULT_MODEL; + + if (!apiKey || !endpoint) { + throw new Error( + "AZURE_DOCUMENT_AI_API_KEY and AZURE_DOCUMENT_AI_ENDPOINT must be set" + ); + } + + const documentUrl = `data:application/pdf;base64,${base64Pdf}`; + const includeImages = process.env.OCR_INCLUDE_IMAGES !== "false"; + const body = { + model, + document: { + type: "document_url", + document_name: "chunk", + document_url: documentUrl, + }, + include_image_base64: includeImages, + table_format: "markdown", + }; + + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Azure OCR failed (${res.status}): ${errText}`); + } + + const json = (await res.json()) as AzureOcrResponse; + const rawPages = json.pages ?? []; + + return rawPages.map((p, i) => { + const page: OcrPage = { + index: p.index ?? i, + markdown: p.markdown ?? "", + }; + if (p.images?.length) page.images = p.images; + if (p.footer) page.footer = p.footer; + if (p.header) page.header = p.header; + if (p.hyperlinks?.length) page.hyperlinks = p.hyperlinks; + if (p.tables?.length) page.tables = p.tables; + return page; + }); +} + +/** + * Split a PDF buffer into page chunks. Respects 30 pages and ~30 MB per batch. + */ +function getChunkRanges( + pageCount: number, + buffer: Buffer, + maxPages: number +): number[][] { + if (pageCount <= maxPages) { + return [[0, pageCount - 1]]; + } + + const ranges: number[][] = []; + const avgBytesPerPage = buffer.length / pageCount; + const maxPagesForSize = Math.max( + 1, + Math.floor(MAX_BATCH_SIZE_BYTES / avgBytesPerPage) + ); + const effectiveMaxPages = Math.min(maxPages, maxPagesForSize); + + for (let start = 0; start < pageCount; start += effectiveMaxPages) { + const end = Math.min(start + effectiveMaxPages, pageCount) - 1; + ranges.push([start, end]); + } + return ranges; +} + +/** + * Extract a page range from a PDF as a new PDF buffer (base64). + */ +async function extractChunkAsBase64( + sourceDoc: PDFDocument, + startIndex: number, + endIndex: number +): Promise { + const indices: number[] = []; + for (let i = startIndex; i <= endIndex; i++) indices.push(i); + + const chunkDoc = await PDFDocument.create(); + const copiedPages = await chunkDoc.copyPages(sourceDoc, indices); + for (const page of copiedPages) chunkDoc.addPage(page); + + const bytes = await chunkDoc.save(); + return Buffer.from(bytes).toString("base64"); +} + +/** + * Run OCR on a PDF buffer. Splits into batches if >30 pages or chunk >30 MB. + */ +export async function ocrPdfFromBuffer( + buffer: Buffer, + options?: { maxPagesPerBatch?: number } +): Promise { + const maxPages = options?.maxPagesPerBatch ?? MAX_PAGES_PER_BATCH; + + const doc = await PDFDocument.load(buffer); + const pageCount = doc.getPageCount(); + + if (pageCount === 0) { + return { pages: [], textContent: "" }; + } + + const ranges = getChunkRanges(pageCount, buffer, maxPages); + + const chunkPromises = ranges.map(async ([start, end]) => { + const base64 = await extractChunkAsBase64(doc, start, end); + const pages = await ocrChunk(base64); + return { start, end, pages }; + }); + + const chunkResults = await Promise.all(chunkPromises); + + const allPages: OcrPage[] = []; + let globalIndex = 0; + for (const { pages } of chunkResults) { + for (const p of pages) { + allPages.push({ + ...p, + index: globalIndex, + }); + globalIndex++; + } + } + + const textContent = allPages + .map((p) => p.markdown) + .filter(Boolean) + .join("\n\n"); + + return { pages: allPages, textContent }; +} diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index fdb5999b..e5db5b76 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -15,7 +15,10 @@ function formatItemMetadata(item: Item, items: Item[]): string { case "pdf": { const d = item.data as PdfData; if (d?.filename) parts.push(`filename=${d.filename}`); - if (d?.textContent) parts.push("hasContent=true"); + // OCR indicator: complete only when we have ocrPages (actual OCR ran) + if (d?.ocrStatus === "failed") parts.push("ocr=failed"); + else if (d?.ocrPages?.length) parts.push("ocr=complete"); + else parts.push("ocr=none"); break; } case "flashcard": { @@ -57,7 +60,7 @@ Workspace is empty. Reference items by name when created. ); return ` -Paths and metadata. Reference items by path or name. Use processFiles or selected cards for content. +Paths and metadata. Use readWorkspace for items with ocr=complete; use processFiles for PDFs with ocr=none. ${entries.join("\n")} `; @@ -109,7 +112,9 @@ Use webSearch when: temporal cues ("today", "latest", "current"), real-time data Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content. If uncertain about accuracy, prefer to search. -PDF ATTACHMENTS: When the user uploads a PDF as a file attachment and there's a matching PDF item in the workspace, call updatePdfContent with a comprehensive summary of the PDF's content to cache it. This avoids reprocessing the file later. +PDF CONTENT: For PDFs with ocr=complete (in virtual-workspace metadata), use readWorkspace to get the content — it is already extracted. Use processFiles only for PDFs with ocr=none (not yet extracted) or ocr=failed. + +PDF IMAGES: When readWorkspace shows image placeholders like ![img-0.jpeg](img-0.jpeg), use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "img-0.jpeg" }] to analyze the image. YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search. @@ -141,6 +146,8 @@ Use inline brackets only — no separate block. Format: [citation:REF] where REF - Workspace note: [citation:Note Title] — use exact note title from workspace - Workspace + quote: [citation:Note Title | exact excerpt] — pipe with spaces before quote; only when you have the exact text +Do NOT use page numbers (e.g. p. 3, page 5) in citations. Use the actual quoted text instead. + Examples: - [citation:https://en.wikipedia.org/wiki/Supply_chain] - [citation:My Calculus Notes] @@ -528,7 +535,7 @@ function formatSelectedCardFull(item: Item, index: number): string { * Format full content of a single item. Used by read tool and formatSelectedCardFull. */ export function formatItemContent(item: Item): string { - const lines = [``]; + const lines: string[] = []; switch (item.type) { case "note": @@ -556,7 +563,6 @@ export function formatItemContent(item: Item): string { break; } - lines.push(``); return lines.join("\n"); } @@ -584,10 +590,57 @@ function formatNoteDetailsFull(data: NoteData): string[] { return lines; } +/** + * Formats OCR pages as markdown matching readWorkspace output. + * Exported for processFiles to return OCR content in the same format. + */ +export function formatOcrPagesAsMarkdown(ocrPages: PdfData["ocrPages"]): string { + if (!ocrPages?.length) return ""; + const lines: string[] = [`OCR Pages (${ocrPages.length}):`]; + for (const page of ocrPages) { + const pageNum = page.index + 1; + lines.push(`--- Page ${pageNum} ---`); + if (page.header) lines.push(`Header: ${page.header}`); + const rawMd = page.markdown ?? ""; + const md = replaceOcrPlaceholders( + rawMd, + page.tables as Array<{ id?: string; content?: string }> | undefined + ); + for (const line of md.split(/\r?\n/)) lines.push(line); + if (page.footer) lines.push(`Footer: ${page.footer}`); + lines.push(""); + } + return lines.join("\n").trimEnd(); +} + +/** Replaces table placeholders [id](id) with actual table content. Images stay as placeholders; use processFiles with pdfImageRefs to fetch. */ +function replaceOcrPlaceholders( + markdown: string, + tables?: Array<{ id?: string; content?: string }> +): string { + let out = markdown; + for (const tbl of tables ?? []) { + const id = tbl.id; + const content = tbl.content; + if (!id || !content) continue; + out = out.replace( + new RegExp(`\\[${escapeRegex(id)}\\]\\(${escapeRegex(id)}\\)`, "g"), + `\n\n[Table ${id}]\n${content}\n\n` + ); + } + return out; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + /** * Formats PDF details with FULL content - * If cached textContent is available, include it so the agent can reason about the PDF + * If cached textContent/ocrPages are available, include them so the agent can reason about the PDF * without needing to call processFiles. + * OCR pages output markdown as proper lines (one line per line) instead of JSON blobs. + * Image and table placeholders are mapped to actual content when available. */ function formatPdfDetailsFull(data: PdfData): string[] { const lines: string[] = []; @@ -600,12 +653,23 @@ function formatPdfDetailsFull(data: PdfData): string[] { lines.push(` - URL: ${data.fileUrl}`); } - if (data.fileSize) { - const sizeMB = (data.fileSize / (1024 * 1024)).toFixed(2); - lines.push(` - Size: ${sizeMB} MB`); - } - - if (data.textContent) { + if (data.ocrPages?.length) { + lines.push(` - OCR Pages (${data.ocrPages.length}):`); + for (const page of data.ocrPages) { + const pageNum = page.index + 1; + lines.push(` --- Page ${pageNum} ---`); + if (page.header) lines.push(` Header: ${page.header}`); + const rawMd = page.markdown ?? ""; + const md = replaceOcrPlaceholders( + rawMd, + page.tables as Array<{ id?: string; content?: string }> | undefined + ); + for (const line of md.split(/\r?\n/)) { + lines.push(` ${line}`); + } + if (page.footer) lines.push(` Footer: ${page.footer}`); + } + } else if (data.textContent) { lines.push(` - Extracted Content:\n${data.textContent}`); } else { lines.push(` - (Content not yet extracted — use processFiles or upload the PDF to extract)`); diff --git a/src/lib/workspace-state/search.ts b/src/lib/workspace-state/search.ts index 00ec4bfa..317ad08f 100644 --- a/src/lib/workspace-state/search.ts +++ b/src/lib/workspace-state/search.ts @@ -1,4 +1,4 @@ -import type { Item, NoteData } from "./types"; +import type { Item, NoteData, PdfData } from "./types"; /** * Extracts searchable text from an item's data field @@ -13,9 +13,8 @@ function getSearchableDataText(item: Item): string { } case "pdf": { - // PDF cards don't have searchable text content in the data field - // The filename is already indexed via item.name - return ""; + const pdfData = data as PdfData; + return pdfData.textContent ?? ""; } default: diff --git a/src/lib/workspace-state/types.ts b/src/lib/workspace-state/types.ts index d6f9323d..bddf3982 100644 --- a/src/lib/workspace-state/types.ts +++ b/src/lib/workspace-state/types.ts @@ -31,6 +31,17 @@ export interface PdfData { filename: string; // original filename fileSize?: number; // optional file size in bytes textContent?: string; // cached extracted text content (avoids reprocessing) + ocrStatus?: "complete" | "failed"; + ocrError?: string; + ocrPages?: Array<{ + index: number; + markdown: string; + images?: unknown[]; + footer?: string | null; + header?: string | null; + hyperlinks?: unknown[]; + tables?: unknown[]; + }>; } export interface FlashcardItem { From 23ff5674fad346603581e7e0dd82bc988c0ec189 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 12:29:07 -0500 Subject: [PATCH 17/56] fix --- src/app/api/pdf/upload-and-ocr/route.ts | 86 ++++++++++--------------- 1 file changed, 35 insertions(+), 51 deletions(-) diff --git a/src/app/api/pdf/upload-and-ocr/route.ts b/src/app/api/pdf/upload-and-ocr/route.ts index 10e2be32..bf546ebc 100644 --- a/src/app/api/pdf/upload-and-ocr/route.ts +++ b/src/app/api/pdf/upload-and-ocr/route.ts @@ -80,67 +80,51 @@ export async function POST(req: NextRequest) { const filename = `${timestamp}-${random}-${sanitizedName}`; const storageType = getStorageType(); - let fileUrl: string; - if (storageType === "local") { - fileUrl = await saveFileLocally(buffer, filename); - } else { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - - if (!supabaseUrl || !serviceRoleKey) { - return NextResponse.json( - { error: "Server configuration error: Supabase not configured" }, - { status: 500 } - ); - } - - const supabase = createClient(supabaseUrl, serviceRoleKey, { - auth: { autoRefreshToken: false, persistSession: false }, - }); - - const { error } = await supabase.storage - .from("file-upload") - .upload(filename, buffer, { - cacheControl: "3600", - upsert: false, - contentType: "application/pdf", - }); - - if (error) { - logger.error("[PDF_UPLOAD_OCR] Supabase upload failed:", error); - return NextResponse.json( - { error: `Failed to upload: ${error.message}` }, - { status: 500 } - ); - } - - const { data: urlData } = supabase.storage - .from("file-upload") - .getPublicUrl(filename); - fileUrl = urlData.publicUrl; - } - - logger.info("[PDF_UPLOAD_OCR] Upload complete, running OCR", { - filename, - sizeMB: (buffer.length / (1024 * 1024)).toFixed(2), - }); + // Run upload and OCR in parallel (we have the buffer for both) + const [uploadResult, ocrResultOrError] = await Promise.all([ + storageType === "local" + ? saveFileLocally(buffer, filename) + : (async () => { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !serviceRoleKey) { + throw new Error("Server configuration error: Supabase not configured"); + } + const supabase = createClient(supabaseUrl, serviceRoleKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + const { error } = await supabase.storage + .from("file-upload") + .upload(filename, buffer, { + cacheControl: "3600", + upsert: false, + contentType: "application/pdf", + }); + if (error) throw new Error(error.message); + const { data } = supabase.storage.from("file-upload").getPublicUrl(filename); + return data.publicUrl; + })(), + ocrPdfFromBuffer(buffer).catch((err) => err), + ]); + + const fileUrl = uploadResult; let ocrResult: Awaited>; let ocrStatus: "complete" | "failed" = "complete"; let ocrError: string | undefined; - try { - ocrResult = await ocrPdfFromBuffer(buffer); + if (ocrResultOrError instanceof Error) { + ocrStatus = "failed"; + ocrError = ocrResultOrError.message; + ocrResult = { pages: [], textContent: "" }; + logger.warn("[PDF_UPLOAD_OCR] OCR failed, returning file without content:", ocrError); + } else { + ocrResult = ocrResultOrError; logger.info("[PDF_UPLOAD_OCR] OCR complete", { pageCount: ocrResult.pages.length, textContentLength: ocrResult.textContent.length, }); - } catch (err) { - ocrStatus = "failed"; - ocrError = err instanceof Error ? err.message : "OCR failed"; - logger.warn("[PDF_UPLOAD_OCR] OCR failed, returning file without content:", ocrError); - ocrResult = { pages: [], textContent: "" }; } return NextResponse.json({ From 810908afdba7e649521e8e9a12d0acb3728881e9 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:17:30 -0500 Subject: [PATCH 18/56] fix: types --- src/app/api/audio/process/status/route.ts | 6 +++++- src/lib/ai/workers/workspace-worker.ts | 5 +++-- tsconfig.json | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/api/audio/process/status/route.ts b/src/app/api/audio/process/status/route.ts index a03d4d7a..26827b27 100644 --- a/src/app/api/audio/process/status/route.ts +++ b/src/app/api/audio/process/status/route.ts @@ -30,7 +30,11 @@ export async function GET(req: NextRequest) { const status = await run.status; if (status === "completed") { - const result = await run.returnValue; + const result = (await run.returnValue) as { + summary: string; + segments: Array<{ speaker: string; timestamp: string; content: string }>; + duration?: number; + }; return NextResponse.json({ status: "completed", result: { diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index eed05d42..f6cde0e1 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -948,15 +948,16 @@ export async function workspaceWorker( throw new Error("Workspace was modified by another user, please try again"); } + const contentLen = textContent?.length ?? 0; logger.info("📄 [WORKSPACE-WORKER] Updated PDF text content:", { itemId: params.itemId, - contentLength: params.pdfTextContent.length, + contentLength: contentLen, }); return { success: true, itemId: params.itemId, - message: `Cached text content for PDF "${existingItem.name}" (${params.pdfTextContent.length} chars)`, + message: `Cached text content for PDF "${existingItem.name}" (${contentLen} chars)`, event, version: appendResult.version, }; diff --git a/tsconfig.json b/tsconfig.json index 26bd994a..ead11e1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,7 @@ ], "exclude": [ "node_modules", - "assistant-ui-main" + "assistant-ui-main", + "tmp" ] } From eecc672670477bc71695a40458fb9b04c36adccc Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:07:00 -0500 Subject: [PATCH 19/56] fix: upload --- .env.example | 2 +- src/app/api/pdf/ocr/route.ts | 14 +- src/app/api/pdf/upload-and-ocr/route.ts | 149 ------------------ src/app/dashboard/page.tsx | 14 +- .../assistant-ui/ReadWorkspaceToolUI.tsx | 23 ++- .../WorkspaceCanvasDropzone.tsx | 18 +-- .../workspace-canvas/WorkspaceSection.tsx | 14 +- src/lib/ai/tools/read-workspace.ts | 33 +++- src/lib/pdf/azure-ocr.ts | 126 ++++++++++----- src/lib/uploads/client-upload.ts | 34 +++- src/lib/uploads/pdf-upload-with-ocr.ts | 76 +++++++++ src/lib/utils/format-workspace-context.ts | 51 +++++- 12 files changed, 316 insertions(+), 238 deletions(-) delete mode 100644 src/app/api/pdf/upload-and-ocr/route.ts create mode 100644 src/lib/uploads/pdf-upload-with-ocr.ts diff --git a/.env.example b/.env.example index a114bc06..c0a996ca 100644 --- a/.env.example +++ b/.env.example @@ -48,7 +48,7 @@ SCRAPING_MODE=hybrid # Azure Document AI (Mistral OCR) - for PDF upload OCR and scripts/ocr-pdf-from-url.sh AZURE_DOCUMENT_AI_API_KEY=your-api-key-from-azure-deployment -AZURE_DOCUMENT_AI_ENDPOINT=https://chakrabortyurjit-7873-resource.services.ai.azure.com/providers/mistral/azure/ocr +# Required: AZURE_DOCUMENT_AI_ENDPOINT=your-ocr-endpoint-url # Optional: AZURE_DOCUMENT_AI_MODEL (default: mistral-document-ai-2512) # Optional: OCR_INCLUDE_IMAGES=false to skip extracting images (smaller OCR; pdfImageRefs won't work) diff --git a/src/app/api/pdf/ocr/route.ts b/src/app/api/pdf/ocr/route.ts index 6b7d4432..5b5b7b87 100644 --- a/src/app/api/pdf/ocr/route.ts +++ b/src/app/api/pdf/ocr/route.ts @@ -31,6 +31,7 @@ export async function POST(req: NextRequest) { ); } + const t0 = Date.now(); logger.info("[PDF_OCR] Route fired", { fileUrl: fileUrl.slice(0, 80) + (fileUrl.length > 80 ? "…" : ""), userId: session.user?.id, @@ -65,7 +66,11 @@ export async function POST(req: NextRequest) { ); } + const tFetch = Date.now(); const res = await fetch(fileUrl); + logger.info("[PDF_OCR] Fetched from storage", { + ms: Date.now() - tFetch, + }); if (!res.ok) { return NextResponse.json( { error: `Failed to fetch PDF: ${res.status} ${res.statusText}` }, @@ -97,9 +102,10 @@ export async function POST(req: NextRequest) { const arrayBuffer = await res.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - logger.debug("[PDF_OCR] Fetched PDF", { + logger.info("[PDF_OCR] PDF buffered", { sizeBytes: buffer.length, sizeMB: (buffer.length / (1024 * 1024)).toFixed(2), + fetchMs: Date.now() - tFetch, }); if (buffer.length > MAX_PDF_SIZE_BYTES) { @@ -111,12 +117,16 @@ export async function POST(req: NextRequest) { ); } + const tOcr = Date.now(); const result = await ocrPdfFromBuffer(buffer); + const ocrMs = Date.now() - tOcr; + const totalMs = Date.now() - t0; logger.info("[PDF_OCR] OCR complete", { pageCount: result.pages.length, textContentLength: result.textContent.length, - textContentPreview: result.textContent.slice(0, 100) + (result.textContent.length > 100 ? "…" : ""), + ocrMs, + totalMs, }); return NextResponse.json({ diff --git a/src/app/api/pdf/upload-and-ocr/route.ts b/src/app/api/pdf/upload-and-ocr/route.ts deleted file mode 100644 index bf546ebc..00000000 --- a/src/app/api/pdf/upload-and-ocr/route.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/lib/auth"; -import { headers } from "next/headers"; -import { createClient } from "@supabase/supabase-js"; -import { writeFile, mkdir } from "fs/promises"; -import { join } from "path"; -import { existsSync } from "fs"; -import { ocrPdfFromBuffer } from "@/lib/pdf/azure-ocr"; -import { logger } from "@/lib/utils/logger"; - -export const dynamic = "force-dynamic"; -export const maxDuration = 120; // OCR can be slow for large PDFs -const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB - -const getStorageType = (): "supabase" | "local" => { - const storageType = process.env.STORAGE_TYPE || "supabase"; - return storageType === "local" ? "local" : "supabase"; -}; - -async function saveFileLocally(buffer: Buffer, filename: string): Promise { - const uploadsDir = process.env.UPLOADS_DIR || join(process.cwd(), "uploads"); - if (!existsSync(uploadsDir)) { - await mkdir(uploadsDir, { recursive: true }); - } - const filePath = join(uploadsDir, filename); - await writeFile(filePath, buffer); - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; - return `${baseUrl}/api/files/${filename}`; -} - -/** - * POST /api/pdf/upload-and-ocr - * Accepts a PDF file via multipart form, uploads to storage, runs OCR on the buffer, - * returns fileUrl + OCR result. Single request — no fetch-back from storage. - */ -export async function POST(req: NextRequest) { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const formData = await req.formData(); - const file = formData.get("file") as File | null; - - if (!file) { - return NextResponse.json( - { error: "No file provided" }, - { status: 400 } - ); - } - - if ( - file.type !== "application/pdf" && - !file.name.toLowerCase().endsWith(".pdf") - ) { - return NextResponse.json( - { error: "File must be a PDF" }, - { status: 400 } - ); - } - - if (file.size > MAX_PDF_SIZE_BYTES) { - return NextResponse.json( - { - error: `PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`, - }, - { status: 400 } - ); - } - - const arrayBuffer = await file.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - const timestamp = Date.now(); - const random = Math.random().toString(36).substring(2, 15); - const sanitizedName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_"); - const filename = `${timestamp}-${random}-${sanitizedName}`; - - const storageType = getStorageType(); - - // Run upload and OCR in parallel (we have the buffer for both) - const [uploadResult, ocrResultOrError] = await Promise.all([ - storageType === "local" - ? saveFileLocally(buffer, filename) - : (async () => { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!supabaseUrl || !serviceRoleKey) { - throw new Error("Server configuration error: Supabase not configured"); - } - const supabase = createClient(supabaseUrl, serviceRoleKey, { - auth: { autoRefreshToken: false, persistSession: false }, - }); - const { error } = await supabase.storage - .from("file-upload") - .upload(filename, buffer, { - cacheControl: "3600", - upsert: false, - contentType: "application/pdf", - }); - if (error) throw new Error(error.message); - const { data } = supabase.storage.from("file-upload").getPublicUrl(filename); - return data.publicUrl; - })(), - ocrPdfFromBuffer(buffer).catch((err) => err), - ]); - - const fileUrl = uploadResult; - - let ocrResult: Awaited>; - let ocrStatus: "complete" | "failed" = "complete"; - let ocrError: string | undefined; - - if (ocrResultOrError instanceof Error) { - ocrStatus = "failed"; - ocrError = ocrResultOrError.message; - ocrResult = { pages: [], textContent: "" }; - logger.warn("[PDF_UPLOAD_OCR] OCR failed, returning file without content:", ocrError); - } else { - ocrResult = ocrResultOrError; - logger.info("[PDF_UPLOAD_OCR] OCR complete", { - pageCount: ocrResult.pages.length, - textContentLength: ocrResult.textContent.length, - }); - } - - return NextResponse.json({ - fileUrl, - filename: file.name, - fileSize: file.size, - textContent: ocrResult.textContent, - ocrPages: ocrResult.pages, - ocrStatus, - ...(ocrError && { ocrError }), - }); - } catch (error: unknown) { - logger.error("[PDF_UPLOAD_OCR] Error:", error); - return NextResponse.json( - { - error: - error instanceof Error ? error.message : "Upload and OCR failed", - }, - { status: 500 } - ); - } -} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index cd39c4f5..8377fb79 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -44,6 +44,7 @@ import { useWorkspaceInstructionModal } from "@/hooks/workspace/use-workspace-in import { InviteGuard } from "@/components/workspace/InviteGuard"; import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; +import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import { useFolderUrl } from "@/hooks/ui/use-folder-url"; import { OPEN_RECORD_PARAM } from "@/components/modals/RecordWorkspaceDialog"; @@ -352,23 +353,14 @@ function DashboardContent({ await Promise.all( unprotectedFiles.map(async (file) => { try { - const formData = new FormData(); - formData.append("file", file); - const res = await fetch("/api/pdf/upload-and-ocr", { - method: "POST", - body: formData, - }); - const json = await res.json(); - if (!res.ok || json.error) { - throw new Error(json.error || "Upload and OCR failed"); - } + const json = await uploadPdfAndRunOcr(file); const pdfData: Partial = { fileUrl: json.fileUrl, filename: json.filename, fileSize: json.fileSize, textContent: json.textContent, ocrPages: json.ocrPages, - ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? "complete" : "failed"), + ocrStatus: json.ocrStatus, ...(json.ocrError && { ocrError: json.ocrError }), }; return { diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx index 8616e889..9ae75971 100644 --- a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -6,7 +6,7 @@ import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; -type ReadArgs = { path?: string; itemName?: string }; +type ReadArgs = { path?: string; itemName?: string; pageStart?: number; pageEnd?: number }; type ReadResult = { success: boolean; itemName?: string; @@ -14,6 +14,8 @@ type ReadResult = { path?: string; content?: string; message?: string; + totalPages?: number; + pageRange?: { start?: number; end?: number }; }; function stripExtension(s: string): string { @@ -26,10 +28,18 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({ let content: React.ReactNode = null; if (status.type === "running") { + const pageInfo = + args?.pageStart != null && args?.pageEnd != null + ? ` (pages ${args.pageStart}-${args.pageEnd})` + : args?.pageStart != null + ? ` (from page ${args.pageStart})` + : args?.pageEnd != null + ? ` (to page ${args.pageEnd})` + : ""; const label = args?.path - ? `Reading ${stripExtension(args.path)}` + ? `Reading ${stripExtension(args.path)}${pageInfo}` : args?.itemName - ? `Reading "${args.itemName}"` + ? `Reading "${args.itemName}"${pageInfo}` : "Reading workspace item..."; content = ; } else if (status.type === "complete" && result) { @@ -41,6 +51,12 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({ /> ); } else if (result.success) { + const pageLabel = + result.pageRange?.start != null && result.pageRange?.end != null + ? ` pages ${result.pageRange.start}-${result.pageRange.end}` + : result.totalPages != null + ? ` (${result.totalPages} pages)` + : ""; content = (
@@ -50,6 +66,7 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({ {result.path ? stripExtension(result.path) : result.itemName} + {pageLabel} {result.type && ( {result.type} diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx index 8e06ed25..9ab4f53d 100644 --- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx +++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx @@ -11,6 +11,7 @@ import type { PdfData, ImageData, AudioData } from "@/lib/workspace-state/types" import { getBestFrameForRatio, type GridFrame } from "@/lib/workspace-state/aspect-ratios"; import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; +import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; @@ -142,11 +143,11 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro ); try { - // Separate PDFs from other files — PDFs use upload-and-ocr (single request) + // Separate PDFs from other files — PDFs use direct upload + OCR from URL const pdfFiles = filteredFiles.filter((f) => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')); const nonPdfFiles = filteredFiles.filter((f) => f.type !== 'application/pdf' && !f.name.toLowerCase().endsWith('.pdf')); - // PDFs: upload + OCR in one request per file + // PDFs: direct upload to Supabase + OCR from URL (bypasses 10MB body limit) const pdfResults: Array<{ fileUrl: string; filename: string; @@ -162,16 +163,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro const pdfPromises = pdfFiles.map(async (file) => { try { - const formData = new FormData(); - formData.append('file', file); - const res = await fetch('/api/pdf/upload-and-ocr', { - method: 'POST', - body: formData, - }); - const json = await res.json(); - if (!res.ok || json.error) { - throw new Error(json.error || 'Upload and OCR failed'); - } + const json = await uploadPdfAndRunOcr(file); return { fileUrl: json.fileUrl, filename: json.filename, @@ -183,7 +175,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro fileSize: json.fileSize, textContent: json.textContent, ocrPages: json.ocrPages, - ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? 'complete' : 'failed'), + ocrStatus: json.ocrStatus, ...(json.ocrError && { ocrError: json.ocrError }), } as Partial, }; diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 2115d284..6c230107 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -15,6 +15,7 @@ import { useSession } from "@/lib/auth-client"; import { LoginGate } from "@/components/workspace/LoginGate"; import { AccessDenied } from "@/components/workspace/AccessDenied"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; +import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; @@ -468,23 +469,14 @@ export function WorkspaceSection({ const uploadAndOcrPromises = unprotectedFiles.map(async (file) => { try { - const formData = new FormData(); - formData.append('file', file); - const res = await fetch('/api/pdf/upload-and-ocr', { - method: 'POST', - body: formData, - }); - const json = await res.json(); - if (!res.ok || json.error) { - throw new Error(json.error || 'Upload and OCR failed'); - } + const json = await uploadPdfAndRunOcr(file); const pdfData: Partial = { fileUrl: json.fileUrl, filename: json.filename, fileSize: json.fileSize, textContent: json.textContent, ocrPages: json.ocrPages, - ocrStatus: json.ocrStatus ?? (json.ocrPages?.length ? 'complete' : 'failed'), + ocrStatus: json.ocrStatus, ...(json.ocrError && { ocrError: json.ocrError }), }; return { diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts index d5f7ecb6..3b158839 100644 --- a/src/lib/ai/tools/read-workspace.ts +++ b/src/lib/ai/tools/read-workspace.ts @@ -17,7 +17,7 @@ const MAX_LINE_LENGTH = 2000; export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { return tool({ description: - "Read content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Usage: By default returns up to 500 lines from the start. The lineStart parameter is the 1-indexed line number to start from — call again with a larger lineStart to read later sections. Use searchWorkspace to find specific content in large items. Contents are returned with each line prefixed by its line number as : . Any line longer than 2000 characters is truncated. Avoid tiny repeated slices (e.g. 30-line chunks); read a larger window. REQUIRED before targeted updateNote edits — the tool will error otherwise.", + "Read content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Usage: By default returns up to 500 lines from the start. The lineStart parameter is the 1-indexed line number to start from — call again with a larger lineStart to read later sections. For PDFs: use pageStart and pageEnd (1-indexed) to read only specific pages — e.g. pageStart=5, pageEnd=10 reads pages 5–10. Use searchWorkspace to find specific content in large items. Contents are returned with each line prefixed by its line number as : . Any line longer than 2000 characters is truncated. Avoid tiny repeated slices (e.g. 30-line chunks); read a larger window. REQUIRED before targeted updateNote edits — the tool will error otherwise.", inputSchema: zodSchema( z.object({ path: z @@ -45,9 +45,21 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { .max(MAX_LIMIT) .optional() .describe(`Max lines to return (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}). Use with lineStart for pagination.`), + pageStart: z + .number() + .int() + .min(1) + .optional() + .describe("For PDFs only: 1-indexed start page (e.g. 5 for page 5). Use with pageEnd to read a page range."), + pageEnd: z + .number() + .int() + .min(1) + .optional() + .describe("For PDFs only: 1-indexed end page inclusive (e.g. 10 for pages 5–10). Use with pageStart."), }) ), - execute: async ({ path, itemName, lineStart = 1, limit = DEFAULT_LIMIT }) => { + execute: async ({ path, itemName, lineStart = 1, limit = DEFAULT_LIMIT, pageStart, pageEnd }) => { if (!path?.trim() && !itemName?.trim()) { return { success: false, @@ -101,7 +113,12 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { }; } - const fullContent = formatItemContent(item); + const pdfPageRange = + item.type === "pdf" && (pageStart != null || pageEnd != null) + ? { pageStart, pageEnd } + : undefined; + + const fullContent = formatItemContent(item, pdfPageRange); const allLines = fullContent.split(/\r?\n/); const totalLines = allLines.length; const startIdx = Math.max(0, lineStart - 1); @@ -157,6 +174,16 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { lineEnd, hasMore, ...(hasMore && { nextLineStart: lineEnd + 1 }), + ...(item.type === "pdf" && + Array.isArray((item.data as { ocrPages?: unknown[] })?.ocrPages) && { + totalPages: (item.data as { ocrPages: unknown[] }).ocrPages.length, + ...(pdfPageRange && { + pageRange: { + start: pdfPageRange.pageStart, + end: pdfPageRange.pageEnd, + }, + }), + }), }; }, }); diff --git a/src/lib/pdf/azure-ocr.ts b/src/lib/pdf/azure-ocr.ts index 9bbe6d0d..3d507d4d 100644 --- a/src/lib/pdf/azure-ocr.ts +++ b/src/lib/pdf/azure-ocr.ts @@ -1,12 +1,15 @@ /** * Azure Mistral Document AI OCR for PDFs. - * Splits large PDFs into 30-page batches (30 MB cap), processes in parallel, merges results. + * Splits large PDFs into 10-page batches (30 MB cap), processes in parallel, merges results. */ import { PDFDocument } from "pdf-lib"; +import { logger } from "@/lib/utils/logger"; -const MAX_PAGES_PER_BATCH = 30; +const MAX_PAGES_PER_BATCH = 10; const MAX_BATCH_SIZE_BYTES = 30 * 1024 * 1024; // 30 MB +/** Max concurrent OCR requests to Azure; prevents 408 timeouts from request flooding */ +const MAX_CONCURRENT_OCR = 5; const DEFAULT_MODEL = "mistral-document-ai-2512"; /** Rich OCR page data from Azure Document AI (stored in PdfData.ocrPages). Dimensions omitted. */ @@ -67,42 +70,55 @@ async function ocrChunk(base64Pdf: string): Promise { document_url: documentUrl, }, include_image_base64: includeImages, - table_format: "markdown", + table_format: null, }; - const res = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify(body), - }); + const maxRetries = 1; // Retry 408 (timeout) and 429 (rate limit) once + let lastError: Error | null = null; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (res.ok) { + const json = (await res.json()) as AzureOcrResponse; + const rawPages = json.pages ?? []; + + return rawPages.map((p, i) => { + const page: OcrPage = { + index: p.index ?? i, + markdown: p.markdown ?? "", + }; + if (p.images?.length) page.images = p.images; + if (p.footer) page.footer = p.footer; + if (p.header) page.header = p.header; + if (p.hyperlinks?.length) page.hyperlinks = p.hyperlinks; + if (p.tables?.length) page.tables = p.tables; + return page; + }); + } - if (!res.ok) { const errText = await res.text(); - throw new Error(`Azure OCR failed (${res.status}): ${errText}`); + lastError = new Error(`Azure OCR failed (${res.status}): ${errText}`); + + if ((res.status === 408 || res.status === 429) && attempt < maxRetries) { + const delayMs = 3000 * (attempt + 1); + logger.warn("[PDF_OCR_AZURE] Retry after", res.status, { attempt, delayMs }); + await new Promise((r) => setTimeout(r, delayMs)); + } else { + throw lastError; + } } - - const json = (await res.json()) as AzureOcrResponse; - const rawPages = json.pages ?? []; - - return rawPages.map((p, i) => { - const page: OcrPage = { - index: p.index ?? i, - markdown: p.markdown ?? "", - }; - if (p.images?.length) page.images = p.images; - if (p.footer) page.footer = p.footer; - if (p.header) page.header = p.header; - if (p.hyperlinks?.length) page.hyperlinks = p.hyperlinks; - if (p.tables?.length) page.tables = p.tables; - return page; - }); + throw lastError; } /** - * Split a PDF buffer into page chunks. Respects 30 pages and ~30 MB per batch. + * Split a PDF buffer into page chunks. Respects 10 pages and ~30 MB per batch. */ function getChunkRanges( pageCount: number, @@ -148,30 +164,60 @@ async function extractChunkAsBase64( } /** - * Run OCR on a PDF buffer. Splits into batches if >30 pages or chunk >30 MB. + * Run OCR on a PDF buffer. Splits into batches if >10 pages or chunk >30 MB. */ export async function ocrPdfFromBuffer( buffer: Buffer, options?: { maxPagesPerBatch?: number } ): Promise { + const t0 = Date.now(); const maxPages = options?.maxPagesPerBatch ?? MAX_PAGES_PER_BATCH; const doc = await PDFDocument.load(buffer); const pageCount = doc.getPageCount(); + logger.info("[PDF_OCR_AZURE] Loaded PDF", { + pageCount, + sizeMB: (buffer.length / (1024 * 1024)).toFixed(2), + loadMs: Date.now() - t0, + }); if (pageCount === 0) { return { pages: [], textContent: "" }; } const ranges = getChunkRanges(pageCount, buffer, maxPages); - - const chunkPromises = ranges.map(async ([start, end]) => { - const base64 = await extractChunkAsBase64(doc, start, end); - const pages = await ocrChunk(base64); - return { start, end, pages }; + logger.info("[PDF_OCR_AZURE] Chunk plan", { + chunkCount: ranges.length, + concurrency: MAX_CONCURRENT_OCR, + ranges: ranges.map(([s, e]) => `${s}-${e}`), }); - const chunkResults = await Promise.all(chunkPromises); + // Process in batches of MAX_CONCURRENT_OCR to avoid 408 timeouts from flooding Azure + const chunkResults: Array<{ start: number; end: number; pages: OcrPage[] }> = []; + for (let i = 0; i < ranges.length; i += MAX_CONCURRENT_OCR) { + const batch = ranges.slice(i, i + MAX_CONCURRENT_OCR); + const batchResults = await Promise.all( + batch.map(async ([start, end]) => { + const tChunk = Date.now(); + const base64 = await extractChunkAsBase64(doc, start, end); + const extractMs = Date.now() - tChunk; + const pages = await ocrChunk(base64); + const ocrChunkMs = Date.now() - tChunk; + logger.debug("[PDF_OCR_AZURE] Chunk done", { + range: `${start}-${end}`, + pages: pages.length, + extractMs, + ocrMs: ocrChunkMs - extractMs, + }); + return { start, end, pages }; + }) + ); + chunkResults.push(...batchResults); + } + logger.info("[PDF_OCR_AZURE] All chunks complete", { + totalChunks: chunkResults.length, + chunkMs: Date.now() - t0, + }); const allPages: OcrPage[] = []; let globalIndex = 0; @@ -190,5 +236,11 @@ export async function ocrPdfFromBuffer( .filter(Boolean) .join("\n\n"); + logger.info("[PDF_OCR_AZURE] Merge complete", { + pageCount: allPages.length, + textContentLength: textContent.length, + totalMs: Date.now() - t0, + }); + return { pages: allPages, textContent }; } diff --git a/src/lib/uploads/client-upload.ts b/src/lib/uploads/client-upload.ts index 385812e7..27aa31c2 100644 --- a/src/lib/uploads/client-upload.ts +++ b/src/lib/uploads/client-upload.ts @@ -17,11 +17,22 @@ interface UploadResult { filename: string; } +export interface UploadFileDirectOptions { + /** Enable timing and step logs for debugging (e.g. PDF upload flow) */ + log?: boolean; +} + /** * Upload a file directly to storage, bypassing the serverless function body limit. * Works for both Supabase (direct upload) and local storage (fallback to API route). */ -export async function uploadFileDirect(file: File): Promise { +export async function uploadFileDirect( + file: File, + options?: UploadFileDirectOptions +): Promise { + const log = options?.log ?? false; + const t0 = log ? performance.now() : 0; + if (file.size > MAX_FILE_SIZE_BYTES) { throw new Error( `File size exceeds ${MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB limit` @@ -46,14 +57,25 @@ export async function uploadFileDirect(file: File): Promise { } const urlData = await urlResponse.json(); + if (log) { + console.info( + `[PDF_UPLOAD] Get signed URL: ${(performance.now() - t0).toFixed(0)}ms` + ); + } // Local storage mode: fall back to /api/upload-file if (urlData.mode === "local") { - return uploadViaApiRoute(file); + const result = await uploadViaApiRoute(file); + if (log) { + const t = performance.now() - t0; + console.info(`[PDF_UPLOAD] Local fallback upload: ${t.toFixed(0)}ms`); + } + return result; } // Step 2: Upload file directly to Supabase using the signed URL const { signedUrl, token, publicUrl, path } = urlData; + const tPut = log ? performance.now() : 0; const uploadResponse = await fetch(signedUrl, { method: "PUT", @@ -74,6 +96,14 @@ export async function uploadFileDirect(file: File): Promise { ); } + if (log) { + const t = performance.now() - tPut; + const total = performance.now() - t0; + console.info( + `[PDF_UPLOAD] Direct upload to storage: ${t.toFixed(0)}ms | total upload: ${total.toFixed(0)}ms` + ); + } + return { url: publicUrl, filename: path, diff --git a/src/lib/uploads/pdf-upload-with-ocr.ts b/src/lib/uploads/pdf-upload-with-ocr.ts new file mode 100644 index 00000000..26741c8a --- /dev/null +++ b/src/lib/uploads/pdf-upload-with-ocr.ts @@ -0,0 +1,76 @@ +/** + * PDF upload + OCR flow using direct Supabase upload. + * Uploads to storage first, then runs OCR via /api/pdf/ocr (bypasses 10MB body limit). + */ + +import type { OcrPage } from "@/lib/pdf/azure-ocr"; +import { uploadFileDirect } from "./client-upload"; + +export interface PdfUploadWithOcrResult { + fileUrl: string; + filename: string; + fileSize: number; + textContent: string; + ocrPages: OcrPage[]; + ocrStatus: "complete" | "failed"; + ocrError?: string; +} + +/** + * Upload a PDF to storage (Supabase or local) and run OCR. + * Uses direct upload to bypass Next.js body size limits. + */ +export async function uploadPdfAndRunOcr( + file: File +): Promise { + const t0 = performance.now(); + console.info( + `[PDF_UPLOAD_OCR] Start: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)` + ); + + const { url: fileUrl, filename } = await uploadFileDirect(file, { log: true }); + const uploadMs = performance.now() - t0; + console.info(`[PDF_UPLOAD_OCR] Upload complete: ${uploadMs.toFixed(0)}ms`); + + const tOcr = performance.now(); + const ocrRes = await fetch("/api/pdf/ocr", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileUrl }), + }); + + const ocrJson = await ocrRes.json(); + const ocrMs = performance.now() - tOcr; + const totalMs = performance.now() - t0; + console.info( + `[PDF_UPLOAD_OCR] OCR request: ${ocrMs.toFixed(0)}ms | total: ${totalMs.toFixed(0)}ms` + ); + + if (!ocrRes.ok) { + console.warn( + `[PDF_UPLOAD_OCR] OCR failed (${totalMs.toFixed(0)}ms):`, + ocrJson.error + ); + return { + fileUrl, + filename: filename || file.name, + fileSize: file.size, + textContent: "", + ocrPages: [], + ocrStatus: "failed", + ocrError: ocrJson.error || "OCR failed", + }; + } + + console.info( + `[PDF_UPLOAD_OCR] Done: ${file.name} | ${ocrJson.ocrPages?.length ?? 0} pages | ${totalMs.toFixed(0)}ms total` + ); + return { + fileUrl, + filename: filename || file.name, + fileSize: file.size, + textContent: ocrJson.textContent ?? "", + ocrPages: ocrJson.ocrPages ?? [], + ocrStatus: "complete", + }; +} diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index e5db5b76..372875af 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -112,7 +112,7 @@ Use webSearch when: temporal cues ("today", "latest", "current"), real-time data Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content. If uncertain about accuracy, prefer to search. -PDF CONTENT: For PDFs with ocr=complete (in virtual-workspace metadata), use readWorkspace to get the content — it is already extracted. Use processFiles only for PDFs with ocr=none (not yet extracted) or ocr=failed. +PDF CONTENT: For PDFs with ocr=complete (in virtual-workspace metadata), use readWorkspace to get the content — it is already extracted. Use pageStart and pageEnd (1-indexed) to read specific pages only — e.g. pageStart=5, pageEnd=10 for pages 5–10. Use processFiles only for PDFs with ocr=none (not yet extracted) or ocr=failed. PDF IMAGES: When readWorkspace shows image placeholders like ![img-0.jpeg](img-0.jpeg), use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "img-0.jpeg" }] to analyze the image. @@ -531,10 +531,21 @@ function formatSelectedCardFull(item: Item, index: number): string { return formatItemContent(item); } +export interface FormatItemContentOptions { + /** For PDFs: 1-indexed start page (inclusive) */ + pageStart?: number; + /** For PDFs: 1-indexed end page (inclusive) */ + pageEnd?: number; +} + /** * Format full content of a single item. Used by read tool and formatSelectedCardFull. + * For PDFs, pass pageStart/pageEnd to read only specific pages. */ -export function formatItemContent(item: Item): string { +export function formatItemContent( + item: Item, + options?: FormatItemContentOptions +): string { const lines: string[] = []; switch (item.type) { @@ -542,7 +553,13 @@ export function formatItemContent(item: Item): string { lines.push(...formatNoteDetailsFull(item.data as NoteData)); break; case "pdf": - lines.push(...formatPdfDetailsFull(item.data as PdfData)); + lines.push( + ...formatPdfDetailsFull( + item.data as PdfData, + options?.pageStart, + options?.pageEnd + ) + ); break; case "flashcard": lines.push(...formatFlashcardDetailsFull(item.data as FlashcardData)); @@ -641,8 +658,13 @@ function escapeRegex(s: string): string { * without needing to call processFiles. * OCR pages output markdown as proper lines (one line per line) instead of JSON blobs. * Image and table placeholders are mapped to actual content when available. + * Optionally filter by pageStart/pageEnd (1-indexed, inclusive). */ -function formatPdfDetailsFull(data: PdfData): string[] { +function formatPdfDetailsFull( + data: PdfData, + pageStart?: number, + pageEnd?: number +): string[] { const lines: string[] = []; if (data.filename) { @@ -654,8 +676,25 @@ function formatPdfDetailsFull(data: PdfData): string[] { } if (data.ocrPages?.length) { - lines.push(` - OCR Pages (${data.ocrPages.length}):`); - for (const page of data.ocrPages) { + let pagesToShow = data.ocrPages; + if (pageStart != null || pageEnd != null) { + const startIdx = pageStart != null ? Math.max(0, pageStart - 1) : 0; + const endIdx = + pageEnd != null + ? Math.min(data.ocrPages.length - 1, pageEnd - 1) + : data.ocrPages.length - 1; + pagesToShow = data.ocrPages.filter( + (p) => p.index >= startIdx && p.index <= endIdx + ); + if (pagesToShow.length > 0) { + lines.push( + ` - OCR Pages ${pageStart ?? 1}-${pageEnd ?? data.ocrPages.length} (${pagesToShow.length} of ${data.ocrPages.length}):` + ); + } + } else { + lines.push(` - OCR Pages (${data.ocrPages.length}):`); + } + for (const page of pagesToShow) { const pageNum = page.index + 1; lines.push(` --- Page ${pageNum} ---`); if (page.header) lines.push(` Header: ${page.header}`); From fa26f550975b6045a6d4c4063f9800aeb1bb6b3f Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:26:17 -0500 Subject: [PATCH 20/56] more fixes --- src/app/api/pdf/ocr/route.ts | 19 +-- src/app/api/pdf/ocr/start/route.ts | 85 ++++++++++ src/app/api/pdf/ocr/status/route.ts | 75 +++++++++ src/app/dashboard/page.tsx | 105 ++++++++---- src/components/assistant-ui/attachment.tsx | 2 +- src/components/assistant-ui/markdown-text.tsx | 48 +++++- src/components/modals/UploadDialog.tsx | 15 +- src/components/pdf/AppPdfViewer.tsx | 105 +++++++----- .../WorkspaceCanvasDropzone.tsx | 77 ++++++--- .../workspace-canvas/WorkspaceCard.tsx | 30 +++- .../workspace-canvas/WorkspaceContent.tsx | 25 +++ .../workspace-canvas/WorkspaceSection.tsx | 116 +++++++++---- src/hooks/workspace/use-workspace-mutation.ts | 17 +- .../workspace/use-workspace-operations.ts | 40 ++++- src/lib/ai/tools/process-files.ts | 157 +++--------------- src/lib/pdf/azure-ocr.ts | 115 +++++++++---- src/lib/pdf/poll-pdf-ocr.ts | 45 +++++ src/lib/stores/ui-store.ts | 4 +- src/lib/uploads/pdf-upload-with-ocr.ts | 67 +++++--- src/lib/utils/format-workspace-context.ts | 48 +++--- src/lib/utils/preprocess-latex.ts | 26 +-- src/lib/workspace-state/types.ts | 2 +- src/workflows/pdf-ocr/index.ts | 66 ++++++++ src/workflows/pdf-ocr/steps/fetch-and-ocr.ts | 62 +++++++ src/workflows/pdf-ocr/steps/fetch-pdf.ts | 39 +++++ src/workflows/pdf-ocr/steps/index.ts | 4 + src/workflows/pdf-ocr/steps/ocr-chunk.ts | 16 ++ .../pdf-ocr/steps/prepare-ocr-chunks.ts | 18 ++ 28 files changed, 1028 insertions(+), 400 deletions(-) create mode 100644 src/app/api/pdf/ocr/start/route.ts create mode 100644 src/app/api/pdf/ocr/status/route.ts create mode 100644 src/lib/pdf/poll-pdf-ocr.ts create mode 100644 src/workflows/pdf-ocr/index.ts create mode 100644 src/workflows/pdf-ocr/steps/fetch-and-ocr.ts create mode 100644 src/workflows/pdf-ocr/steps/fetch-pdf.ts create mode 100644 src/workflows/pdf-ocr/steps/index.ts create mode 100644 src/workflows/pdf-ocr/steps/ocr-chunk.ts create mode 100644 src/workflows/pdf-ocr/steps/prepare-ocr-chunks.ts diff --git a/src/app/api/pdf/ocr/route.ts b/src/app/api/pdf/ocr/route.ts index 5b5b7b87..11b06b86 100644 --- a/src/app/api/pdf/ocr/route.ts +++ b/src/app/api/pdf/ocr/route.ts @@ -32,10 +32,6 @@ export async function POST(req: NextRequest) { } const t0 = Date.now(); - logger.info("[PDF_OCR] Route fired", { - fileUrl: fileUrl.slice(0, 80) + (fileUrl.length > 80 ? "…" : ""), - userId: session.user?.id, - }); // Validate URL origin to prevent SSRF const allowedHosts: string[] = []; @@ -66,11 +62,7 @@ export async function POST(req: NextRequest) { ); } - const tFetch = Date.now(); const res = await fetch(fileUrl); - logger.info("[PDF_OCR] Fetched from storage", { - ms: Date.now() - tFetch, - }); if (!res.ok) { return NextResponse.json( { error: `Failed to fetch PDF: ${res.status} ${res.statusText}` }, @@ -102,12 +94,6 @@ export async function POST(req: NextRequest) { const arrayBuffer = await res.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - logger.info("[PDF_OCR] PDF buffered", { - sizeBytes: buffer.length, - sizeMB: (buffer.length / (1024 * 1024)).toFixed(2), - fetchMs: Date.now() - tFetch, - }); - if (buffer.length > MAX_PDF_SIZE_BYTES) { return NextResponse.json( { @@ -119,13 +105,10 @@ export async function POST(req: NextRequest) { const tOcr = Date.now(); const result = await ocrPdfFromBuffer(buffer); - const ocrMs = Date.now() - tOcr; const totalMs = Date.now() - t0; - logger.info("[PDF_OCR] OCR complete", { + logger.info("[PDF_OCR] Complete", { pageCount: result.pages.length, - textContentLength: result.textContent.length, - ocrMs, totalMs, }); diff --git a/src/app/api/pdf/ocr/start/route.ts b/src/app/api/pdf/ocr/start/route.ts new file mode 100644 index 00000000..4d88d567 --- /dev/null +++ b/src/app/api/pdf/ocr/start/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { start } from "workflow/api"; +import { pdfOcrWorkflow } from "@/workflows/pdf-ocr"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/pdf/ocr/start + * Validates URL, starts durable PDF OCR workflow, returns runId and itemId for polling. + */ +export async function POST(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { fileUrl, itemId } = body; + + if (!fileUrl || typeof fileUrl !== "string") { + return NextResponse.json( + { error: "fileUrl is required" }, + { status: 400 } + ); + } + + if (!itemId || typeof itemId !== "string") { + return NextResponse.json( + { error: "itemId is required for polling" }, + { status: 400 } + ); + } + + // Validate URL origin to prevent SSRF + const allowedHosts: string[] = []; + if (process.env.NEXT_PUBLIC_SUPABASE_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname); + } + if (process.env.NEXT_PUBLIC_APP_URL) { + allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname); + } + allowedHosts.push("localhost", "127.0.0.1"); + + let parsedUrl: URL; + try { + parsedUrl = new URL(fileUrl); + } catch { + return NextResponse.json({ error: "Invalid fileUrl" }, { status: 400 }); + } + + if ( + !allowedHosts.some( + (host) => + parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`) + ) + ) { + return NextResponse.json( + { error: "fileUrl origin is not allowed" }, + { status: 400 } + ); + } + + // Start durable workflow; return immediately for client to poll + const run = await start(pdfOcrWorkflow, [fileUrl]); + + return NextResponse.json({ + runId: run.runId, + itemId, + }); + } catch (error: unknown) { + console.error("[PDF_OCR_START] Error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to start PDF OCR", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/pdf/ocr/status/route.ts b/src/app/api/pdf/ocr/status/route.ts new file mode 100644 index 00000000..124a3e69 --- /dev/null +++ b/src/app/api/pdf/ocr/status/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { getRun } from "workflow/api"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/pdf/ocr/status?runId=xxx + * Poll workflow status. Returns { status, result? } when completed or { status, error? } when failed. + */ +export async function GET(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const runId = req.nextUrl.searchParams.get("runId"); + if (!runId) { + return NextResponse.json( + { error: "runId is required" }, + { status: 400 } + ); + } + + const run = getRun(runId); + const status = await run.status; + + if (status === "completed") { + const result = (await run.returnValue) as { + textContent: string; + ocrPages: Array<{ + index: number; + markdown: string; + [key: string]: unknown; + }>; + }; + return NextResponse.json({ + status: "completed", + result: { + textContent: result.textContent ?? "", + ocrPages: result.ocrPages ?? [], + }, + }); + } + + if (status === "failed") { + let errorMessage = "OCR failed"; + try { + await run.returnValue; + } catch (err) { + errorMessage = + err instanceof Error ? err.message : String(err ?? "OCR failed"); + } + return NextResponse.json({ + status: "failed", + error: errorMessage, + }); + } + + return NextResponse.json({ status: "running" }); + } catch (error: unknown) { + console.error("[PDF_OCR_STATUS] Error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to get status", + }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 8377fb79..ab953405 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -44,7 +44,7 @@ import { useWorkspaceInstructionModal } from "@/hooks/workspace/use-workspace-in import { InviteGuard } from "@/components/workspace/InviteGuard"; import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; -import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr"; +import { uploadPdfToStorage } from "@/lib/uploads/pdf-upload-with-ocr"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; import { useFolderUrl } from "@/hooks/ui/use-folder-url"; import { OPEN_RECORD_PARAM } from "@/components/modals/RecordWorkspaceDialog"; @@ -344,45 +344,86 @@ function DashboardContent({ return; } - const ocrToastId = toast.loading( - `Uploading and extracting text from ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? "s" : ""}...`, + const uploadToastId = toast.loading( + `Uploading ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? "s" : ""}...`, { style: { color: "#fff" } } ); - const pdfCardDefinitions = ( - await Promise.all( - unprotectedFiles.map(async (file) => { - try { - const json = await uploadPdfAndRunOcr(file); - const pdfData: Partial = { - fileUrl: json.fileUrl, - filename: json.filename, - fileSize: json.fileSize, - textContent: json.textContent, - ocrPages: json.ocrPages, - ocrStatus: json.ocrStatus, - ...(json.ocrError && { ocrError: json.ocrError }), - }; - return { - type: "pdf" as const, - name: file.name.replace(/\.pdf$/i, ""), - initialData: pdfData, - }; - } catch (err) { - toast.error(`Failed to process ${file.name}: ${err instanceof Error ? err.message : "Unknown error"}`); - return null; - } - }) - ) - ).filter((r): r is NonNullable => r !== null); + const uploadResults = await Promise.all( + unprotectedFiles.map(async (file) => { + try { + const { url, filename, fileSize } = await uploadPdfToStorage(file); + return { file, fileUrl: url, filename, fileSize }; + } catch (err) { + toast.error(`Failed to upload ${file.name}: ${err instanceof Error ? err.message : "Unknown error"}`); + return null; + } + }) + ); - toast.dismiss(ocrToastId); + toast.dismiss(uploadToastId); - if (pdfCardDefinitions.length === 0) return; + const validUploads = uploadResults.filter((r): r is NonNullable => r !== null); + if (validUploads.length === 0) return; + + const pdfCardDefinitions = validUploads.map(({ file, fileUrl, filename, fileSize }) => ({ + type: "pdf" as const, + name: file.name.replace(/\.pdf$/i, ""), + initialData: { + fileUrl, + filename, + fileSize, + ocrStatus: "processing" as const, + ocrPages: [], + } as Partial, + })); - // Create all PDF cards and navigate to the first one const createdIds = operations.createItems(pdfCardDefinitions); handleCreatedItems(createdIds); + + // Run OCR via workflow; poller dispatches pdf-processing-complete + validUploads.forEach((r, i) => { + const itemId = createdIds[i]; + if (!itemId) return; + fetch("/api/pdf/ocr/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileUrl: r.fileUrl, itemId }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.runId && data.itemId) { + import("@/lib/pdf/poll-pdf-ocr").then(({ pollPdfOcr }) => + pollPdfOcr(data.runId, data.itemId) + ); + } else { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: data.error || "Failed to start OCR", + }, + }) + ); + } + }) + .catch((err) => { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: err.message || "Failed to start OCR", + }, + }) + ); + }); + }); }, [operations, currentWorkspaceId, handleCreatedItems] ); diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index e4d6032c..08322cd4 100644 --- a/src/components/assistant-ui/attachment.tsx +++ b/src/components/assistant-ui/attachment.tsx @@ -467,7 +467,7 @@ export const ComposerAddAttachment: FC = () => { if (!files || files.length === 0) return; const MAX_FILES = 10; - const MAX_FILE_SIZE_MB = 10; + const MAX_FILE_SIZE_MB = 50; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; const fileArray = Array.from(files); diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index 884a865f..96953428 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -40,6 +40,26 @@ import { cn } from "@/lib/utils"; const math = createMathPlugin({ singleDollarTextMath: true }); const code = createCodePlugin({ themes: ["one-dark-pro", "one-dark-pro"] }); +/** Parse page number from citation ref (e.g. "Title | quote | p. 5" or "Title | p. 5"). */ +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: "" }; + + const last = segments[segments.length - 1]; + const pageMatch = last.match(/^(?:p\.?\s*)?(\d+)$/i) || last.match(/^page\s*(\d+)$/i); + let pageNumber: number | undefined; + let title: string; + let quote: string | undefined; + + if (pageMatch) { + pageNumber = parseInt(pageMatch[1], 10); + segments.pop(); + } + title = segments[0] ?? ""; + quote = segments.length > 1 ? segments.slice(1).join(" | ") : undefined; + 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(); @@ -78,10 +98,11 @@ const CitationRenderer = memo( return ; } - // Workspace citation: Title or Title|quote - const pipeIdx = ref.indexOf(" | "); - const title = pipeIdx !== -1 ? ref.slice(0, pipeIdx).trim() : ref; - const quote = pipeIdx !== -1 ? ref.slice(pipeIdx + 3).trim() : undefined; + // Workspace citation: Title, Title|quote, or Title|quote|p. 5 (with optional page) + 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); @@ -98,8 +119,13 @@ const CitationRenderer = memo( titleNorm(i.name) === titleNorm(title) ); if (!item) return; - if (quote?.trim()) { - setCitationHighlightQuery({ itemId: item.id, query: quote.trim() }); + // 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, + query: quote?.trim() ?? "", + ...(pageNumber != null && { pageNumber }), + }); } navigateToItem(item.id, { silent: true }); setOpenModalItemId(item.id); @@ -116,7 +142,10 @@ const CitationRenderer = memo( 20 ? "…" : "")} + fallbackLabel={ + title.slice(0, 20) + (title.length > 20 ? "…" : "") + + (pageNumber != null ? ` · p.${pageNumber}` : "") + } /> {quote && {quote}} + {pageNumber != null && ( + + Page {pageNumber} + + )} diff --git a/src/components/modals/UploadDialog.tsx b/src/components/modals/UploadDialog.tsx index e1202cfd..e26d6105 100644 --- a/src/components/modals/UploadDialog.tsx +++ b/src/components/modals/UploadDialog.tsx @@ -71,17 +71,20 @@ export function UploadDialog({ } }, [open]); - // Upload multiple image files + // Upload multiple image files in parallel const uploadImageFiles = useCallback(async (files: File[]) => { setIsUploading(true); const toastId = toast.loading(`Uploading ${files.length} image${files.length > 1 ? 's' : ''}...`); try { - for (const file of files) { - const result = await uploadFileDirect(file); - const simpleName = file.name.split('.').slice(0, -1).join('.') || "Image"; - onImageCreate(result.url, simpleName); - } + const results = await Promise.all( + files.map(async (file) => { + const result = await uploadFileDirect(file); + const simpleName = file.name.split('.').slice(0, -1).join('.') || "Image"; + return { url: result.url, simpleName }; + }) + ); + results.forEach(({ url, simpleName }) => onImageCreate(url, simpleName)); toast.dismiss(toastId); toast.success(`${files.length} image${files.length > 1 ? 's' : ''} uploaded successfully`); diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx index 325e6a5f..ffda59d5 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -695,72 +695,101 @@ const PdfSearchBar = ({ documentId }: { documentId: string }) => { const PDF_HIGHLIGHT_DURATION_MS = 2500; -/** Syncs citation highlight query from store: search PDF and scroll to first match */ +/** Syncs citation highlight query from store: search PDF and scroll to first match, or scroll to page if no results */ const PdfCitationHighlightSync = ({ documentId, itemId }: { documentId: string; itemId?: string }) => { const { state, provides } = useSearch(documentId); const { provides: scrollCapability } = useScrollCapability(); + const { state: scrollState } = useScroll(documentId); const scroll = scrollCapability?.forDocument(documentId); const triggeredRef = useRef(null); + const pageFallbackRef = useRef(null); const timeoutRef = useRef | undefined>(undefined); useEffect(() => { if (!itemId) return; - const applyHighlight = (query: string) => { - if (!provides || !query?.trim()) return; - triggeredRef.current = query; - provides.searchAllPages(query); + const clearCitation = () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = undefined; + provides?.stopSearch(); + useUIStore.getState().setCitationHighlightQuery(null); + triggeredRef.current = null; + pageFallbackRef.current = null; + }; + + const applyCitation = (hl: { itemId: string; query: string; pageNumber?: number }) => { + const hasQuery = !!hl.query?.trim(); + const hasPage = hl.pageNumber != null && hl.pageNumber >= 1; + + if (hasQuery) { + triggeredRef.current = hl.query.trim(); + pageFallbackRef.current = hasPage ? hl.pageNumber! : null; + provides?.searchAllPages(hl.query.trim()); + } else if (hasPage && scroll && hl.pageNumber != null) { + // No query: scroll directly to page + const totalPages = scrollState?.totalPages ?? 1; + const page = Math.min(Math.max(1, hl.pageNumber), totalPages); + scroll.scrollToPage({ pageNumber: page }); + pageFallbackRef.current = null; + } if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => { - provides.stopSearch(); - useUIStore.getState().setCitationHighlightQuery(null); - triggeredRef.current = null; - }, PDF_HIGHLIGHT_DURATION_MS); + timeoutRef.current = setTimeout(clearCitation, PDF_HIGHLIGHT_DURATION_MS); }; const unsub = useUIStore.subscribe((storeState) => { const hl = storeState.citationHighlightQuery; - if (!hl || hl.itemId !== itemId || !hl.query?.trim()) return; - applyHighlight(hl.query.trim()); + if (!hl || hl.itemId !== itemId) return; + if (!hl.query?.trim() && !(hl.pageNumber != null && hl.pageNumber >= 1)) return; + applyCitation(hl); }); const hl = useUIStore.getState().citationHighlightQuery; - if (hl?.itemId === itemId && hl.query?.trim()) { - applyHighlight(hl.query.trim()); + if (hl?.itemId === itemId && (hl.query?.trim() || (hl.pageNumber != null && hl.pageNumber >= 1))) { + applyCitation(hl); } return () => { unsub(); - if (timeoutRef.current) clearTimeout(timeoutRef.current); + clearCitation(); }; - }, [documentId, itemId, provides]); + }, [documentId, itemId, provides, scroll, scrollState?.totalPages]); - // Scroll to first result when search completes + // Scroll to first result when search completes, or fallback to page when no results useEffect(() => { if (!scroll || !triggeredRef.current || state.loading) return; - const results = state.results; - if (!results?.length) return; - - const idx = state.activeResultIndex ?? 0; - const item = results[idx]; - if (!item) return; - - const minCoordinates = item.rects.reduce( - (min, rect) => ({ - x: Math.min(min.x, rect.origin.x), - y: Math.min(min.y, rect.origin.y), - }), - { x: Infinity, y: Infinity } - ); + const results = state.results ?? []; + const pageFallback = pageFallbackRef.current; + + if (results.length > 0) { + const idx = state.activeResultIndex ?? 0; + const item = results[idx]; + if (item) { + const minCoordinates = item.rects.reduce( + (min, rect) => ({ + x: Math.min(min.x, rect.origin.x), + y: Math.min(min.y, rect.origin.y), + }), + { x: Infinity, y: Infinity } + ); + scroll.scrollToPage({ + pageNumber: item.pageIndex + 1, + pageCoordinates: minCoordinates, + alignX: 50, + alignY: 50, + }); + return; + } + } - scroll.scrollToPage({ - pageNumber: item.pageIndex + 1, - pageCoordinates: minCoordinates, - alignX: 50, - alignY: 50, - }); - }, [scroll, state.results, state.activeResultIndex, state.loading]); + // Text search returned no results: fallback to page number if provided + if (results.length === 0 && pageFallback != null) { + const totalPages = scrollState?.totalPages ?? 1; + const page = Math.min(Math.max(1, pageFallback), totalPages); + scroll.scrollToPage({ pageNumber: page }); + pageFallbackRef.current = null; + } + }, [scroll, state.results, state.activeResultIndex, state.loading, scrollState?.totalPages]); return null; }; diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx index 9ab4f53d..c6908e20 100644 --- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx +++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx @@ -11,7 +11,7 @@ import type { PdfData, ImageData, AudioData } from "@/lib/workspace-state/types" import { getBestFrameForRatio, type GridFrame } from "@/lib/workspace-state/aspect-ratios"; import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; -import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr"; +import { uploadPdfToStorage } from "@/lib/uploads/pdf-upload-with-ocr"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; @@ -147,7 +147,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro const pdfFiles = filteredFiles.filter((f) => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')); const nonPdfFiles = filteredFiles.filter((f) => f.type !== 'application/pdf' && !f.name.toLowerCase().endsWith('.pdf')); - // PDFs: direct upload to Supabase + OCR from URL (bypasses 10MB body limit) + // PDFs: upload to storage only (non-blocking), OCR runs in background const pdfResults: Array<{ fileUrl: string; filename: string; @@ -156,40 +156,32 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro pdfData: Partial; }> = []; if (pdfFiles.length > 0) { - const pdfToastId = toast.loading( - `Uploading and extracting text from ${pdfFiles.length} PDF${pdfFiles.length > 1 ? 's' : ''}...`, - { style: { color: '#fff' } } - ); - const pdfPromises = pdfFiles.map(async (file) => { try { - const json = await uploadPdfAndRunOcr(file); + const { url, filename, fileSize } = await uploadPdfToStorage(file); return { - fileUrl: json.fileUrl, - filename: json.filename, - fileSize: json.fileSize, + fileUrl: url, + filename, + fileSize, name: file.name.replace(/\.pdf$/i, ''), pdfData: { - fileUrl: json.fileUrl, - filename: json.filename, - fileSize: json.fileSize, - textContent: json.textContent, - ocrPages: json.ocrPages, - ocrStatus: json.ocrStatus, - ...(json.ocrError && { ocrError: json.ocrError }), + fileUrl: url, + filename, + fileSize, + ocrStatus: "processing" as const, + ocrPages: [], } as Partial, }; } catch (err) { const fileKey = getFileKey(file); processingFilesRef.current.delete(fileKey); - console.error('PDF upload-and-OCR failed:', err); + console.error('PDF upload failed:', err); return null; } }); const results = await Promise.all(pdfPromises); pdfResults.push(...results.filter((r): r is NonNullable => r !== null)); - toast.dismiss(pdfToastId); } // Non-PDFs: upload only @@ -231,7 +223,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro else imageResults.push(r); }); - // Create PDF cards (OCR data already included) + // Create PDF cards (OCR runs in background) if (pdfResults.length > 0) { const pdfCardDefinitions = pdfResults.map((r) => ({ type: 'pdf' as const, @@ -240,6 +232,49 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro })); const pdfCreatedIds = operations.createItems(pdfCardDefinitions); handleCreatedItems(pdfCreatedIds); + // Run OCR via workflow; poller dispatches pdf-processing-complete + pdfResults.forEach((r, i) => { + const itemId = pdfCreatedIds[i]; + if (!itemId) return; + fetch("/api/pdf/ocr/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileUrl: r.fileUrl, itemId }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.runId && data.itemId) { + import("@/lib/pdf/poll-pdf-ocr").then(({ pollPdfOcr }) => + pollPdfOcr(data.runId, data.itemId) + ); + } else { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: data.error || "Failed to start OCR", + }, + }) + ); + } + }) + .catch((err) => { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: err.message || "Failed to start OCR", + }, + }) + ); + }); + }); } toast.dismiss(loadingToastId); diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index bf5b1ab3..0608cbc5 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -1,6 +1,6 @@ import { QuizContent } from "./QuizContent"; import { ImageCardContent } from "./ImageCardContent"; -import { MoreVertical, Trash2, Palette, CheckCircle2, FolderInput, FileText, Copy, X, Pencil, Columns, Link2, PanelRight, SplitSquareHorizontal } from "lucide-react"; +import { MoreVertical, Trash2, Palette, CheckCircle2, FolderInput, FileText, Copy, X, Pencil, Columns, Link2, PanelRight, SplitSquareHorizontal, Loader2 } from "lucide-react"; import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi"; import { useCallback, useState, memo, useRef, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; @@ -815,11 +815,18 @@ function WorkspaceCard({ {/* Subtle type label for narrow cards without preview; hover shows "EXPAND OR CLICK TO VIEW" */} {(item.type === 'note' || item.type === 'pdf' || item.type === 'quiz' || item.type === 'audio') && !shouldShowPreview && ( - - - {item.type === 'note' ? 'Note' : item.type === 'pdf' ? 'PDF' : item.type === 'quiz' ? 'Quiz' : 'Recording'} + + + {item.type === 'note' ? 'Note' : item.type === 'pdf' ? ( + (item.data as PdfData)?.ocrStatus === 'processing' ? ( + <> + + Extracting… + + ) : 'PDF' + ) : item.type === 'quiz' ? 'Quiz' : 'Recording'} - + Expand or click to view @@ -837,10 +844,11 @@ function WorkspaceCard({ {/* When scroll is locked, render lightweight placeholder instead of full PDF viewer */} {!isOpenInPanel && item.type === 'pdf' && shouldShowPreview && (() => { const pdfData = item.data as PdfData; + const isOcrProcessing = pdfData?.ocrStatus === 'processing'; return (
{!isCardVisible ? ( @@ -856,6 +864,16 @@ function WorkspaceCard({ ) : ( )} + {/* OCR processing indicator overlay */} + {isOcrProcessing && ( +
+ + Extracting… +
+ )}
); })()} diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index a92e4c39..224145e9 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -149,6 +149,31 @@ export default function WorkspaceContent({ onOpenFolder?.(folderId); }, [setActiveFolderId, onOpenFolder]); + // Listen for PDF OCR completion events + useEffect(() => { + const handlePdfComplete = (e: Event) => { + const { itemId, textContent, ocrPages, ocrStatus, ocrError } = (e as CustomEvent).detail; + if (!itemId) return; + + const existingData = viewState.items.find((i) => i.id === itemId)?.data ?? {}; + + updateItem(itemId, { + data: { + ...existingData, + textContent, + ocrPages: ocrPages ?? [], + ocrStatus, + ...(ocrError && { ocrError }), + } as any, + }); + }; + + window.addEventListener("pdf-processing-complete", handlePdfComplete); + return () => { + window.removeEventListener("pdf-processing-complete", handlePdfComplete); + }; + }, [updateItem, viewState.items]); + // Listen for audio processing completion events useEffect(() => { const handleAudioComplete = (e: Event) => { diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 6c230107..1b5ee4e0 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -15,7 +15,7 @@ import { useSession } from "@/lib/auth-client"; import { LoginGate } from "@/components/workspace/LoginGate"; import { AccessDenied } from "@/components/workspace/AccessDenied"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; -import { uploadPdfAndRunOcr } from "@/lib/uploads/pdf-upload-with-ocr"; +import { uploadPdfToStorage } from "@/lib/uploads/pdf-upload-with-ocr"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog"; @@ -462,47 +462,91 @@ export function WorkspaceSection({ return; } - const ocrToastId = toast.loading( - `Uploading and extracting text from ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? 's' : ''}...`, + const uploadToastId = toast.loading( + `Uploading ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? 's' : ''}...`, { style: { color: '#fff' } } ); - const uploadAndOcrPromises = unprotectedFiles.map(async (file) => { - try { - const json = await uploadPdfAndRunOcr(file); - const pdfData: Partial = { - fileUrl: json.fileUrl, - filename: json.filename, - fileSize: json.fileSize, - textContent: json.textContent, - ocrPages: json.ocrPages, - ocrStatus: json.ocrStatus, - ...(json.ocrError && { ocrError: json.ocrError }), - }; - return { - type: 'pdf' as const, - name: file.name.replace(/\.pdf$/i, ''), - initialData: pdfData, - }; - } catch (err) { - toast.error(`Failed to process ${file.name}: ${err instanceof Error ? err.message : 'Unknown error'}`); - return null; - } - }); - - const pdfCardDefinitions = (await Promise.all(uploadAndOcrPromises)) - .filter((r): r is NonNullable => r !== null); - - toast.dismiss(ocrToastId); + const uploadResults = await Promise.all( + unprotectedFiles.map(async (file) => { + try { + const { url, filename, fileSize } = await uploadPdfToStorage(file); + return { + file, + fileUrl: url, + filename, + fileSize, + }; + } catch (err) { + toast.error(`Failed to upload ${file.name}: ${err instanceof Error ? err.message : 'Unknown error'}`); + return null; + } + }) + ); - if (pdfCardDefinitions.length > 0) { + toast.dismiss(uploadToastId); - // Create all PDF cards atomically in a single event - const createdIds = operations.createItems(pdfCardDefinitions); + const validUploads = uploadResults.filter((r): r is NonNullable => r !== null); + if (validUploads.length === 0) return; - // Auto-navigate to first created item - handleCreatedItems(createdIds); - } + const pdfCardDefinitions = validUploads.map(({ file, fileUrl, filename, fileSize }) => ({ + type: 'pdf' as const, + name: file.name.replace(/\.pdf$/i, ''), + initialData: { + fileUrl, + filename, + fileSize, + ocrStatus: 'processing' as const, + ocrPages: [], + } as Partial, + })); + + const createdIds = operations.createItems(pdfCardDefinitions); + handleCreatedItems(createdIds); + + // Run OCR via workflow; poller dispatches pdf-processing-complete + validUploads.forEach((r, i) => { + const itemId = createdIds[i]; + if (!itemId) return; + fetch("/api/pdf/ocr/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileUrl: r.fileUrl, itemId }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.runId && data.itemId) { + import("@/lib/pdf/poll-pdf-ocr").then(({ pollPdfOcr }) => + pollPdfOcr(data.runId, data.itemId) + ); + } else { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: data.error || "Failed to start OCR", + }, + }) + ); + } + }) + .catch((err) => { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: err.message || "Failed to start OCR", + }, + }) + ); + }); + }); }; diff --git a/src/hooks/workspace/use-workspace-mutation.ts b/src/hooks/workspace/use-workspace-mutation.ts index 918398a7..02ab4131 100644 --- a/src/hooks/workspace/use-workspace-mutation.ts +++ b/src/hooks/workspace/use-workspace-mutation.ts @@ -199,11 +199,18 @@ export function useWorkspaceMutation(workspaceId: string | null, options: Worksp onSuccess: (data, event, context) => { if (!workspaceId) return; - logger.debug("✅ [SUCCESS] Mutation succeeded:", { - conflict: data.conflict, - newVersion: data.version, - eventId: event.id, - }); + logger.debug("✅ [SUCCESS] Mutation succeeded:", { + conflict: data.conflict, + newVersion: data.version, + eventId: event.id, + }); + if (event.type === "ITEM_UPDATED" && event.payload?.changes?.data?.ocrStatus) { + console.log("[OCR/UPDATE] ITEM_UPDATED persisted:", { + itemId: event.payload.id, + ocrStatus: event.payload.changes.data.ocrStatus, + version: data.version, + }); + } // Handle conflicts with automatic retry if (data.conflict) { diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index 4820b58f..5b97f091 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -370,10 +370,34 @@ export function useWorkspaceOperations( const timeout = setTimeout(() => { const finalUpdater = pendingItemDataUpdatersRef.current.get(itemId); if (finalUpdater) { - // Get the latest item state when the timeout fires - const latestItem = currentState.items.find(item => item.id === itemId); + // CRITICAL: Read latest state from cache (not currentState closure) so we get + // items created after this callback was invoked (e.g. OCR completing after PDF create) + let latestItem: Item | undefined; + if (workspaceId) { + const cacheData = queryClient.getQueryData([ + "workspace", + workspaceId, + "events", + ]); + if (cacheData?.events) { + const latestState = replayEvents(cacheData.events, workspaceId, cacheData.snapshot?.state); + latestItem = latestState.items.find(item => item.id === itemId); + } + } + if (!latestItem) { + latestItem = currentState.items.find(item => item.id === itemId); + } if (!latestItem) { - logger.warn(`updateItemData: Item ${itemId} not found when applying debounced update`); + const cacheData = workspaceId + ? queryClient.getQueryData(["workspace", workspaceId, "events"]) + : null; + const itemCount = cacheData?.events + ? replayEvents(cacheData.events, workspaceId!, cacheData.snapshot?.state).items.length + : 0; + logger.warn(`[OCR/UPDATE] updateItemData: Item ${itemId} not found. Item may have been deleted. Cache has ${itemCount} items.`, { + itemId, + workspaceId, + }); pendingItemDataUpdatersRef.current.delete(itemId); updateItemDataDebounceRef.current.delete(itemId); return; @@ -382,10 +406,12 @@ export function useWorkspaceOperations( // Apply the final updater to get new data const newData = finalUpdater(latestItem.data); - logger.debug("⏱️ [DEBOUNCE] updateItemData firing after 500ms:", { + const ocrStatus = (newData as { ocrStatus?: string })?.ocrStatus; + logger.debug("[OCR/UPDATE] updateItemData firing:", { itemId, - hasDataChanges: true, - dataKeys: Object.keys(newData) + itemName: latestItem.name, + ocrStatus, + dataKeys: Object.keys(newData), }); // Emit update event with new data - include name for version history display @@ -404,7 +430,7 @@ export function useWorkspaceOperations( updateItemDataDebounceRef.current.set(itemId, timeout); }, - [currentState, mutation, userId, userName] + [workspaceId, queryClient, currentState, mutation, userId, userName] ); diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts index 24fd9a88..e650fd2d 100644 --- a/src/lib/ai/tools/process-files.ts +++ b/src/lib/ai/tools/process-files.ts @@ -2,9 +2,6 @@ import { google } from "@ai-sdk/google"; import { generateText, tool, zodSchema } from "ai"; import { z } from "zod"; import { logger } from "@/lib/utils/logger"; -import { readFile } from "fs/promises"; -import { join } from "path"; -import { existsSync } from "fs"; import { headers } from "next/headers"; import { loadStateForTool, fuzzyMatchItem } from "./tool-utils"; import type { WorkspaceToolContext } from "./workspace-tools"; @@ -86,96 +83,6 @@ function buildFileProcessingPrompt( return { defaultInstruction, outputFormat }; } -/** - * Extract filename from local file URL - */ -function extractLocalFilename(url: string): string | null { - // Match: http://localhost:3000/api/files/filename or /api/files/filename - const match = url.match(/\/api\/files\/(.+?)(?:\?|$)/); - return match ? decodeURIComponent(match[1]) : null; -} - -/** - * Process local files by reading from disk and sending as base64 to Gemini - */ -async function processLocalFiles( - localUrls: string[], - instruction?: string -): Promise { - const uploadsDir = process.env.UPLOADS_DIR || join(process.cwd(), 'uploads'); - const fileInfos: Array<{ filename: string; mediaType: string; data: string }> = []; - - for (const url of localUrls) { - const filename = extractLocalFilename(url); - if (!filename) { - logger.warn(`📁 [FILE_TOOL] Could not extract filename from local URL: ${url}`); - continue; - } - - // Security: Prevent directory traversal (slash and backslash for Windows) - if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { - logger.warn(`📁 [FILE_TOOL] Invalid filename detected: ${filename}`); - continue; - } - - const filePath = join(uploadsDir, filename); - - if (!existsSync(filePath)) { - logger.warn(`📁 [FILE_TOOL] File not found: ${filePath}`); - continue; - } - - try { - const fileBuffer = await readFile(filePath); - const base64Data = fileBuffer.toString('base64'); - const mediaType = getMediaTypeFromUrl(url); - - fileInfos.push({ - filename, - mediaType, - data: `data:${mediaType};base64,${base64Data}`, - }); - } catch (error) { - logger.error(`📁 [FILE_TOOL] Error reading local file ${filePath}:`, error); - continue; - } - } - - if (fileInfos.length === 0) { - return "No local files could be processed"; - } - - const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename}`).join('\n'); - const { defaultInstruction, outputFormat } = buildFileProcessingPrompt(fileInfos); - - const batchPrompt = instruction - ? `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${instruction}\n\n${outputFormat}` - : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`; - - const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [ - { type: "text", text: batchPrompt }, - ...fileInfos.map((f) => ({ - type: "file" as const, - data: f.data, - mediaType: f.mediaType, - filename: f.filename, - })), - ]; - - logger.debug("📁 [FILE_TOOL] Sending batched analysis request for", fileInfos.length, "local files"); - - const { text: batchAnalysis } = await generateText({ - model: google("gemini-2.5-flash-lite"), - messages: [{ - role: "user", - content: messageContent, - }], - }); - - logger.debug("📁 [FILE_TOOL] Successfully analyzed", fileInfos.length, "local files in batch"); - return batchAnalysis; -} - /** * Process Supabase storage files by sending URLs directly to Gemini */ @@ -369,15 +276,15 @@ async function processYouTubeVideo( */ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { return tool({ - description: "Process and analyze files including PDFs, images, documents, and videos. Handles local file URLs (/api/files/...), Supabase storage URLs (files uploaded to your workspace), YouTube videos, and images extracted from PDFs (use pdfImageRefs for placeholders like img-0.jpeg from readWorkspace). Use processFiles for file URLs, video URLs, workspace file names (fuzzy matched), OR pdfImageRefs to analyze images inside PDFs. For workspace PDFs without OCR content (ocr=none), processFiles runs OCR first and caches the result; if OCR fails it falls back to Gemini via the file URL. If a PDF has cached content it will be returned automatically — set forceReprocess to true to bypass the cache.", + description: "Process and analyze files including PDFs, images, documents, and videos. Handles Supabase storage URLs (files uploaded to your workspace), YouTube videos, and images from PDFs (use pdfImageRefs for placeholders like img-0.jpeg from readWorkspace). Use processFiles for file URLs, video URLs, workspace file names (fuzzy matched), OR pdfImageRefs to analyze images inside PDFs. For workspace PDFs without extracted content, processFiles extracts and caches the result; if extraction fails it falls back to Gemini via the file URL. If a PDF is still extracting, respond that the user should wait. If a PDF has cached content it will be returned automatically — set forceReprocess to true to bypass the cache. Cloud storage URLs only.", inputSchema: zodSchema( z.object({ - urls: z.array(z.string()).optional().describe("Array of file/video URLs to process (Supabase storage URLs, local /api/files/ URLs, or YouTube URLs)"), + urls: z.array(z.string()).optional().describe("Array of file/video URLs to process (Supabase storage URLs or YouTube URLs)"), fileNames: z.array(z.string()).optional().describe("Array of workspace item names to look up via fuzzy match (e.g. 'Annual Report')"), pdfImageRefs: z.array(z.object({ pdfName: z.string().describe("Name of the PDF workspace item (fuzzy matched)"), - imageId: z.string().describe("Image placeholder ID from OCR output (e.g. img-0.jpeg)"), - })).optional().describe("Images extracted from PDFs during OCR — map placeholder names to base64 for analysis"), + imageId: z.string().describe("Image placeholder ID from PDF (e.g. img-0.jpeg)"), + })).optional().describe("Images from PDFs — map placeholder names to base64 for analysis"), instruction: z.string().optional().describe("Custom instruction for what to extract or focus on during analysis"), forceReprocess: z.boolean().optional().describe("Set to true to bypass cached PDF content and re-analyze the file"), }) @@ -387,8 +294,6 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { const fileNames = fileNamesInput || []; const forceReprocess = forceReprocessInput === true; - // Track matched PDF items for auto-caching after extraction - const matchedPdfItems: Map = new Map(); // fileUrl -> Item const cachedResults: string[] = []; // Resolve file names to URLs using fuzzy matching if context is available @@ -416,6 +321,13 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { continue; // Skip adding to urlList — no reprocessing needed } + // OCR already running in background from upload — respond instead of Gemini fallback + if (pdfData.ocrStatus === "processing") { + cachedResults.push(`**${matchedItem.name}**: This PDF is still being extracted. Please wait a moment and try again.`); + logger.debug(`📁 [FILE_TOOL] PDF "${name}" still processing — responding with wait message`); + continue; + } + if (pdfData.fileUrl) { // Try OCR first (reuses upload+extract logic); fall back to Supabase URL if OCR fails const ocrResult = await runOcrForPdfUrl(pdfData.fileUrl); @@ -424,7 +336,7 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { ? formatOcrPagesAsMarkdown(ocrResult.ocrPages) : ocrResult.textContent; logger.debug(`📁 [FILE_TOOL] OCR extracted content for "${name}" (${formatted.length} chars)`); - cachedResults.push(`**${matchedItem.name}** (OCR):\n\n${formatted}`); + cachedResults.push(`**${matchedItem.name}** (extracted):\n\n${formatted}`); // Persist OCR result so future calls use cached content try { await workspaceWorker("updatePdfContent", { @@ -442,7 +354,6 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { } // OCR failed — fall back to Supabase URL (Gemini) urlList.push(pdfData.fileUrl); - matchedPdfItems.set(pdfData.fileUrl, matchedItem); logger.debug(`📁 [FILE_TOOL] Resolved file name "${name}" to URL (Supabase fallback): ${pdfData.fileUrl}`); } else { notFoundData.push(`Item "${name}" found but has no file URL.`); @@ -515,28 +426,26 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { return `Too many files (${urlList.length}). Maximum 20 files allowed.`; } - // Separate file URLs by type + // Separate file URLs by type (cloud only — no local /api/files/) const supabaseUrls = urlList.filter((url: string) => url.includes('supabase.co/storage')); - const localUrls = urlList.filter((url: string) => url.includes('/api/files/')); const youtubeUrls = urlList.filter((url: string) => url.match(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+/)); + const skippedLocal = urlList.filter((url: string) => url.includes('/api/files/')); + if (skippedLocal.length > 0) { + logger.debug(`📁 [FILE_TOOL] Skipping ${skippedLocal.length} local file URL(s) — cloud storage only`); + } + + if (supabaseUrls.length === 0 && youtubeUrls.length === 0) { + return skippedLocal.length > 0 + ? "Local file URLs (/api/files/...) are not supported. Use Supabase storage URLs (files uploaded to your workspace) or YouTube URLs." + : "No file URLs provided (and no file names or pdfImageRefs could be resolved)."; + } + const fileResults: string[] = []; // Process different file types in parallel using Promise.all() const processingPromises: Promise[] = []; - // Handle local file URLs (read from disk and send as base64) - if (localUrls.length > 0) { - processingPromises.push( - processLocalFiles(localUrls, instruction) - .then(result => result) - .catch(error => { - logger.error("📁 [FILE_TOOL] Error in local file processing:", error); - return `Error processing local files: ${error instanceof Error ? error.message : String(error)}`; - }) - ); - } - // Handle Supabase file URLs if (supabaseUrls.length > 0) { processingPromises.push( @@ -576,23 +485,7 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { fileResults.push(...results.filter((r): r is string => r !== null)); } - // Auto-persist extracted content to matched PDF items (fire-and-forget) - if (matchedPdfItems.size > 0 && ctx?.workspaceId && fileResults.length > 0) { - const combinedResult = fileResults.join('\n\n---\n\n'); - for (const [fileUrl, item] of matchedPdfItems) { - try { - await workspaceWorker("updatePdfContent", { - workspaceId: ctx.workspaceId, - itemId: item.id, - pdfTextContent: combinedResult, - }); - logger.debug(`📁 [FILE_TOOL] Auto-cached extracted content for PDF "${item.name}" (${combinedResult.length} chars)`); - } catch (cacheError) { - // Non-fatal: log but don't fail the tool call - logger.warn(`📁 [FILE_TOOL] Failed to auto-cache content for PDF "${item.name}":`, cacheError); - } - } - } + // Never persist Gemini analysis to PDF items — only Azure OCR (runOcrForPdfUrl) writes ocrPages/textContent // Prepend cached results if we had a mix of cached + freshly processed if (cachedResults.length > 0) { diff --git a/src/lib/pdf/azure-ocr.ts b/src/lib/pdf/azure-ocr.ts index 3d507d4d..af2ec395 100644 --- a/src/lib/pdf/azure-ocr.ts +++ b/src/lib/pdf/azure-ocr.ts @@ -1,17 +1,29 @@ /** * Azure Mistral Document AI OCR for PDFs. - * Splits large PDFs into 10-page batches (30 MB cap), processes in parallel, merges results. + * Splits PDFs into page batches (30 MB cap), processes in parallel, merges results. + * Chunk size is adaptive: smaller for few-page PDFs (max parallelism), larger for big PDFs (fewer API calls). */ import { PDFDocument } from "pdf-lib"; import { logger } from "@/lib/utils/logger"; -const MAX_PAGES_PER_BATCH = 10; const MAX_BATCH_SIZE_BYTES = 30 * 1024 * 1024; // 30 MB /** Max concurrent OCR requests to Azure; prevents 408 timeouts from request flooding */ const MAX_CONCURRENT_OCR = 5; const DEFAULT_MODEL = "mistral-document-ai-2512"; +/** + * Choose chunk size based on total page count. + * Small PDFs: 1 page per chunk (max parallelism). + * Large PDFs: bigger chunks to reduce API calls and rate-limit risk. + */ +function getMaxPagesPerChunk(pageCount: number): number { + if (pageCount <= 10) return 1; + if (pageCount <= 30) return 3; + if (pageCount <= 100) return 6; + return 12; +} + /** Rich OCR page data from Azure Document AI (stored in PdfData.ocrPages). Dimensions omitted. */ export interface OcrPage { index: number; @@ -47,8 +59,9 @@ interface AzureOcrResponse { /** * Call Azure Document AI OCR endpoint with a base64-encoded PDF chunk. + * Exported for use in workflow steps (single chunk = single step). */ -async function ocrChunk(base64Pdf: string): Promise { +export async function ocrSingleChunk(base64Pdf: string): Promise { const apiKey = process.env.AZURE_DOCUMENT_AI_API_KEY; const endpoint = process.env.AZURE_DOCUMENT_AI_ENDPOINT; const model = @@ -118,7 +131,7 @@ async function ocrChunk(base64Pdf: string): Promise { } /** - * Split a PDF buffer into page chunks. Respects 10 pages and ~30 MB per batch. + * Split a PDF buffer into page chunks. Respects maxPages and ~30 MB per batch. */ function getChunkRanges( pageCount: number, @@ -144,6 +157,38 @@ function getChunkRanges( return ranges; } +/** Chunk spec for workflow steps - each chunk is a separate OCR step */ +export interface OcrChunkSpec { + start: number; + end: number; + base64: string; +} + +/** + * Load PDF, compute chunk ranges, extract each chunk to base64. + * Returns chunk specs for workflow steps (avoids passing full PDF to each step). + */ +export async function prepareOcrChunks(buffer: Buffer): Promise<{ + pageCount: number; + chunks: OcrChunkSpec[]; +}> { + const doc = await PDFDocument.load(buffer); + const pageCount = doc.getPageCount(); + if (pageCount === 0) { + return { pageCount: 0, chunks: [] }; + } + + const maxPages = getMaxPagesPerChunk(pageCount); + const ranges = getChunkRanges(pageCount, buffer, maxPages); + + const chunks: OcrChunkSpec[] = []; + for (const [start, end] of ranges) { + const base64 = await extractChunkAsBase64(doc, start, end); + chunks.push({ start, end, base64 }); + } + return { pageCount, chunks }; +} + /** * Extract a page range from a PDF as a new PDF buffer (base64). */ @@ -164,60 +209,73 @@ async function extractChunkAsBase64( } /** - * Run OCR on a PDF buffer. Splits into batches if >10 pages or chunk >30 MB. + * Run OCR on a PDF buffer. Splits into batches; chunk size adapts to page count. */ export async function ocrPdfFromBuffer( buffer: Buffer, options?: { maxPagesPerBatch?: number } ): Promise { const t0 = Date.now(); - const maxPages = options?.maxPagesPerBatch ?? MAX_PAGES_PER_BATCH; const doc = await PDFDocument.load(buffer); const pageCount = doc.getPageCount(); - logger.info("[PDF_OCR_AZURE] Loaded PDF", { - pageCount, - sizeMB: (buffer.length / (1024 * 1024)).toFixed(2), - loadMs: Date.now() - t0, - }); if (pageCount === 0) { return { pages: [], textContent: "" }; } + const maxPages = + options?.maxPagesPerBatch ?? getMaxPagesPerChunk(pageCount); const ranges = getChunkRanges(pageCount, buffer, maxPages); - logger.info("[PDF_OCR_AZURE] Chunk plan", { - chunkCount: ranges.length, - concurrency: MAX_CONCURRENT_OCR, - ranges: ranges.map(([s, e]) => `${s}-${e}`), + + logger.info("[PDF_OCR_AZURE] Start", { + pageCount, + totalChunks: ranges.length, + pagesPerChunk: maxPages, + totalBytes: buffer.length, + totalBytesMB: (buffer.length / (1024 * 1024)).toFixed(2), }); // Process in batches of MAX_CONCURRENT_OCR to avoid 408 timeouts from flooding Azure const chunkResults: Array<{ start: number; end: number; pages: OcrPage[] }> = []; for (let i = 0; i < ranges.length; i += MAX_CONCURRENT_OCR) { const batch = ranges.slice(i, i + MAX_CONCURRENT_OCR); + const batchIndex = Math.floor(i / MAX_CONCURRENT_OCR) + 1; + const totalBatches = Math.ceil(ranges.length / MAX_CONCURRENT_OCR); + + logger.info("[PDF_OCR_AZURE] Batch start", { + batch: `${batchIndex}/${totalBatches}`, + chunks: batch.map(([s, e]) => `pages ${s}-${e}`), + }); + + const batchT0 = Date.now(); const batchResults = await Promise.all( batch.map(async ([start, end]) => { - const tChunk = Date.now(); const base64 = await extractChunkAsBase64(doc, start, end); - const extractMs = Date.now() - tChunk; - const pages = await ocrChunk(base64); - const ocrChunkMs = Date.now() - tChunk; + const chunkSizeKB = (Buffer.byteLength(base64, "utf8") * 3) / 4 / 1024; // approximate raw size + logger.debug("[PDF_OCR_AZURE] Chunk request", { + pages: `${start}-${end}`, + chunkSizeKB: chunkSizeKB.toFixed(0), + }); + const pages = await ocrSingleChunk(base64); logger.debug("[PDF_OCR_AZURE] Chunk done", { - range: `${start}-${end}`, - pages: pages.length, - extractMs, - ocrMs: ocrChunkMs - extractMs, + pages: `${start}-${end}`, + extractedPages: pages.length, }); return { start, end, pages }; }) ); + const batchMs = Date.now() - batchT0; + + const batchPages = batchResults.reduce((sum, r) => sum + r.pages.length, 0); + logger.info("[PDF_OCR_AZURE] Batch complete", { + batch: `${batchIndex}/${totalBatches}`, + pagesProcessed: batchPages, + ms: batchMs, + }); + chunkResults.push(...batchResults); } - logger.info("[PDF_OCR_AZURE] All chunks complete", { - totalChunks: chunkResults.length, - chunkMs: Date.now() - t0, - }); const allPages: OcrPage[] = []; let globalIndex = 0; @@ -236,9 +294,8 @@ export async function ocrPdfFromBuffer( .filter(Boolean) .join("\n\n"); - logger.info("[PDF_OCR_AZURE] Merge complete", { + logger.info("[PDF_OCR_AZURE] Complete", { pageCount: allPages.length, - textContentLength: textContent.length, totalMs: Date.now() - t0, }); diff --git a/src/lib/pdf/poll-pdf-ocr.ts b/src/lib/pdf/poll-pdf-ocr.ts new file mode 100644 index 00000000..54788ec2 --- /dev/null +++ b/src/lib/pdf/poll-pdf-ocr.ts @@ -0,0 +1,45 @@ +const POLL_INTERVAL_MS = 2000; // 2 seconds + +/** + * Polls the PDF OCR status endpoint until the workflow completes or fails. + * Dispatches pdf-processing-complete when done. + */ +export async function pollPdfOcr(runId: string, itemId: string): Promise { + while (true) { + const res = await fetch( + `/api/pdf/ocr/status?runId=${encodeURIComponent(runId)}` + ); + const data = await res.json(); + + if (data.status === "completed") { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: data.result?.textContent ?? "", + ocrPages: data.result?.ocrPages ?? [], + ocrStatus: "complete" as const, + }, + }) + ); + return; + } + + if (data.status === "failed") { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: data.error || "OCR failed", + }, + }) + ); + return; + } + + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } +} diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index ce293577..7873dcd4 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -57,7 +57,7 @@ interface UIState { blockNoteSelection: { cardId: string; cardName: string; text: string } | null; // Citation highlight: when opening note/PDF from citation click, highlight/search this quote - citationHighlightQuery: { itemId: string; query: string } | null; + citationHighlightQuery: { itemId: string; query: string; pageNumber?: number } | null; // Actions - Chat setIsChatExpanded: (expanded: boolean) => void; @@ -130,7 +130,7 @@ interface UIState { // Actions - BlockNote selection setBlockNoteSelection: (selection: { cardId: string; cardName: string; text: string } | null) => void; clearBlockNoteSelection: () => void; - setCitationHighlightQuery: (query: { itemId: string; query: string } | null) => void; + setCitationHighlightQuery: (query: { itemId: string; query: string; pageNumber?: number } | null) => void; // Utility actions resetChatState: () => void; diff --git a/src/lib/uploads/pdf-upload-with-ocr.ts b/src/lib/uploads/pdf-upload-with-ocr.ts index 26741c8a..9aec7302 100644 --- a/src/lib/uploads/pdf-upload-with-ocr.ts +++ b/src/lib/uploads/pdf-upload-with-ocr.ts @@ -1,6 +1,7 @@ /** * PDF upload + OCR flow using direct Supabase upload. * Uploads to storage first, then runs OCR via /api/pdf/ocr (bypasses 10MB body limit). + * Use uploadPdfToStorage + runOcrFromUrl for non-blocking UI (add item after upload, OCR in background). */ import type { OcrPage } from "@/lib/pdf/azure-ocr"; @@ -16,23 +17,58 @@ export interface PdfUploadWithOcrResult { ocrError?: string; } +export interface OcrResult { + textContent: string; + ocrPages: OcrPage[]; + ocrStatus: "complete" | "failed"; + ocrError?: string; +} + +/** + * Upload a PDF to storage only (no OCR). Use with runOcrFromUrl for non-blocking flow. + */ +export async function uploadPdfToStorage( + file: File +): Promise<{ url: string; filename: string; fileSize: number }> { + const { url, filename } = await uploadFileDirect(file); + return { url, filename: filename || file.name, fileSize: file.size }; +} + +/** + * Run OCR on a PDF already in storage. Fire-and-forget; call onComplete when done. + */ +export async function runOcrFromUrl(fileUrl: string): Promise { + const res = await fetch("/api/pdf/ocr", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileUrl }), + }); + const json = await res.json(); + if (!res.ok) { + return { + textContent: "", + ocrPages: [], + ocrStatus: "failed", + ocrError: json.error || "OCR failed", + }; + } + return { + textContent: json.textContent ?? "", + ocrPages: json.ocrPages ?? [], + ocrStatus: "complete", + }; +} + /** * Upload a PDF to storage (Supabase or local) and run OCR. * Uses direct upload to bypass Next.js body size limits. + * Blocking: use uploadPdfToStorage + runOcrFromUrl for non-blocking UI. */ export async function uploadPdfAndRunOcr( file: File ): Promise { - const t0 = performance.now(); - console.info( - `[PDF_UPLOAD_OCR] Start: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)` - ); - - const { url: fileUrl, filename } = await uploadFileDirect(file, { log: true }); - const uploadMs = performance.now() - t0; - console.info(`[PDF_UPLOAD_OCR] Upload complete: ${uploadMs.toFixed(0)}ms`); + const { url: fileUrl, filename } = await uploadFileDirect(file); - const tOcr = performance.now(); const ocrRes = await fetch("/api/pdf/ocr", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -40,17 +76,9 @@ export async function uploadPdfAndRunOcr( }); const ocrJson = await ocrRes.json(); - const ocrMs = performance.now() - tOcr; - const totalMs = performance.now() - t0; - console.info( - `[PDF_UPLOAD_OCR] OCR request: ${ocrMs.toFixed(0)}ms | total: ${totalMs.toFixed(0)}ms` - ); if (!ocrRes.ok) { - console.warn( - `[PDF_UPLOAD_OCR] OCR failed (${totalMs.toFixed(0)}ms):`, - ocrJson.error - ); + console.warn("[PDF_UPLOAD_OCR] OCR failed:", ocrJson.error); return { fileUrl, filename: filename || file.name, @@ -62,9 +90,6 @@ export async function uploadPdfAndRunOcr( }; } - console.info( - `[PDF_UPLOAD_OCR] Done: ${file.name} | ${ocrJson.ocrPages?.length ?? 0} pages | ${totalMs.toFixed(0)}ms total` - ); return { fileUrl, filename: filename || file.name, diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 372875af..0e00271f 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -15,10 +15,6 @@ function formatItemMetadata(item: Item, items: Item[]): string { case "pdf": { const d = item.data as PdfData; if (d?.filename) parts.push(`filename=${d.filename}`); - // OCR indicator: complete only when we have ocrPages (actual OCR ran) - if (d?.ocrStatus === "failed") parts.push("ocr=failed"); - else if (d?.ocrPages?.length) parts.push("ocr=complete"); - else parts.push("ocr=none"); break; } case "flashcard": { @@ -60,7 +56,7 @@ Workspace is empty. Reference items by name when created. ); return ` -Paths and metadata. Use readWorkspace for items with ocr=complete; use processFiles for PDFs with ocr=none. +Paths and metadata. Use readWorkspace to read content. Use processFiles for PDFs that need content extracted. ${entries.join("\n")} `; @@ -112,7 +108,7 @@ Use webSearch when: temporal cues ("today", "latest", "current"), real-time data Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content. If uncertain about accuracy, prefer to search. -PDF CONTENT: For PDFs with ocr=complete (in virtual-workspace metadata), use readWorkspace to get the content — it is already extracted. Use pageStart and pageEnd (1-indexed) to read specific pages only — e.g. pageStart=5, pageEnd=10 for pages 5–10. Use processFiles only for PDFs with ocr=none (not yet extracted) or ocr=failed. +PDF CONTENT: For PDFs with extracted content, use readWorkspace to get the content. Use pageStart and pageEnd (1-indexed) to read specific pages — e.g. pageStart=5, pageEnd=10 for pages 5–10. If readWorkspace indicates content is not yet available, use processFiles instead. PDF IMAGES: When readWorkspace shows image placeholders like ![img-0.jpeg](img-0.jpeg), use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "img-0.jpeg" }] to analyze the image. @@ -140,27 +136,25 @@ NOTE EDITING (updateNote, Cline convention): - Only use emojis if the user explicitly requests them. Avoid adding emojis unless asked. INLINE CITATIONS (optional): -Use inline brackets only — no separate block. Format: [citation:REF] where REF is one of: +Output citation HTML directly: REF where REF is one of: -- Web URL: [citation:https://example.com/article] — for web sources -- Workspace note: [citation:Note Title] — use exact note title from workspace -- Workspace + quote: [citation:Note Title | exact excerpt] — pipe with spaces before quote; only when you have the exact text - -Do NOT use page numbers (e.g. p. 3, page 5) in citations. Use the actual quoted text instead. +- Web URL: https://example.com/article +- Workspace note: Note Title +- Workspace + quote: Note Title | exact excerpt — pipe with spaces; only when you have the exact text +- PDF with page: PDF Title | exact excerpt | p. 5 — for PDFs, ALWAYS include page; use " | p. N" at end (1-indexed) +- PDF with page only: PDF Title | p. 5 Examples: -- [citation:https://en.wikipedia.org/wiki/Supply_chain] -- [citation:My Calculus Notes] -- [citation:My Calculus Notes | The derivative of x^2 is 2x] - -NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt from the source. If unsure, use [citation:Title] without a quote. Never fabricate or paraphrase. +- https://en.wikipedia.org/wiki/Supply_chain +- My Calculus Notes +- My Calculus Notes | The derivative of x^2 is 2x +- Math 240 Textbook | The limit of f(x) as x approaches a is L | p. 42 -When quoting: Use ONLY plain text — no math, code blocks, or special formatting. Use surrounding prose or omit the quote instead. +NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt. If unsure, use Title without a quote. For PDFs, always include the page. -CRITICAL — Punctuation: Put the period or comma BEFORE the citation. The citation always comes after the punctuation. -Correct: "...flow of goods and services." [citation:Source Title | comprehensive administration] -Correct: "demand forecasting." [citation:Source Title] -Wrong: "...flow of goods and services" [citation:Source Title]. (do NOT put the period after the citation) +CRITICAL — Punctuation: Put the period or comma BEFORE the citation. +Correct: "...flow of goods and services." Source Title | comprehensive administration +Wrong: "...flow of goods and services" Source Title. (do NOT put the period after) @@ -613,7 +607,7 @@ function formatNoteDetailsFull(data: NoteData): string[] { */ export function formatOcrPagesAsMarkdown(ocrPages: PdfData["ocrPages"]): string { if (!ocrPages?.length) return ""; - const lines: string[] = [`OCR Pages (${ocrPages.length}):`]; + const lines: string[] = [`Pages (${ocrPages.length}):`]; for (const page of ocrPages) { const pageNum = page.index + 1; lines.push(`--- Page ${pageNum} ---`); @@ -688,11 +682,11 @@ function formatPdfDetailsFull( ); if (pagesToShow.length > 0) { lines.push( - ` - OCR Pages ${pageStart ?? 1}-${pageEnd ?? data.ocrPages.length} (${pagesToShow.length} of ${data.ocrPages.length}):` + ` - Pages ${pageStart ?? 1}-${pageEnd ?? data.ocrPages.length} (${pagesToShow.length} of ${data.ocrPages.length}):` ); } } else { - lines.push(` - OCR Pages (${data.ocrPages.length}):`); + lines.push(` - Pages (${data.ocrPages.length}):`); } for (const page of pagesToShow) { const pageNum = page.index + 1; @@ -708,10 +702,8 @@ function formatPdfDetailsFull( } if (page.footer) lines.push(` Footer: ${page.footer}`); } - } else if (data.textContent) { - lines.push(` - Extracted Content:\n${data.textContent}`); } else { - lines.push(` - (Content not yet extracted — use processFiles or upload the PDF to extract)`); + lines.push(` - (Content not yet extracted — use processFiles to extract)`); } return lines; diff --git a/src/lib/utils/preprocess-latex.ts b/src/lib/utils/preprocess-latex.ts index 1efc7f14..6506af10 100644 --- a/src/lib/utils/preprocess-latex.ts +++ b/src/lib/utils/preprocess-latex.ts @@ -2,7 +2,7 @@ * Preprocesses markdown content to normalize LaTeX delimiters for Streamdown/remark-math. * * Handles: - * 0. Converts SurfSense-style [citation:X] to X (inline-only, no block) + * 0. Citation: model outputs X; URLs get placeholder; [citation:X] fallback * 1. Protects currency values ($19.99, $5, $1,000) from being parsed as math * 2. Converts \(...\) → $...$ and \[...\] → $$...$$ (remark-math doesn't support these) * @@ -20,19 +20,19 @@ export function getCitationUrl(placeholder: string): string | undefined { } /** - * Converts [citation:X] to X. - * For URLs: replaces with placeholder to avoid GFM autolinks; stores URL in _pendingUrlCitations. - * Supports: [citation:https://...], [citation:Title], [citation:Title|quote] + * Handles citation markup. + * - Model outputs X directly for workspace refs. + * - For https://..., replaces URL with placeholder to avoid GFM autolinks. + * - Fallback: [citation:X] → X for legacy or model slip-ups. */ function preprocessCitations(markdown: string): string { if (!markdown) return markdown; _pendingUrlCitations = new Map(); _urlCiteIdx = 0; - // Replace URL citations with placeholders BEFORE markdown parsing - // GFM autolinks would otherwise convert https://... into
, breaking our pattern + // URLs inside : replace with placeholder to avoid GFM autolinks let out = markdown.replace( - /\[citation:\s*(https?:\/\/[^\]\u200B]+)\s*\]/g, + /(https?:\/\/[^<]+)<\/citation>/g, (_, url: string) => { const key = `urlcite${_urlCiteIdx++}`; _pendingUrlCitations.set(key, url.trim()); @@ -40,11 +40,17 @@ function preprocessCitations(markdown: string): string { } ); - // Replace remaining [citation:Title] or [citation:Title|quote] with ... - // Content can be: workspace title (spaces ok), or title|quote + // Fallback: [citation:X] → X (legacy format; URL in brackets also gets placeholder) out = out.replace(/\[citation:\s*([^\]]+)\s*\]/g, (_, content: string) => { const trimmed = content.trim(); - return trimmed ? `${trimmed}` : ""; + if (!trimmed) return ""; + const urlMatch = trimmed.match(/^(https?:\/\/\S+)$/); + if (urlMatch) { + const key = `urlcite${_urlCiteIdx++}`; + _pendingUrlCitations.set(key, urlMatch[1].trim()); + return `${key}`; + } + return `${trimmed}`; }); return out; diff --git a/src/lib/workspace-state/types.ts b/src/lib/workspace-state/types.ts index bddf3982..c8bc4359 100644 --- a/src/lib/workspace-state/types.ts +++ b/src/lib/workspace-state/types.ts @@ -31,7 +31,7 @@ export interface PdfData { filename: string; // original filename fileSize?: number; // optional file size in bytes textContent?: string; // cached extracted text content (avoids reprocessing) - ocrStatus?: "complete" | "failed"; + ocrStatus?: "complete" | "failed" | "processing"; ocrError?: string; ocrPages?: Array<{ index: number; diff --git a/src/workflows/pdf-ocr/index.ts b/src/workflows/pdf-ocr/index.ts new file mode 100644 index 00000000..76fd8825 --- /dev/null +++ b/src/workflows/pdf-ocr/index.ts @@ -0,0 +1,66 @@ +import type { OcrPage } from "@/lib/pdf/azure-ocr"; +import { sleep } from "workflow"; +import { fetchPdf } from "./steps/fetch-pdf"; +import { prepareOcrChunks } from "./steps/prepare-ocr-chunks"; +import { ocrChunk } from "./steps/ocr-chunk"; + +const MAX_CONCURRENT_OCR = 5; +const OCR_TIMEOUT = "5min"; // Per docs: Promise.race with sleep() for timeout + +/** + * Durable workflow for PDF OCR. + * Each fetch, prepare, and OCR batch is a step — retriable and observable. + * Uses timeout pattern per workflow docs (Promise.race with sleep). + * + * @param fileUrl - URL of the PDF file (must be from allowed hosts) + */ +export async function pdfOcrWorkflow(fileUrl: string) { + "use workflow"; + + const runOcr = async (): Promise<{ + textContent: string; + ocrPages: OcrPage[]; + }> => { + const { base64 } = await fetchPdf(fileUrl); + const { chunks } = await prepareOcrChunks(base64); + + if (chunks.length === 0) { + return { textContent: "", ocrPages: [] }; + } + + const allPages: OcrPage[] = []; + let globalIndex = 0; + + for (let i = 0; i < chunks.length; i += MAX_CONCURRENT_OCR) { + const batch = chunks.slice(i, i + MAX_CONCURRENT_OCR); + const batchResults = await Promise.all( + batch.map((chunk) => ocrChunk(chunk.base64, chunk.start, chunk.end)) + ); + + for (const pages of batchResults) { + for (const p of pages) { + allPages.push({ ...p, index: globalIndex }); + globalIndex++; + } + } + } + + const textContent = allPages + .map((p) => p.markdown) + .filter(Boolean) + .join("\n\n"); + + return { textContent, ocrPages: allPages }; + }; + + const result = await Promise.race([ + runOcr(), + sleep(OCR_TIMEOUT).then(() => ({ timedOut: true } as const)), + ]); + + if ("timedOut" in result) { + throw new Error(`PDF OCR timed out after ${OCR_TIMEOUT}`); + } + + return result; +} diff --git a/src/workflows/pdf-ocr/steps/fetch-and-ocr.ts b/src/workflows/pdf-ocr/steps/fetch-and-ocr.ts new file mode 100644 index 00000000..5cabe710 --- /dev/null +++ b/src/workflows/pdf-ocr/steps/fetch-and-ocr.ts @@ -0,0 +1,62 @@ +import { ocrPdfFromBuffer } from "@/lib/pdf/azure-ocr"; +import type { OcrPage } from "@/lib/pdf/azure-ocr"; +import { logger } from "@/lib/utils/logger"; + +const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB + +export interface PdfOcrResult { + textContent: string; + ocrPages: OcrPage[]; +} + +/** + * Step: Fetch PDF from URL and run Azure OCR. + * Durable step — retriable and survives restarts. + */ +export async function fetchAndOcrPdf(fileUrl: string): Promise { + "use step"; + + logger.info("[PDF_OCR_WORKFLOW] Fetch start", { fileUrl }); + + const res = await fetch(fileUrl); + if (!res.ok) { + throw new Error(`Failed to fetch PDF: ${res.status} ${res.statusText}`); + } + + const contentType = res.headers.get("content-type") ?? ""; + if ( + !contentType.includes("application/pdf") && + !fileUrl.toLowerCase().includes(".pdf") + ) { + throw new Error("URL does not point to a PDF file"); + } + + const contentLength = res.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_PDF_SIZE_BYTES) { + throw new Error(`PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`); + } + + const arrayBuffer = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + if (buffer.length > MAX_PDF_SIZE_BYTES) { + throw new Error(`PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`); + } + + logger.info("[PDF_OCR_WORKFLOW] Fetch complete", { + sizeBytes: buffer.length, + sizeMB: (buffer.length / (1024 * 1024)).toFixed(2), + }); + + logger.info("[PDF_OCR_WORKFLOW] OCR start"); + const result = await ocrPdfFromBuffer(buffer); + logger.info("[PDF_OCR_WORKFLOW] OCR complete", { + pageCount: result.pages.length, + textLength: result.textContent.length, + }); + + return { + textContent: result.textContent, + ocrPages: result.pages, + }; +} diff --git a/src/workflows/pdf-ocr/steps/fetch-pdf.ts b/src/workflows/pdf-ocr/steps/fetch-pdf.ts new file mode 100644 index 00000000..b566a8eb --- /dev/null +++ b/src/workflows/pdf-ocr/steps/fetch-pdf.ts @@ -0,0 +1,39 @@ +const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB + +/** + * Step: Fetch PDF from URL. + * Returns base64 for downstream steps (avoids passing Buffer across step boundaries). + */ +export async function fetchPdf(fileUrl: string): Promise<{ base64: string; sizeBytes: number }> { + "use step"; + + const res = await fetch(fileUrl); + if (!res.ok) { + throw new Error(`Failed to fetch PDF: ${res.status} ${res.statusText}`); + } + + const contentType = res.headers.get("content-type") ?? ""; + if ( + !contentType.includes("application/pdf") && + !fileUrl.toLowerCase().includes(".pdf") + ) { + throw new Error("URL does not point to a PDF file"); + } + + const contentLength = res.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_PDF_SIZE_BYTES) { + throw new Error(`PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`); + } + + const arrayBuffer = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + if (buffer.length > MAX_PDF_SIZE_BYTES) { + throw new Error(`PDF exceeds ${MAX_PDF_SIZE_BYTES / (1024 * 1024)}MB limit`); + } + + return { + base64: buffer.toString("base64"), + sizeBytes: buffer.length, + }; +} diff --git a/src/workflows/pdf-ocr/steps/index.ts b/src/workflows/pdf-ocr/steps/index.ts new file mode 100644 index 00000000..594e783a --- /dev/null +++ b/src/workflows/pdf-ocr/steps/index.ts @@ -0,0 +1,4 @@ +export { fetchAndOcrPdf, type PdfOcrResult } from "./fetch-and-ocr"; +export { fetchPdf } from "./fetch-pdf"; +export { prepareOcrChunks } from "./prepare-ocr-chunks"; +export { ocrChunk } from "./ocr-chunk"; diff --git a/src/workflows/pdf-ocr/steps/ocr-chunk.ts b/src/workflows/pdf-ocr/steps/ocr-chunk.ts new file mode 100644 index 00000000..63966918 --- /dev/null +++ b/src/workflows/pdf-ocr/steps/ocr-chunk.ts @@ -0,0 +1,16 @@ +import { ocrSingleChunk } from "@/lib/pdf/azure-ocr"; +import type { OcrPage } from "@/lib/pdf/azure-ocr"; + +/** + * Step: Run Azure OCR on a single PDF chunk. + * Each chunk is a durable step — retriable independently. + */ +export async function ocrChunk( + base64: string, + start: number, + end: number +): Promise { + "use step"; + + return ocrSingleChunk(base64); +} diff --git a/src/workflows/pdf-ocr/steps/prepare-ocr-chunks.ts b/src/workflows/pdf-ocr/steps/prepare-ocr-chunks.ts new file mode 100644 index 00000000..d7e228ee --- /dev/null +++ b/src/workflows/pdf-ocr/steps/prepare-ocr-chunks.ts @@ -0,0 +1,18 @@ +import { + prepareOcrChunks as prepareOcrChunksLib, + type OcrChunkSpec, +} from "@/lib/pdf/azure-ocr"; + +/** + * Step: Load PDF, compute chunk ranges, extract each chunk to base64. + * Returns chunk specs for OCR steps (each chunk = one durable step). + */ +export async function prepareOcrChunks(base64: string): Promise<{ + pageCount: number; + chunks: OcrChunkSpec[]; +}> { + "use step"; + + const buffer = Buffer.from(base64, "base64"); + return prepareOcrChunksLib(buffer); +} From 08e7d4f3099e9865c6f4be2c322ac25dafcd455d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:35:49 -0500 Subject: [PATCH 21/56] fix: pdf images --- src/lib/pdf/azure-ocr.ts | 40 ++++++++++++++++++++++++++++++---- src/workflows/pdf-ocr/index.ts | 3 ++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/lib/pdf/azure-ocr.ts b/src/lib/pdf/azure-ocr.ts index af2ec395..d8c798d1 100644 --- a/src/lib/pdf/azure-ocr.ts +++ b/src/lib/pdf/azure-ocr.ts @@ -35,6 +35,41 @@ export interface OcrPage { tables?: unknown[]; } +/** + * Rewrite image IDs to be globally unique across merged chunks. + * Azure returns chunk-relative IDs (e.g. img-0 per chunk), so without this, + * page 0 and page 5 could both have img-0.jpeg → wrong image when resolving refs. + */ +export function rewriteOcrPageImageIds( + page: OcrPage, + globalPageIndex: number +): OcrPage { + const images = page.images as Array<{ id?: string; [key: string]: unknown }> | undefined; + if (!images?.length) + return { ...page, index: globalPageIndex }; + + const idMap: Record = {}; + const newImages = images.map((img, i) => { + const oldId = (img.id ?? `img-${i}`).toString(); + const newId = `p${globalPageIndex}-${oldId}`; + idMap[oldId] = newId; + return { ...img, id: newId }; + }); + + let markdown = page.markdown ?? ""; + for (const [oldId, newId] of Object.entries(idMap)) { + const escaped = oldId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + markdown = markdown.replace(new RegExp(escaped, "g"), newId); + } + + return { + ...page, + index: globalPageIndex, + images: newImages, + markdown, + }; +} + export interface OcrResult { pages: OcrPage[]; textContent: string; @@ -281,10 +316,7 @@ export async function ocrPdfFromBuffer( let globalIndex = 0; for (const { pages } of chunkResults) { for (const p of pages) { - allPages.push({ - ...p, - index: globalIndex, - }); + allPages.push(rewriteOcrPageImageIds(p, globalIndex)); globalIndex++; } } diff --git a/src/workflows/pdf-ocr/index.ts b/src/workflows/pdf-ocr/index.ts index 76fd8825..899f686a 100644 --- a/src/workflows/pdf-ocr/index.ts +++ b/src/workflows/pdf-ocr/index.ts @@ -1,4 +1,5 @@ import type { OcrPage } from "@/lib/pdf/azure-ocr"; +import { rewriteOcrPageImageIds } from "@/lib/pdf/azure-ocr"; import { sleep } from "workflow"; import { fetchPdf } from "./steps/fetch-pdf"; import { prepareOcrChunks } from "./steps/prepare-ocr-chunks"; @@ -39,7 +40,7 @@ export async function pdfOcrWorkflow(fileUrl: string) { for (const pages of batchResults) { for (const p of pages) { - allPages.push({ ...p, index: globalIndex }); + allPages.push(rewriteOcrPageImageIds(p, globalIndex)); globalIndex++; } } From 26d651b3797ca2d98b773fba7a25195ff5c1391f Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:36:31 -0500 Subject: [PATCH 22/56] fix: image instructions --- src/lib/ai/tools/process-files.ts | 4 ++-- src/lib/utils/format-workspace-context.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts index e650fd2d..30a8e432 100644 --- a/src/lib/ai/tools/process-files.ts +++ b/src/lib/ai/tools/process-files.ts @@ -173,7 +173,7 @@ async function runOcrForPdfUrl(fileUrl: string): Promise<{ type PdfImageRef = { pdfName: string; imageId: string }; /** - * Process images extracted from PDF OCR (resolve placeholder refs like img-0.jpeg to base64). + * Process images extracted from PDF OCR (resolve placeholder refs like img-0.jpeg or p5-img-0.jpeg to base64). */ async function processPdfImages( pdfImageRefs: PdfImageRef[], @@ -212,7 +212,7 @@ async function processPdfImages( } if (fileInfos.length === 0) { - return "No PDF images could be found. Ensure the PDF was OCR'd with images (OCR_INCLUDE_IMAGES not false) and the imageId matches the placeholder (e.g. img-0.jpeg)."; + return "No PDF images could be found. Ensure the PDF was OCR'd with images (OCR_INCLUDE_IMAGES not false) and the imageId matches the placeholder (e.g. img-0.jpeg or p5-img-0.jpeg)."; } const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename} (from PDF)`).join("\n"); diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 0e00271f..c5f8da22 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -110,7 +110,7 @@ If uncertain about accuracy, prefer to search. PDF CONTENT: For PDFs with extracted content, use readWorkspace to get the content. Use pageStart and pageEnd (1-indexed) to read specific pages — e.g. pageStart=5, pageEnd=10 for pages 5–10. If readWorkspace indicates content is not yet available, use processFiles instead. -PDF IMAGES: When readWorkspace shows image placeholders like ![img-0.jpeg](img-0.jpeg), use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "img-0.jpeg" }] to analyze the image. +PDF IMAGES: When readWorkspace shows image placeholders like ![img-0.jpeg](img-0.jpeg) or ![p5-img-0.jpeg](p5-img-0.jpeg) (page-prefixed for multi-chunk PDFs), use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "" }] to analyze the image. YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search. From 4b8709a0aa12f804b623f57e77e5fa9afdbe3196 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:42:47 -0500 Subject: [PATCH 23/56] fix: dialog click events --- src/components/ui/dialog.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 3d3a27cd..d495605e 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -72,6 +72,8 @@ function DialogContent({ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[70] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", className )} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} {...props} > {children} From b8102d429f9560ee2fb1a9b7873e008961b88129 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:47:40 -0500 Subject: [PATCH 24/56] fix: system prompt --- src/lib/utils/format-workspace-context.ts | 25 ++++++++--------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index c5f8da22..98810296 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -102,15 +102,14 @@ CORE BEHAVIORS: - If uncertain, say so rather than guessing - For complex tasks, think step-by-step - You are allowed to complete homework or assignments for the user if they ask +- Only use emojis if the user explicitly requests them WEB SEARCH GUIDELINES: Use webSearch when: temporal cues ("today", "latest", "current"), real-time data (scores, stocks, weather), fact verification, niche/recent info. Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content. If uncertain about accuracy, prefer to search. -PDF CONTENT: For PDFs with extracted content, use readWorkspace to get the content. Use pageStart and pageEnd (1-indexed) to read specific pages — e.g. pageStart=5, pageEnd=10 for pages 5–10. If readWorkspace indicates content is not yet available, use processFiles instead. - -PDF IMAGES: When readWorkspace shows image placeholders like ![img-0.jpeg](img-0.jpeg) or ![p5-img-0.jpeg](p5-img-0.jpeg) (page-prefixed for multi-chunk PDFs), use processFiles with pdfImageRefs: [{ pdfName: "", imageId: "" }] to analyze the image. +PDF: Use readWorkspace for PDFs with content (pageStart/pageEnd for page ranges). If not yet available, use processFiles. For image placeholders in readWorkspace output, use processFiles with pdfImageRefs. YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search. @@ -125,18 +124,12 @@ Rules: - Never make up or hallucinate URLs - Include article dates in responses when available -NOTE EDITING (updateNote, Cline convention): -- You MUST use readWorkspace at least once before a targeted edit. The tool will error if you edit without reading. -- Full rewrite: oldString="", newString=entire note content. -- Targeted edit: readWorkspace first, then oldString=exact text to find (from the Content section only), newString=replacement. Extract oldString from the note body — never include the wrapper or " - Content:" prefix. -- When editing from readWorkspace output, preserve exact indentation and whitespace. Match the text as it appears in the Content section. -- The edit will FAIL if oldString is not found with "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings." -- The edit will FAIL if oldString matches multiple times with "Found multiple matches for oldString. Provide more surrounding context to make the match unique." Use more surrounding lines in oldString or use replaceAll to change every instance. -- Use replaceAll for replacing/renaming across the entire note (e.g., rename a term). -- Only use emojis if the user explicitly requests them. Avoid adding emojis unless asked. +NOTE EDITING: See updateNote and readWorkspace tool descriptions for oldString/newString conventions, read-before-write requirement, and exact-match rules. INLINE CITATIONS (optional): -Output citation HTML directly: REF where REF is one of: +Only in your chat response — never in item content (notes, flashcards, quizzes, etc.). Use sources param for tools; do not put tags in content passed to createNote, updateNote, addFlashcards, etc. +Use simple plain text only. Bare minimum for uniqueness. No math, LaTeX, or complex formatting inside citations. +Output citation HTML: REF where REF is one of: - Web URL: https://example.com/article - Workspace note: Note Title @@ -144,11 +137,11 @@ Output citation HTML directly: REF where REF is one of: - PDF with page: PDF Title | exact excerpt | p. 5 — for PDFs, ALWAYS include page; use " | p. N" at end (1-indexed) - PDF with page only: PDF Title | p. 5 -Examples: +Examples (plain text only): - https://en.wikipedia.org/wiki/Supply_chain - My Calculus Notes -- My Calculus Notes | The derivative of x^2 is 2x -- Math 240 Textbook | The limit of f(x) as x approaches a is L | p. 42 +- My Calculus Notes | the derivative rule for power functions +- Math 240 Textbook | limit definition | p. 42 NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt. If unsure, use Title without a quote. For PDFs, always include the page. From a5741f402ca9002708cf274741962aa5ea56ad1a Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:58:10 -0500 Subject: [PATCH 25/56] Update fetch-pdf.ts --- src/workflows/pdf-ocr/steps/fetch-pdf.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/workflows/pdf-ocr/steps/fetch-pdf.ts b/src/workflows/pdf-ocr/steps/fetch-pdf.ts index b566a8eb..ff47beab 100644 --- a/src/workflows/pdf-ocr/steps/fetch-pdf.ts +++ b/src/workflows/pdf-ocr/steps/fetch-pdf.ts @@ -1,3 +1,5 @@ +import { fetch } from "workflow"; + const MAX_PDF_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB /** From a84da2a31864ce83c1672cb98342c8a8c5ef658d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:27:12 -0500 Subject: [PATCH 26/56] fix: ui --- src/components/assistant-ui/attachment.tsx | 4 ++-- src/components/assistant-ui/thread.tsx | 10 +++++----- src/components/chat/CardContextDisplay.tsx | 2 +- src/components/chat/ReplyContextDisplay.tsx | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index 08322cd4..604afd03 100644 --- a/src/components/assistant-ui/attachment.tsx +++ b/src/components/assistant-ui/attachment.tsx @@ -442,7 +442,7 @@ export const UserMessageAttachments: FC = () => { export const ComposerAttachments: FC = () => { return ( -
+
@@ -542,7 +542,7 @@ export const ComposerAddAttachment: FC = () => {
+ + {/* Expand/Collapse Button */} + {showExpandButton && ( + )} - +
); } From 8315a1391c979127ac07717a4c3786f06ff24edf Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:43:46 -0500 Subject: [PATCH 28/56] fix: transcribe model Co-authored-by: Cursor --- src/workflows/audio-transcribe/steps/transcribe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workflows/audio-transcribe/steps/transcribe.ts b/src/workflows/audio-transcribe/steps/transcribe.ts index c1766d3c..f4afbb8d 100644 --- a/src/workflows/audio-transcribe/steps/transcribe.ts +++ b/src/workflows/audio-transcribe/steps/transcribe.ts @@ -36,7 +36,7 @@ Requirements: 4. Provide the total duration of the audio in seconds (a single number, e.g. 180.5 for 3 minutes).`; const response = await client.models.generateContent({ - model: "gemini-2.5-flash-lite", + model: "gemini-2.5-flash", contents: createUserContent([ createPartFromUri(fileUri, mimeType), prompt, From 56689b755897f29c2eefb4dff33dbeef513d6ea1 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:56:17 -0500 Subject: [PATCH 29/56] fix: model and refetch after update pdf content --- src/app/api/chat/route.ts | 2 +- .../assistant-ui/FileProcessingToolUI.tsx | 20 +++++++++++++++++++ src/lib/stores/ui-store.ts | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 7d920fb3..3c8a2859 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -220,7 +220,7 @@ export async function POST(req: Request) { const selectedCardsContext = getSelectedCardsContext(body); // Get model ID and ensure it has the correct prefix for Gateway - let modelId = body.modelId || "gemini-3-flash-preview"; + let modelId = body.modelId || "gemini-2.5-flash"; // Auto-prefix with google/ if it looks like a gemini model and lacks prefix // This allows existing client code to work without changes diff --git a/src/components/assistant-ui/FileProcessingToolUI.tsx b/src/components/assistant-ui/FileProcessingToolUI.tsx index f8bd7880..e122e5f3 100644 --- a/src/components/assistant-ui/FileProcessingToolUI.tsx +++ b/src/components/assistant-ui/FileProcessingToolUI.tsx @@ -4,6 +4,7 @@ import { FileIcon, ChevronDownIcon, CheckIcon, ExternalLinkIcon, AlertCircleIcon import { memo, useCallback, + useEffect, useRef, useState, type FC, @@ -15,6 +16,8 @@ import { makeAssistantToolUI, } from "@assistant-ui/react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { StandaloneMarkdown } from "@/components/assistant-ui/standalone-markdown"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { parseStringResult } from "@/lib/ai/tool-result-schemas"; @@ -227,6 +230,23 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ }, string>({ toolName: "processFiles", render: function FileProcessingToolUI({ args, status, result }) { + const queryClient = useQueryClient(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + + // When processFiles completes with workspace file names, OCR may have been persisted + // server-side — invalidate events so the client refetches and shows updated PDF content + useEffect(() => { + if ( + status.type === "complete" && + workspaceId && + (args?.fileNames?.length ?? 0) > 0 + ) { + queryClient.invalidateQueries({ + queryKey: ["workspace", workspaceId, "events"], + }); + } + }, [status.type, workspaceId, args?.fileNames?.length, queryClient]); + // Client-side debugging if (typeof window !== 'undefined') { console.debug("📁 [FILE_TOOL_UI] Component render:", { diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index 7873dcd4..0fd4d226 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -162,7 +162,7 @@ const initialState = { searchQuery: '', activeFolderId: null, - selectedModelId: 'gemini-3-flash-preview', + selectedModelId: 'gemini-2.5-flash', // Text selection inMultiSelectMode: false, From da0295eb2d9bcfafb7654e852549a239d66aeddb Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:57:17 -0500 Subject: [PATCH 30/56] fix: types --- src/hooks/workspace/use-workspace-mutation.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/hooks/workspace/use-workspace-mutation.ts b/src/hooks/workspace/use-workspace-mutation.ts index 02ab4131..1254afcf 100644 --- a/src/hooks/workspace/use-workspace-mutation.ts +++ b/src/hooks/workspace/use-workspace-mutation.ts @@ -204,12 +204,16 @@ export function useWorkspaceMutation(workspaceId: string | null, options: Worksp newVersion: data.version, eventId: event.id, }); - if (event.type === "ITEM_UPDATED" && event.payload?.changes?.data?.ocrStatus) { - console.log("[OCR/UPDATE] ITEM_UPDATED persisted:", { - itemId: event.payload.id, - ocrStatus: event.payload.changes.data.ocrStatus, - version: data.version, - }); + if (event.type === "ITEM_UPDATED") { + const payload = event.payload; + const changesData = payload?.changes?.data; + if (changesData && "ocrStatus" in changesData && changesData.ocrStatus) { + console.log("[OCR/UPDATE] ITEM_UPDATED persisted:", { + itemId: payload.id, + ocrStatus: changesData.ocrStatus, + version: data.version, + }); + } } // Handle conflicts with automatic retry From e5e4b60e26aa4dd71efb3fd07d0b185efad9a7b8 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:16:07 -0500 Subject: [PATCH 31/56] fix: citations --- src/components/assistant-ui/markdown-text.tsx | 55 ++++-------- src/components/assistant-ui/reasoning.tsx | 22 ++++- src/components/pdf/AppPdfViewer.tsx | 87 ++++++++++++------- .../workspace-canvas/ItemPanelContent.tsx | 9 ++ src/lib/utils/format-workspace-context.ts | 6 +- 5 files changed, 109 insertions(+), 70 deletions(-) diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index 96953428..ae902665 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -19,15 +19,8 @@ import type { ClipboardEvent as ReactClipboardEvent, HTMLAttributes, } from "react"; -import { - InlineCitation, - InlineCitationCard, - InlineCitationCardTrigger, - InlineCitationCardBody, - InlineCitationSource, - InlineCitationQuote, - UrlCitation, -} from "@/components/ai-elements/inline-citation"; +import { InlineCitation, UrlCitation } from "@/components/ai-elements/inline-citation"; +import { Badge } from "@/components/ui/badge"; import { MarkdownLink } from "@/components/ui/markdown-link"; import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; @@ -131,37 +124,27 @@ const CitationRenderer = memo( setOpenModalItemId(item.id); }; - const hasWorkspaceItem = workspaceState?.items?.some( - (i) => - (i.type === "note" || i.type === "pdf") && - titleNorm(i.name) === titleNorm(title) - ); + const badgeLabel = + title.slice(0, 20) + (title.length > 20 ? "…" : "") + + (pageNumber != null ? ` · p.${pageNumber}` : ""); return ( - - 20 ? "…" : "") + - (pageNumber != null ? ` · p.${pageNumber}` : "") + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleWorkspaceItemClick(); } - /> - - - {quote && {quote}} - {pageNumber != null && ( - - Page {pageNumber} - - )} - - - + }} + > + {badgeLabel} + ); } diff --git a/src/components/assistant-ui/reasoning.tsx b/src/components/assistant-ui/reasoning.tsx index cf2d6dee..839773fb 100644 --- a/src/components/assistant-ui/reasoning.tsx +++ b/src/components/assistant-ui/reasoning.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useCallback, useRef, useState } from "react"; +import { memo, useCallback, useRef, useState, useEffect } from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { BrainIcon, ChevronDownIcon } from "lucide-react"; import { @@ -233,8 +233,26 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ return lastIndex >= startIndex && lastIndex <= endIndex; }); + const [isManuallyOpen, setIsManuallyOpen] = useState(false); + const isOpen = isReasoningStreaming || isManuallyOpen; + + // Auto-collapse when streaming finishes + useEffect(() => { + if (!isReasoningStreaming) { + setIsManuallyOpen(false); + } + }, [isReasoningStreaming]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (isReasoningStreaming && !open) return; + setIsManuallyOpen(open); + }, + [isReasoningStreaming], + ); + return ( - + {children} diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx index ffda59d5..ac4109c4 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -52,8 +52,9 @@ const restoreCompleted = new Set(); /** * Persists and restores PDF scroll position and zoom level to localStorage. + * Skips scroll restore when a citation is opening this PDF (citation takes priority). */ -const PdfStatePersister = ({ documentId, pdfSrc }: { documentId: string; pdfSrc: string }) => { +const PdfStatePersister = ({ documentId, pdfSrc, itemId }: { documentId: string; pdfSrc: string; itemId?: string }) => { const { provides: viewportCapability } = useViewportCapability(); const { state: zoomState, provides: zoomScope } = useZoom(documentId); const debounceRef = useRef(undefined); @@ -112,8 +113,9 @@ const PdfStatePersister = ({ documentId, pdfSrc }: { documentId: string; pdfSrc: try { const saved = localStorage.getItem(storageKey); + const citationForThisItem = itemId && useUIStore.getState().citationHighlightQuery?.itemId === itemId; - if (saved) { + if (saved && !citationForThisItem) { const state: PdfSavedState = JSON.parse(saved); // Delay restoration to ensure it runs AFTER the plugin's default zoom is applied @@ -137,7 +139,7 @@ const PdfStatePersister = ({ documentId, pdfSrc }: { documentId: string; pdfSrc: }, 200); }, 300); // Wait for default zoom to be applied first } else { - // No saved state, enable saving immediately + // No saved state, or citation is opening this PDF (citation scroll takes priority) restoreCompleted.add(restoreKey); } } catch (e) { @@ -149,7 +151,7 @@ const PdfStatePersister = ({ documentId, pdfSrc }: { documentId: string; pdfSrc: return () => { saveCurrentState(); }; - }, [viewportCapability, zoomScope, documentId, pdfSrc, storageKey, restoreKey, saveCurrentState]); + }, [viewportCapability, zoomScope, documentId, pdfSrc, storageKey, restoreKey, saveCurrentState, itemId]); // Save on zoom changes (debounced) useEffect(() => { @@ -211,6 +213,8 @@ interface Props { itemName?: string; /** Workspace item ID - for citation highlight sync */ itemId?: string; + /** Scroll to this page when document loads (e.g. from citation). Uses EmbedPDF onLayoutReady. */ + initialPage?: number; isMaximized?: boolean; /** Initial visibility of the annotation toolbar */ initialShowAnnotations?: boolean; @@ -695,7 +699,39 @@ const PdfSearchBar = ({ documentId }: { documentId: string }) => { const PDF_HIGHLIGHT_DURATION_MS = 2500; -/** Syncs citation highlight query from store: search PDF and scroll to first match, or scroll to page if no results */ +/** Scrolls to initialPage when layout is ready. Uses EmbedPDF's onLayoutReady (EventHook API). */ +const PdfInitialPageScroll = ({ + documentId, + initialPage, + onScrolled, +}: { + documentId: string; + initialPage?: number; + onScrolled?: () => void; +}) => { + const { provides: scrollCapability } = useScrollCapability(); + + useEffect(() => { + if (!scrollCapability || initialPage == null || initialPage < 1) return; + + const unsub = scrollCapability.onLayoutReady((ev) => { + if (ev.documentId !== documentId || !ev.isInitial || ev.totalPages < 1) return; + + const scrollScope = scrollCapability.forDocument(documentId); + if (!scrollScope) return; + + const page = Math.min(Math.max(1, initialPage), ev.totalPages); + scrollScope.scrollToPage({ pageNumber: page }); + onScrolled?.(); + }); + + return unsub; + }, [scrollCapability, documentId, initialPage, onScrolled]); + + return null; +}; + +/** Syncs citation search query from store: runs text search, scrolls to first match or page fallback. */ const PdfCitationHighlightSync = ({ documentId, itemId }: { documentId: string; itemId?: string }) => { const { state, provides } = useSearch(documentId); const { provides: scrollCapability } = useScrollCapability(); @@ -719,41 +755,29 @@ const PdfCitationHighlightSync = ({ documentId, itemId }: { documentId: string; const applyCitation = (hl: { itemId: string; query: string; pageNumber?: number }) => { const hasQuery = !!hl.query?.trim(); - const hasPage = hl.pageNumber != null && hl.pageNumber >= 1; - - if (hasQuery) { - triggeredRef.current = hl.query.trim(); - pageFallbackRef.current = hasPage ? hl.pageNumber! : null; - provides?.searchAllPages(hl.query.trim()); - } else if (hasPage && scroll && hl.pageNumber != null) { - // No query: scroll directly to page - const totalPages = scrollState?.totalPages ?? 1; - const page = Math.min(Math.max(1, hl.pageNumber), totalPages); - scroll.scrollToPage({ pageNumber: page }); - pageFallbackRef.current = null; - } + if (!hasQuery) return; // Page-only handled by PdfInitialPageScroll + triggeredRef.current = hl.query.trim(); + pageFallbackRef.current = hl.pageNumber ?? null; + provides?.searchAllPages(hl.query.trim()); if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(clearCitation, PDF_HIGHLIGHT_DURATION_MS); }; const unsub = useUIStore.subscribe((storeState) => { const hl = storeState.citationHighlightQuery; - if (!hl || hl.itemId !== itemId) return; - if (!hl.query?.trim() && !(hl.pageNumber != null && hl.pageNumber >= 1)) return; + if (!hl || hl.itemId !== itemId || !hl.query?.trim()) return; applyCitation(hl); }); const hl = useUIStore.getState().citationHighlightQuery; - if (hl?.itemId === itemId && (hl.query?.trim() || (hl.pageNumber != null && hl.pageNumber >= 1))) { - applyCitation(hl); - } + if (hl?.itemId === itemId && hl.query?.trim()) applyCitation(hl); return () => { unsub(); clearCitation(); }; - }, [documentId, itemId, provides, scroll, scrollState?.totalPages]); + }, [documentId, itemId, provides]); // Scroll to first result when search completes, or fallback to page when no results useEffect(() => { @@ -794,7 +818,7 @@ const PdfCitationHighlightSync = ({ documentId, itemId }: { documentId: string; return null; }; -const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName, itemId, isMaximized, initialShowAnnotations = false }: Props) => { +const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName, itemId, initialPage, isMaximized, initialShowAnnotations = false }: Props) => { // Use the shared Pdfium engine from context const { engine, isLoading } = useEngineContext(); @@ -933,7 +957,7 @@ const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName, {isLoaded && (
{/* PDF State Persistence */} - + {/* Show page controls on viewport scroll activity */} @@ -962,9 +986,14 @@ const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName, {/* Viewport */}
- {itemId && ( - - )} + useUIStore.getState().setCitationHighlightQuery(null)} + /> + {itemId && ( + + )} state.isChatExpanded); const setIsChatExpanded = useUIStore((state) => state.setIsChatExpanded); + const citationHighlightQuery = useUIStore((state) => state.citationHighlightQuery); const isDesktop = true; @@ -164,6 +165,14 @@ export function ItemPanelContent({ showThumbnails={showThumbnails} itemName={item.name} itemId={item.id} + initialPage={ + citationHighlightQuery?.itemId === item.id && + citationHighlightQuery?.pageNumber != null && + citationHighlightQuery.pageNumber >= 1 && + !citationHighlightQuery?.query?.trim() + ? citationHighlightQuery.pageNumber + : undefined + } isMaximized={isMaximized} renderHeader={(documentId, annotationControls) => (
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 98810296..a292464d 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -56,7 +56,7 @@ Workspace is empty. Reference items by name when created. ); return ` -Paths and metadata. Use readWorkspace to read content. Use processFiles for PDFs that need content extracted. +Paths and metadata. Use readWorkspace to read content. Call processFiles for PDFs that need content extracted (you call it — do not ask the user). ${entries.join("\n")} `; @@ -109,7 +109,7 @@ Use webSearch when: temporal cues ("today", "latest", "current"), real-time data Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content. If uncertain about accuracy, prefer to search. -PDF: Use readWorkspace for PDFs with content (pageStart/pageEnd for page ranges). If not yet available, use processFiles. For image placeholders in readWorkspace output, use processFiles with pdfImageRefs. +PDF: Use readWorkspace for PDFs with content (pageStart/pageEnd for page ranges). If content is not yet extracted, you must call processFiles — do not ask the user. For image placeholders in readWorkspace output, call processFiles with pdfImageRefs. YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search. @@ -696,7 +696,7 @@ function formatPdfDetailsFull( if (page.footer) lines.push(` Footer: ${page.footer}`); } } else { - lines.push(` - (Content not yet extracted — use processFiles to extract)`); + lines.push(` - (Content not yet extracted. You must call the processFiles tool to extract it — do not ask the user to do this.)`); } return lines; From 7c3942fd6b8b5e1493b1b8004c7e3df64dc47e1d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:43:12 -0500 Subject: [PATCH 32/56] fix: citations --- src/components/assistant-ui/markdown-text.tsx | 3 +- src/hooks/ui/use-navigate-to-item.ts | 135 +++++++----------- 2 files changed, 57 insertions(+), 81 deletions(-) diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index ae902665..62349218 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -101,7 +101,8 @@ const CitationRenderer = memo( const { state: workspaceState } = useWorkspaceState(workspaceId); const navigateToItem = useNavigateToItem(); const setOpenModalItemId = useUIStore((s) => s.setOpenModalItemId); - const titleNorm = (s: string) => s.trim().toLowerCase(); + // 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 = () => { diff --git a/src/hooks/ui/use-navigate-to-item.ts b/src/hooks/ui/use-navigate-to-item.ts index 698b04c9..044992a5 100644 --- a/src/hooks/ui/use-navigate-to-item.ts +++ b/src/hooks/ui/use-navigate-to-item.ts @@ -5,19 +5,11 @@ import { useUIStore } from "@/lib/stores/ui-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { toast } from "sonner"; -import { getFolderPath } from "@/lib/workspace-state/search"; + +let activeCleanup: (() => void) | null = null; /** * Hook that provides a function to navigate to and highlight an item in the workspace. - * This replicates the behavior of clicking an item in the workspace sidebar. - * - * The function will: - * 1. If the item is in a folder, set that folder as active. - * 2. If the item is NOT in a folder, clear the active folder. - * 3. Scroll to the item's card in the workspace grid. - * 4. Add a temporary highlight border to the card. - * - * Returns false if the item doesn't exist, true otherwise. */ export function useNavigateToItem() { const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); @@ -30,112 +22,97 @@ export function useNavigateToItem() { if (!options?.silent) toast.error("Workspace not loaded"); return false; } - const item = workspaceState.items.find((i) => i.id === itemId); if (!item) { if (!options?.silent) toast.error("Item no longer exists"); return false; } - // If item is in a folder, set the folder as active so it's visible in the main view (reveal only) - if (item.folderId) { - setActiveFolderId(item.folderId); - } else { - setActiveFolderId(null); + if (activeCleanup) { + activeCleanup(); + activeCleanup = null; } - // Small delay to let the DOM update (folder filter and scroll) + if (item.folderId) setActiveFolderId(item.folderId); + else setActiveFolderId(null); + setTimeout(() => { const element = document.getElementById(`item-${itemId}`); - if (!element) { - // Element not found after folder switch - likely doesn't exist in DOM - // The scroll logic won't work, so we should stop here - return; - } + if (!element) return; - // Find the scrollable container - let container = element.parentElement; + let container: Element | null = element.parentElement; while (container && container !== document.body) { - const style = window.getComputedStyle(container); - if (style.overflowY === "auto" || style.overflowY === "scroll") { - break; - } + const s = window.getComputedStyle(container); + if (s.overflowY === "auto" || s.overflowY === "scroll") break; container = container.parentElement; } - // Function to add temporary highlight after scroll ends - const addHighlight = () => { - // Store original border styles - const originalBorder = element.style.border; - const originalBorderColor = element.style.borderColor; - const originalBorderWidth = element.style.borderWidth; - const originalBorderRadius = element.style.borderRadius; - - // Add highlight border with smooth animation (white, like selection) - element.style.transition = - "border-color 0.3s ease-out, border-width 0.2s ease-out"; - element.style.borderColor = "rgba(255, 255, 255, 0.8)"; - element.style.borderWidth = "3px"; - element.style.borderRadius = "0.375rem"; // rounded-md (6px) + let done = false; + const clear = () => { + if (done) return; + done = true; + try { + element.style.outline = ""; + element.style.outlineOffset = ""; + element.style.transition = ""; + } catch {} + activeCleanup = null; + }; + activeCleanup = clear; - // Remove highlight after 1 second with fade out + const addHighlight = () => { + if (done) return; + try { + element.style.transition = "outline-color 0.3s ease-out"; + element.style.outline = "3px solid rgba(255, 255, 255, 0.8)"; + element.style.outlineOffset = "2px"; + } catch { + return; + } setTimeout(() => { - element.style.borderColor = "rgba(255, 255, 255, 0)"; - // Restore original styles after transition completes - setTimeout(() => { - element.style.border = originalBorder; - element.style.borderColor = originalBorderColor; - element.style.borderWidth = originalBorderWidth; - element.style.borderRadius = originalBorderRadius; - element.style.transition = ""; - }, 300); + if (done) return; + try { + element.style.outlineColor = "rgba(255, 255, 255, 0)"; + } catch {} + setTimeout(clear, 300); }, 1000); }; - let highlightTriggered = false; - - const triggerHighlight = () => { - if (highlightTriggered) return; - highlightTriggered = true; + let triggered = false; + const trigger = () => { + if (triggered) return; + triggered = true; addHighlight(); }; - // Use IntersectionObserver to detect when element is visible const observer = new IntersectionObserver( (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && entry.intersectionRatio >= 0.5) { - triggerHighlight(); + for (const e of entries) { + if (e.isIntersecting && e.intersectionRatio >= 0.5) { + trigger(); observer.disconnect(); + break; } - }); + } }, - { - root: container !== document.body ? container : null, - threshold: 0.5, // Trigger when 50% visible - } + { root: container !== document.body ? container : null, threshold: 0.5 } ); - - // Start observing observer.observe(element); - - // Fallback timeout in case observer doesn't fire (edge cases) setTimeout(() => { - if (!highlightTriggered) { - triggerHighlight(); + if (!triggered) { + trigger(); observer.disconnect(); } }, 1000); if (container && container !== document.body) { - const elementRect = element.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const relativeTop = elementRect.top - containerRect.top; - + const rect = element.getBoundingClientRect(); + const box = container.getBoundingClientRect(); container.scrollTo({ top: container.scrollTop + - relativeTop - + rect.top - + box.top - container.clientHeight / 2 + element.clientHeight / 2, behavior: "smooth", @@ -143,12 +120,10 @@ export function useNavigateToItem() { } else { element.scrollIntoView({ behavior: "smooth", block: "center" }); } - }, 50); // Small delay to let the DOM update (folder filter and scroll) - + }, 50); return true; }, [workspaceState?.items, setActiveFolderId] ); - return navigateToItem; } From f7934e4779406570ca3358106a059b829d76cce4 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:48:33 -0500 Subject: [PATCH 33/56] fix: truncate file output --- src/components/assistant-ui/FileProcessingToolUI.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/assistant-ui/FileProcessingToolUI.tsx b/src/components/assistant-ui/FileProcessingToolUI.tsx index e122e5f3..3ba9a76c 100644 --- a/src/components/assistant-ui/FileProcessingToolUI.tsx +++ b/src/components/assistant-ui/FileProcessingToolUI.tsx @@ -32,6 +32,7 @@ import { Badge } from "@/components/ui/badge"; const ANIMATION_DURATION = 200; const SHIMMER_DURATION = 1000; +const MAX_DISPLAY_CHARS = 3000; /** * Root collapsible container that manages open/closed state and scroll lock. @@ -379,7 +380,11 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ {isComplete && parsedResult != null && parsedResult.trim() && (
- {parsedResult} + + {parsedResult.length > MAX_DISPLAY_CHARS + ? `${parsedResult.slice(0, MAX_DISPLAY_CHARS)}\n\n*…truncated for display*` + : parsedResult} +
)} From d2a39dce09c12bad197b268818add7e66202a55b Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:07:03 -0500 Subject: [PATCH 34/56] feat: nice anim --- src/app/globals.css | 17 +++++++++++++++++ src/components/assistant-ui/reasoning.tsx | 6 +----- src/components/assistant-ui/thread.tsx | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 8e9bc650..e1d6f83e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1536,4 +1536,21 @@ body:has(.card-detail-modal) .workspace-grid-container { to { opacity: 1; } +} + +/* User message breathe-in animation */ +@keyframes breatheIn { + from { + opacity: 0; + transform: scale(0.7); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.animate-breathe-in { + transform-origin: right center; + animation: breatheIn 0.7s cubic-bezier(0.16, 1.1, 0.3, 1.02) forwards; } \ No newline at end of file diff --git a/src/components/assistant-ui/reasoning.tsx b/src/components/assistant-ui/reasoning.tsx index 839773fb..6675281a 100644 --- a/src/components/assistant-ui/reasoning.tsx +++ b/src/components/assistant-ui/reasoning.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useRef, useState, useEffect } from "react"; import { cva, type VariantProps } from "class-variance-authority"; -import { BrainIcon, ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon } from "lucide-react"; import { useScrollLock, useAuiState, @@ -136,10 +136,6 @@ function ReasoningTrigger({ )} {...props} > - { return (
{/* Attachments display */} From 5d38a74f8d1f994ddfeaee98704810a6404bb107 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:31:38 -0500 Subject: [PATCH 35/56] fixes --- src/components/assistant-ui/thread.tsx | 2 +- src/lib/utils/format-workspace-context.ts | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 9a160d0e..dc06800a 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -827,7 +827,7 @@ const ComposerAction: FC = ({ items }) => { return (
{/* Attachment buttons on the left */} -
+
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index a292464d..a100451e 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -87,12 +87,9 @@ Your knowledge cutoff date is January 2025. -WORKSPACE (virtual file system): ${formatVirtualWorkspaceFS(state)} -When users say "this", they may mean the selected cards. Selected cards context provides paths and metadata only — use searchWorkspace or readWorkspace to fetch full content when needed. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCards tool. - -When answering questions about selected cards, use searchWorkspace to search or readWorkspace to read the item. Rely only on facts from the content you fetch. Do not invent or assume information not present. +When users say "this", they may mean the selected cards, which should be the primary source of your context. Selected cards context provides paths and metadata only, so use searchWorkspace or readWorkspace to fetch full content when needed. Reference items by path or name. If no context is provided, explain how to select cards: hover + click checkmark, shift-click, or drag-select, or select them yourself with the selectCards tool. Rely only on facts from the content you fetch. Do not invent or assume information not present. @@ -109,7 +106,7 @@ Use webSearch when: temporal cues ("today", "latest", "current"), real-time data Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content. If uncertain about accuracy, prefer to search. -PDF: Use readWorkspace for PDFs with content (pageStart/pageEnd for page ranges). If content is not yet extracted, you must call processFiles — do not ask the user. For image placeholders in readWorkspace output, call processFiles with pdfImageRefs. +PDF: Use readWorkspace for PDFs with content (pageStart/pageEnd for page ranges). If content is not yet extracted, you must call processFiles, do not ask the user. For image placeholders in readWorkspace output, call processFiles with pdfImageRefs. YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search. @@ -124,9 +121,7 @@ Rules: - Never make up or hallucinate URLs - Include article dates in responses when available -NOTE EDITING: See updateNote and readWorkspace tool descriptions for oldString/newString conventions, read-before-write requirement, and exact-match rules. - -INLINE CITATIONS (optional): +INLINE CITATIONS (highly recommended for most responses): Only in your chat response — never in item content (notes, flashcards, quizzes, etc.). Use sources param for tools; do not put tags in content passed to createNote, updateNote, addFlashcards, etc. Use simple plain text only. Bare minimum for uniqueness. No math, LaTeX, or complex formatting inside citations. Output citation HTML: REF where REF is one of: @@ -179,7 +174,7 @@ $$ \\sum_{i=1}^{n} i = \\frac{n(n+1)}{2} $$ -DIAGRAMS: Use \`\`\`mermaid blocks for when a diagram would be helpful +DIAGRAMS: Use \`\`\`mermaid blocks for when a diagram would be helpful in your response but not in tool call content From 775af04a6bd22fb072817b5cf678ffef628d799f Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:17:26 -0500 Subject: [PATCH 36/56] fix: handle pdf in progress --- src/lib/utils/format-workspace-context.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index a100451e..0de38791 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -690,6 +690,8 @@ function formatPdfDetailsFull( } if (page.footer) lines.push(` Footer: ${page.footer}`); } + } else if (data.ocrStatus === "processing") { + lines.push(` - (Content is being extracted. Please wait a moment and try readWorkspace or processFiles again.)`); } else { lines.push(` - (Content not yet extracted. You must call the processFiles tool to extract it — do not ask the user to do this.)`); } From edf5ff9e7ebfd25bf0b446807767d059e3dca3a8 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:26:48 -0500 Subject: [PATCH 37/56] fix: type error --- src/components/pdf/AppPdfViewer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx index b754a0e5..06e9de2e 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -58,6 +58,7 @@ const restoreCompleted = new Set(); const PdfStatePersister = ({ documentId, pdfSrc, itemId }: { documentId: string; pdfSrc: string; itemId?: string }) => { const { provides: viewportCapability } = useViewportCapability(); const { state: zoomState, provides: zoomScope } = useZoom(documentId); + const { state: scrollState } = useScroll(documentId); const debounceRef = useRef(undefined); const lastSavedRef = useRef(''); From b306ca3909505a76991675c2f4f93ece1bda266d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:36:13 -0500 Subject: [PATCH 38/56] fix: citations --- src/components/assistant-ui/markdown-text.tsx | 21 +++++++++++++------ src/lib/utils/format-workspace-context.ts | 7 +++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index 62349218..fc4355ae 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -27,6 +27,7 @@ import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { useUIStore } from "@/lib/stores/ui-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; 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"; @@ -107,11 +108,17 @@ const CitationRenderer = memo( const handleWorkspaceItemClick = () => { if (!workspaceState?.items || !title) return; - const item = workspaceState.items.find( - (i) => - (i.type === "note" || i.type === "pdf") && - titleNorm(i.name) === titleNorm(title) - ); + // Resolve by virtual path first (e.g. "pdfs/Syllabus.pdf") — AI may cite using paths from + const items = workspaceState.items; + const byPath = resolveItemByPath(items, title); + const item = + byPath && (byPath.type === "note" || byPath.type === "pdf") + ? byPath + : items.find( + (i) => + (i.type === "note" || i.type === "pdf") && + 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")) { @@ -125,8 +132,10 @@ const CitationRenderer = memo( setOpenModalItemId(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 = - title.slice(0, 20) + (title.length > 20 ? "…" : "") + + displayTitle.slice(0, 20) + (displayTitle.length > 20 ? "…" : "") + (pageNumber != null ? ` · p.${pageNumber}` : ""); return ( diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 0de38791..42c55dc6 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -127,14 +127,17 @@ Use simple plain text only. Bare minimum for uniqueness. No math, LaTeX, or comp Output citation HTML: REF where REF is one of: - Web URL: https://example.com/article -- Workspace note: Note Title +- Workspace note: Note Title — or virtual path: notes/My Note.md - Workspace + quote: Note Title | exact excerpt — pipe with spaces; only when you have the exact text +- PDF: PDF Title or virtual path: pdfs/Syllabus.pdf - PDF with page: PDF Title | exact excerpt | p. 5 — for PDFs, ALWAYS include page; use " | p. N" at end (1-indexed) -- PDF with page only: PDF Title | p. 5 +- PDF with page only: PDF Title | p. 5 or pdfs/Syllabus.pdf | p. 3 Examples (plain text only): - https://en.wikipedia.org/wiki/Supply_chain - My Calculus Notes +- notes/My Calculus Notes.md +- pdfs/Syllabus.pdf - My Calculus Notes | the derivative rule for power functions - Math 240 Textbook | limit definition | p. 42 From c158e9b036184f208f9119ea5c10d84f4f00fac7 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:16:05 -0500 Subject: [PATCH 39/56] fixes --- src/app/api/audio/process/route.ts | 4 +- src/app/api/pdf/ocr/start/route.ts | 4 +- src/app/api/threads/[id]/archive/route.ts | 32 +-------- .../[id]/messages/[messageId]/route.ts | 6 +- src/app/api/threads/[id]/messages/route.ts | 8 +-- src/app/api/threads/[id]/unarchive/route.ts | 32 +-------- src/app/dashboard/page.tsx | 26 +++++-- src/components/pdf/AppPdfViewer.tsx | 34 +++++++-- src/components/ui/carousel.tsx | 1 + src/components/ui/hover-card.tsx | 2 +- .../WorkspaceCanvasDropzone.tsx | 8 ++- .../workspace/use-workspace-operations.ts | 2 +- src/lib/ai/tools/process-files.ts | 16 +++-- src/lib/ai/tools/read-workspace.ts | 9 ++- src/lib/ai/tools/workspace-tools.ts | 3 +- src/lib/api/thread-archive.ts | 40 +++++++++++ src/lib/audio/poll-audio-processing.ts | 69 ++++++++++++------- .../chat/custom-thread-history-adapter.tsx | 4 +- 18 files changed, 181 insertions(+), 119 deletions(-) create mode 100644 src/lib/api/thread-archive.ts diff --git a/src/app/api/audio/process/route.ts b/src/app/api/audio/process/route.ts index d1a111c3..ac397624 100644 --- a/src/app/api/audio/process/route.ts +++ b/src/app/api/audio/process/route.ts @@ -52,7 +52,9 @@ export async function POST(req: NextRequest) { if (process.env.NEXT_PUBLIC_APP_URL) { allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname); } - allowedHosts.push("localhost"); // local storage in dev + if (process.env.NODE_ENV === "development") { + allowedHosts.push("localhost"); // local storage in dev + } let parsedUrl: URL; try { diff --git a/src/app/api/pdf/ocr/start/route.ts b/src/app/api/pdf/ocr/start/route.ts index 4d88d567..1a4bd667 100644 --- a/src/app/api/pdf/ocr/start/route.ts +++ b/src/app/api/pdf/ocr/start/route.ts @@ -44,7 +44,9 @@ export async function POST(req: NextRequest) { if (process.env.NEXT_PUBLIC_APP_URL) { allowedHosts.push(new URL(process.env.NEXT_PUBLIC_APP_URL).hostname); } - allowedHosts.push("localhost", "127.0.0.1"); + if (process.env.NODE_ENV === "development") { + allowedHosts.push("localhost", "127.0.0.1"); + } let parsedUrl: URL; try { diff --git a/src/app/api/threads/[id]/archive/route.ts b/src/app/api/threads/[id]/archive/route.ts index e4661436..ce967f9b 100644 --- a/src/app/api/threads/[id]/archive/route.ts +++ b/src/app/api/threads/[id]/archive/route.ts @@ -1,11 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/lib/db/client"; -import { chatThreads } from "@/lib/db/schema"; -import { - requireAuth, - verifyWorkspaceAccess, -} from "@/lib/api/workspace-helpers"; -import { eq } from "drizzle-orm"; +import { setThreadArchived } from "@/lib/api/thread-archive"; /** * POST /api/threads/[id]/archive @@ -16,30 +10,8 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const userId = await requireAuth(); const { id } = await params; - - const [thread] = await db - .select() - .from(chatThreads) - .where(eq(chatThreads.id, id)) - .limit(1); - - if (!thread) { - return NextResponse.json({ error: "Thread not found" }, { status: 404 }); - } - - await verifyWorkspaceAccess(thread.workspaceId, userId, "editor"); - - await db - .update(chatThreads) - .set({ - isArchived: true, - updatedAt: new Date().toISOString(), - }) - .where(eq(chatThreads.id, id)); - - return new Response(null, { status: 204 }); + return await setThreadArchived(id, true); } catch (error) { if (error instanceof Response) return error; console.error("[threads] archive error:", error); diff --git a/src/app/api/threads/[id]/messages/[messageId]/route.ts b/src/app/api/threads/[id]/messages/[messageId]/route.ts index a1df4ac3..e3433c61 100644 --- a/src/app/api/threads/[id]/messages/[messageId]/route.ts +++ b/src/app/api/threads/[id]/messages/[messageId]/route.ts @@ -35,11 +35,11 @@ export async function PATCH( const userId = await requireAuth(); const { id: threadId, messageId } = await params; const body = await req.json().catch(() => ({})); - const { format, content } = body; + const { content } = body; - if (!format || content === undefined) { + if (content === undefined) { return NextResponse.json( - { error: "format and content are required" }, + { error: "content is required" }, { status: 400 } ); } diff --git a/src/app/api/threads/[id]/messages/route.ts b/src/app/api/threads/[id]/messages/route.ts index 4e7d62dd..66cbfd84 100644 --- a/src/app/api/threads/[id]/messages/route.ts +++ b/src/app/api/threads/[id]/messages/route.ts @@ -5,7 +5,7 @@ import { requireAuth, verifyWorkspaceAccess, } from "@/lib/api/workspace-helpers"; -import { eq, desc } from "drizzle-orm"; +import { eq, and, desc } from "drizzle-orm"; async function getThreadAndVerify(id: string, userId: string) { const [thread] = await db @@ -42,12 +42,10 @@ export async function GET( const rows = await db .select() .from(chatMessages) - .where(eq(chatMessages.threadId, id)) + .where(and(eq(chatMessages.threadId, id), eq(chatMessages.format, format))) .orderBy(desc(chatMessages.createdAt)); - const messages = rows - .filter((r) => r.format === format) - .map((r) => ({ + const messages = rows.map((r) => ({ id: r.messageId, parent_id: r.parentId, format: r.format, diff --git a/src/app/api/threads/[id]/unarchive/route.ts b/src/app/api/threads/[id]/unarchive/route.ts index 71136b1a..8673c06d 100644 --- a/src/app/api/threads/[id]/unarchive/route.ts +++ b/src/app/api/threads/[id]/unarchive/route.ts @@ -1,11 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/lib/db/client"; -import { chatThreads } from "@/lib/db/schema"; -import { - requireAuth, - verifyWorkspaceAccess, -} from "@/lib/api/workspace-helpers"; -import { eq } from "drizzle-orm"; +import { setThreadArchived } from "@/lib/api/thread-archive"; /** * POST /api/threads/[id]/unarchive @@ -16,30 +10,8 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const userId = await requireAuth(); const { id } = await params; - - const [thread] = await db - .select() - .from(chatThreads) - .where(eq(chatThreads.id, id)) - .limit(1); - - if (!thread) { - return NextResponse.json({ error: "Thread not found" }, { status: 404 }); - } - - await verifyWorkspaceAccess(thread.workspaceId, userId, "editor"); - - await db - .update(chatThreads) - .set({ - isArchived: false, - updatedAt: new Date().toISOString(), - }) - .where(eq(chatThreads.id, id)); - - return new Response(null, { status: 204 }); + return await setThreadArchived(id, false); } catch (error) { if (error instanceof Response) return error; console.error("[threads] unarchive error:", error); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index ab953405..2aac1be3 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -390,12 +390,30 @@ function DashboardContent({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileUrl: r.fileUrl, itemId }), }) - .then((res) => res.json()) + .then(async (res) => { + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error((err as { error?: string }).error ?? `OCR start failed: ${res.status}`); + } + return res.json(); + }) .then((data) => { if (data.runId && data.itemId) { - import("@/lib/pdf/poll-pdf-ocr").then(({ pollPdfOcr }) => - pollPdfOcr(data.runId, data.itemId) - ); + import("@/lib/pdf/poll-pdf-ocr") + .then(({ pollPdfOcr }) => pollPdfOcr(data.runId, data.itemId)) + .catch((err) => { + window.dispatchEvent( + new CustomEvent("pdf-processing-complete", { + detail: { + itemId, + textContent: "", + ocrPages: [], + ocrStatus: "failed" as const, + ocrError: err instanceof Error ? err.message : "Failed to start OCR polling", + }, + }) + ); + }); } else { window.dispatchEvent( new CustomEvent("pdf-processing-complete", { diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx index 06e9de2e..315b3117 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -702,7 +702,7 @@ const PdfSearchBar = ({ documentId }: { documentId: string }) => { const PDF_HIGHLIGHT_DURATION_MS = 2500; -/** Scrolls to initialPage when layout is ready. Uses EmbedPDF's onLayoutReady (EventHook API). */ +/** Scrolls to initialPage when layout is ready. Also scrolls when initialPage changes after load (e.g. citation navigation). */ const PdfInitialPageScroll = ({ documentId, initialPage, @@ -713,22 +713,44 @@ const PdfInitialPageScroll = ({ onScrolled?: () => void; }) => { const { provides: scrollCapability } = useScrollCapability(); + const layoutReadyRef = useRef<{ totalPages: number } | null>(null); useEffect(() => { if (!scrollCapability || initialPage == null || initialPage < 1) return; - const unsub = scrollCapability.onLayoutReady((ev) => { - if (ev.documentId !== documentId || !ev.isInitial || ev.totalPages < 1) return; - + const scrollToPage = (totalPages: number) => { const scrollScope = scrollCapability.forDocument(documentId); if (!scrollScope) return; - - const page = Math.min(Math.max(1, initialPage), ev.totalPages); + const page = Math.min(Math.max(1, initialPage), totalPages); scrollScope.scrollToPage({ pageNumber: page }); onScrolled?.(); + }; + + const unsub = scrollCapability.onLayoutReady((ev) => { + if (ev.documentId !== documentId || !ev.isInitial || ev.totalPages < 1) return; + layoutReadyRef.current = { totalPages: ev.totalPages }; + scrollToPage(ev.totalPages); }); return unsub; + }, [scrollCapability, documentId]); + + // Clear ref when document changes so we don't use stale totalPages + useEffect(() => { + return () => { + layoutReadyRef.current = null; + }; + }, [documentId]); + + // When initialPage changes after layout is ready, scroll without waiting for onLayoutReady again + useEffect(() => { + if (!scrollCapability || initialPage == null || initialPage < 1 || !layoutReadyRef.current) return; + const { totalPages } = layoutReadyRef.current; + const scrollScope = scrollCapability.forDocument(documentId); + if (!scrollScope) return; + const page = Math.min(Math.max(1, initialPage), totalPages); + scrollScope.scrollToPage({ pageNumber: page }); + onScrolled?.(); }, [scrollCapability, documentId, initialPage, onScrolled]); return null; diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx index 0e05a77e..d0f8645c 100644 --- a/src/components/ui/carousel.tsx +++ b/src/components/ui/carousel.tsx @@ -101,6 +101,7 @@ function Carousel({ return () => { api?.off("select", onSelect) + api?.off("reInit", onSelect) } }, [api, onSelect]) diff --git a/src/components/ui/hover-card.tsx b/src/components/ui/hover-card.tsx index 4ecc37ef..ce776b2f 100644 --- a/src/components/ui/hover-card.tsx +++ b/src/components/ui/hover-card.tsx @@ -32,7 +32,7 @@ function HoverCardContent({ align={align} sideOffset={sideOffset} className={cn( - "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 z-50 origin-(--radix-hover-card-content-transform-origin) outline-hidden", + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 z-50 origin-(--radix-hover-card-content-transform-origin) outline-hidden", className )} {...props} diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx index c6908e20..03878745 100644 --- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx +++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx @@ -241,7 +241,13 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileUrl: r.fileUrl, itemId }), }) - .then((res) => res.json()) + .then(async (res) => { + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error((err as { error?: string }).error ?? `OCR start failed: ${res.status}`); + } + return res.json(); + }) .then((data) => { if (data.runId && data.itemId) { import("@/lib/pdf/poll-pdf-ocr").then(({ pollPdfOcr }) => diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index 5b97f091..b6e90ae1 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -121,7 +121,7 @@ export function useWorkspaceOperations( if (hasDuplicateName(currentState.items, finalName, validType, folderId)) { toast.error(`A ${validType} named "${finalName}" already exists in this folder`); - return id; + return ""; } const item: Item = { diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts index 30a8e432..f0875252 100644 --- a/src/lib/ai/tools/process-files.ts +++ b/src/lib/ai/tools/process-files.ts @@ -199,10 +199,11 @@ async function processPdfImages( const dataUrl = base64.startsWith("data:") ? base64 : `data:image/png;base64,${base64}`; - const mediaType = ref.imageId.toLowerCase().match(/\.(jpe?g|png|gif|webp)$/) - ? (ref.imageId.endsWith(".png") ? "image/png" - : ref.imageId.match(/\.jpe?g$/i) ? "image/jpeg" - : ref.imageId.endsWith(".gif") ? "image/gif" + const lower = ref.imageId.toLowerCase(); + const mediaType = lower.match(/\.(jpe?g|png|gif|webp)$/) + ? (lower.endsWith(".png") ? "image/png" + : lower.match(/\.jpe?g$/) ? "image/jpeg" + : lower.endsWith(".gif") ? "image/gif" : "image/webp") : "image/png"; @@ -413,9 +414,10 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { return "Error: 'urls' must be an array."; } - // If only pdfImageRefs were provided and we processed them, return those - if (urlList.length === 0 && pdfImageResults.length > 0) { - return pdfImageResults.join("\n\n---\n\n"); + // If only pdfImageRefs and/or cached content: combine both when present + if (urlList.length === 0 && (cachedResults.length > 0 || pdfImageResults.length > 0)) { + const parts = [...cachedResults, ...pdfImageResults].filter(Boolean); + return parts.join("\n\n---\n\n"); } if (urlList.length === 0) { diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts index 3b158839..f8276f76 100644 --- a/src/lib/ai/tools/read-workspace.ts +++ b/src/lib/ai/tools/read-workspace.ts @@ -92,7 +92,14 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { message: `Multiple items named "${itemName}". Use path to disambiguate: ${paths}`, }; } - item = fuzzyMatchItem(items, itemName.trim()); + if (exactMatches.length === 1) { + item = exactMatches[0]!; + } else { + item = fuzzyMatchItem( + items.filter((i) => i.type !== "folder"), + itemName.trim() + ); + } } if (!item) { diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 9960c3f8..774483d3 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -174,7 +174,8 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { // Read-before-write: for targeted edits, assert item was read // Skip assert when threadId is a placeholder (e.g. DEFAULT_THREAD_ID before thread exists) - const isTargetedEdit = oldString.trim().length > 0; + // Use exact empty check so whitespace-only oldString still enforces read requirement + const isTargetedEdit = oldString !== ""; if (isTargetedEdit && isValidThreadIdForDb(ctx.threadId)) { const currentLastModified = matchedNote.lastModified ?? 0; const assert = await assertWorkspaceItemRead( diff --git a/src/lib/api/thread-archive.ts b/src/lib/api/thread-archive.ts new file mode 100644 index 00000000..da4722c7 --- /dev/null +++ b/src/lib/api/thread-archive.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { chatThreads } from "@/lib/db/schema"; +import { + requireAuth, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; +import { eq } from "drizzle-orm"; + +/** + * Shared logic for archive/unarchive. Returns NextResponse or throws. + */ +export async function setThreadArchived( + threadId: string, + isArchived: boolean +): Promise { + const userId = await requireAuth(); + + const [thread] = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.id, threadId)) + .limit(1); + + if (!thread) { + return NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(thread.workspaceId, userId, "editor"); + + await db + .update(chatThreads) + .set({ + isArchived, + updatedAt: new Date().toISOString(), + }) + .where(eq(chatThreads.id, threadId)); + + return new Response(null, { status: 204 }); +} diff --git a/src/lib/audio/poll-audio-processing.ts b/src/lib/audio/poll-audio-processing.ts index ad1e0d67..9eb702e7 100644 --- a/src/lib/audio/poll-audio-processing.ts +++ b/src/lib/audio/poll-audio-processing.ts @@ -5,36 +5,55 @@ const POLL_INTERVAL_MS = 10_000; * Dispatches audio-processing-complete when done. */ export async function pollAudioProcessing(runId: string, itemId: string): Promise { + const dispatchError = (error: string) => { + window.dispatchEvent( + new CustomEvent("audio-processing-complete", { + detail: { itemId, error }, + }) + ); + }; + while (true) { - const res = await fetch(`/api/audio/process/status?runId=${encodeURIComponent(runId)}`); - const data = await res.json(); + try { + const res = await fetch(`/api/audio/process/status?runId=${encodeURIComponent(runId)}`); + if (!res.ok) { + dispatchError(`Status check failed: ${res.status}`); + return; + } + const data = await res.json(); - if (data.status === "completed") { - window.dispatchEvent( - new CustomEvent("audio-processing-complete", { - detail: { - itemId, - summary: data.result.summary, - segments: data.result.segments, - duration: data.result.duration, - }, - }) - ); - return; - } + if (data.status === "completed") { + window.dispatchEvent( + new CustomEvent("audio-processing-complete", { + detail: { + itemId, + summary: data.result.summary, + segments: data.result.segments, + duration: data.result.duration, + }, + }) + ); + return; + } + + if (data.status === "failed") { + window.dispatchEvent( + new CustomEvent("audio-processing-complete", { + detail: { + itemId, + error: data.error || "Processing failed", + }, + }) + ); + return; + } - if (data.status === "failed") { - window.dispatchEvent( - new CustomEvent("audio-processing-complete", { - detail: { - itemId, - error: data.error || "Processing failed", - }, - }) + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } catch (err) { + dispatchError( + err instanceof Error ? err.message : "Network or parse error during polling" ); return; } - - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } } diff --git a/src/lib/chat/custom-thread-history-adapter.tsx b/src/lib/chat/custom-thread-history-adapter.tsx index 2f6a7d80..d28824bd 100644 --- a/src/lib/chat/custom-thread-history-adapter.tsx +++ b/src/lib/chat/custom-thread-history-adapter.tsx @@ -80,7 +80,7 @@ export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter { const messageId = formatAdapter.getId(item.message); const encoded = formatAdapter.encode(item) as TStorageFormat; - const res = await fetch(`/api/threads/${remoteId}/messages`, { + const res = await fetch(`/api/threads/${encodeURIComponent(remoteId)}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -104,7 +104,7 @@ export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter { if (!remoteId) return { messages: [] }; const res = await fetch( - `/api/threads/${remoteId}/messages?format=${formatAdapter.format}` + `/api/threads/${encodeURIComponent(remoteId)}/messages?format=${encodeURIComponent(formatAdapter.format)}` ); if (!res.ok) { throw new Error(`Failed to load messages: ${res.status}`); From 89efe89157ccec20598d44c34435df98ac20091a Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:40:36 -0500 Subject: [PATCH 40/56] fix: audio --- src/app/api/audio/process/route.ts | 14 ++- .../workspace-canvas/AudioCardContent.tsx | 7 +- .../WorkspaceCanvasDropzone.tsx | 1 + .../workspace-canvas/WorkspaceContent.tsx | 30 ++--- .../workspace-canvas/WorkspaceHeader.tsx | 1 + .../workspace-canvas/WorkspaceSection.tsx | 1 + src/workflows/audio-transcribe/index.ts | 40 +++++-- src/workflows/audio-transcribe/steps/index.ts | 1 + .../audio-transcribe/steps/persist-result.ts | 111 ++++++++++++++++++ 9 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 src/workflows/audio-transcribe/steps/persist-result.ts diff --git a/src/app/api/audio/process/route.ts b/src/app/api/audio/process/route.ts index ac397624..d38cd1b6 100644 --- a/src/app/api/audio/process/route.ts +++ b/src/app/api/audio/process/route.ts @@ -28,7 +28,7 @@ export async function POST(req: NextRequest) { } const body = await req.json(); - const { fileUrl, filename, mimeType, itemId } = body; + const { fileUrl, filename, mimeType, itemId, workspaceId } = body; if (!fileUrl) { return NextResponse.json( @@ -44,6 +44,13 @@ export async function POST(req: NextRequest) { ); } + if (!workspaceId || typeof workspaceId !== "string") { + return NextResponse.json( + { error: "workspaceId is required" }, + { status: 400 } + ); + } + // Validate URL origin to prevent SSRF const allowedHosts: string[] = []; if (process.env.NEXT_PUBLIC_SUPABASE_URL) { @@ -77,10 +84,15 @@ export async function POST(req: NextRequest) { const audioMimeType = mimeType || guessMimeType(filename || fileUrl); + const userId = session.user.id; + // Start durable workflow; return immediately for client to poll const run = await start(audioTranscribeWorkflow, [ fileUrl, audioMimeType, + workspaceId, + itemId, + userId, ]); return NextResponse.json({ diff --git a/src/components/workspace-canvas/AudioCardContent.tsx b/src/components/workspace-canvas/AudioCardContent.tsx index bfe8da50..26105cfa 100644 --- a/src/components/workspace-canvas/AudioCardContent.tsx +++ b/src/components/workspace-canvas/AudioCardContent.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useRef, useEffect, useCallback } from "react"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Mic, ChevronDown, @@ -59,11 +60,12 @@ interface AudioCardContentProps { } export function AudioCardContent({ item, isCompact = false, isScrollLocked = false }: AudioCardContentProps) { + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); const audioData = item.data as AudioData; const [isRetrying, setIsRetrying] = useState(false); const handleRetry = useCallback(() => { - if (!audioData.fileUrl || isRetrying) return; + if (!audioData.fileUrl || !workspaceId || isRetrying) return; setIsRetrying(true); // Immediately transition card to "processing" via the same event system @@ -81,6 +83,7 @@ export function AudioCardContent({ item, isCompact = false, isScrollLocked = fal filename: audioData.filename, mimeType: audioData.mimeType || "audio/webm", itemId: item.id, + workspaceId, }), }) .then((res) => res.json()) @@ -105,7 +108,7 @@ export function AudioCardContent({ item, isCompact = false, isScrollLocked = fal ); }) .finally(() => setIsRetrying(false)); - }, [audioData.fileUrl, audioData.filename, audioData.mimeType, item.id, isRetrying]); + }, [audioData.fileUrl, audioData.filename, audioData.mimeType, item.id, workspaceId, isRetrying]); // ── Loading / Error states ────────────────────────────────────────────── diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx index 03878745..a3c8a5c5 100644 --- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx +++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx @@ -409,6 +409,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro filename: result.filename, mimeType: result.originalFile.type || 'audio/mpeg', itemId, + workspaceId: currentWorkspaceId, }), }) .then(res => res.json()) diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index 224145e9..4d6a3e16 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -1,5 +1,7 @@ import ShikiHighlighter from "react-shiki/web"; import { useMemo, useCallback, useRef, useState, useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Plus, Copy, Check, Download, Upload } from "lucide-react"; import { EmptyState } from "@/components/empty-state"; import type { AgentState, Item, CardType } from "@/lib/workspace-state/types"; @@ -61,6 +63,9 @@ export default function WorkspaceContent({ onPDFUpload, onItemCreated, }: WorkspaceContentProps) { + const queryClient = useQueryClient(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + // Use external ref if provided (from dashboard page), otherwise create local one const localScrollContainerRef = useRef(null); const scrollContainerRef = externalScrollContainerRef || localScrollContainerRef; @@ -182,7 +187,7 @@ export default function WorkspaceContent({ const existingData = viewState.items.find((i) => i.id === itemId)?.data ?? {}; - // Retry: transition back to "processing" state + // Retry: transition back to "processing" state (client-only optimistic update) if (retrying) { updateItem(itemId, { data: { @@ -194,23 +199,10 @@ export default function WorkspaceContent({ return; } - if (error) { - updateItem(itemId, { - data: { - ...existingData, - processingStatus: "failed", - error, - } as any, - }); - } else { - updateItem(itemId, { - data: { - ...existingData, - summary, - segments, - ...(typeof duration === "number" && duration > 0 && { duration }), - processingStatus: "complete", - } as any, + // Success/failure: workflow persisted to DB — invalidate to refetch + if (workspaceId) { + queryClient.invalidateQueries({ + queryKey: ["workspace", workspaceId, "events"], }); } }; @@ -219,7 +211,7 @@ export default function WorkspaceContent({ return () => { window.removeEventListener("audio-processing-complete", handleAudioComplete); }; - }, [updateItem, viewState.items]); + }, [updateItem, viewState.items, workspaceId, queryClient]); // OPTIMIZED: Wrap callbacks to ensure stable references const handleUpdateItem = useCallback((itemId: string, updates: Partial) => { diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index f494d2b4..fd51dd05 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -378,6 +378,7 @@ export function WorkspaceHeader({ filename: file.name, mimeType: file.type || "audio/webm", itemId, + workspaceId: currentWorkspaceId, }), }) .then((res) => res.json()) diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 1b5ee4e0..7f51bfae 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -597,6 +597,7 @@ export function WorkspaceSection({ filename: file.name, mimeType: file.type || "audio/webm", itemId, + workspaceId: currentWorkspaceId, }), }) .then((res) => res.json()) diff --git a/src/workflows/audio-transcribe/index.ts b/src/workflows/audio-transcribe/index.ts index eb773319..d2e1df2d 100644 --- a/src/workflows/audio-transcribe/index.ts +++ b/src/workflows/audio-transcribe/index.ts @@ -1,21 +1,45 @@ -import { downloadAndUploadToGemini, transcribeWithGemini } from "./steps"; +import { + downloadAndUploadToGemini, + transcribeWithGemini, + persistAudioResult, + persistAudioFailure, +} from "./steps"; /** * Durable workflow for audio transcription. * Steps are retriable and survive restarts. + * Persists result to workspace on success or failure. * * @param fileUrl - URL of the audio file (must be from allowed hosts) * @param mimeType - MIME type of the audio + * @param workspaceId - Workspace to update + * @param itemId - Audio card item ID + * @param userId - User ID for event attribution */ -export async function audioTranscribeWorkflow(fileUrl: string, mimeType: string) { +export async function audioTranscribeWorkflow( + fileUrl: string, + mimeType: string, + workspaceId: string, + itemId: string, + userId: string +) { "use workflow"; - const { fileUri, mimeType: geminiMimeType } = await downloadAndUploadToGemini( - fileUrl, - mimeType - ); + try { + const { fileUri, mimeType: geminiMimeType } = await downloadAndUploadToGemini( + fileUrl, + mimeType + ); - const result = await transcribeWithGemini(fileUri, geminiMimeType); + const result = await transcribeWithGemini(fileUri, geminiMimeType); - return result; + await persistAudioResult(workspaceId, itemId, userId, result); + + return result; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Processing failed"; + await persistAudioFailure(workspaceId, itemId, userId, errorMessage); + throw err; + } } diff --git a/src/workflows/audio-transcribe/steps/index.ts b/src/workflows/audio-transcribe/steps/index.ts index 6cc18b1a..a951e4a6 100644 --- a/src/workflows/audio-transcribe/steps/index.ts +++ b/src/workflows/audio-transcribe/steps/index.ts @@ -1,2 +1,3 @@ export { downloadAndUploadToGemini } from "./download-and-upload"; export { transcribeWithGemini, type TranscribeResult } from "./transcribe"; +export { persistAudioResult, persistAudioFailure } from "./persist-result"; diff --git a/src/workflows/audio-transcribe/steps/persist-result.ts b/src/workflows/audio-transcribe/steps/persist-result.ts new file mode 100644 index 00000000..797678c3 --- /dev/null +++ b/src/workflows/audio-transcribe/steps/persist-result.ts @@ -0,0 +1,111 @@ +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import { createEvent } from "@/lib/workspace/events"; +import type { TranscribeResult } from "./transcribe"; + +/** + * Persist transcription result to workspace as ITEM_UPDATED event. + * Durable step — retriable. + */ +export async function persistAudioResult( + workspaceId: string, + itemId: string, + userId: string, + result: TranscribeResult +): Promise { + "use step"; + + const event = createEvent( + "ITEM_UPDATED", + { + id: itemId, + changes: { + data: { + summary: result.summary, + segments: result.segments, + ...(typeof result.duration === "number" && + result.duration > 0 && { duration: result.duration }), + processingStatus: "complete" as const, + }, + }, + source: "agent", + }, + userId + ); + + const versionResult = await db.execute(sql` + SELECT get_workspace_version(${workspaceId}::uuid) as version + `); + const baseVersion = versionResult[0]?.version ?? 0; + + const appendResult = await db.execute(sql` + SELECT append_workspace_event( + ${workspaceId}::uuid, + ${event.id}::text, + ${event.type}::text, + ${JSON.stringify(event.payload)}::jsonb, + ${event.timestamp}::bigint, + ${event.userId}::text, + ${baseVersion}::integer, + NULL::text + ) as result + `); + + const raw = appendResult[0]?.result as string | undefined; + const match = raw?.match(/\((\d+),(t|f)\)/); + if (match && match[2] === "t") { + throw new Error("Workspace was modified by another user, please retry"); + } +} + +/** + * Persist audio processing failure to workspace. + * Durable step — retriable. + */ +export async function persistAudioFailure( + workspaceId: string, + itemId: string, + userId: string, + error: string +): Promise { + "use step"; + + const event = createEvent( + "ITEM_UPDATED", + { + id: itemId, + changes: { + data: { + processingStatus: "failed" as const, + error, + }, + }, + source: "agent", + }, + userId + ); + + const versionResult = await db.execute(sql` + SELECT get_workspace_version(${workspaceId}::uuid) as version + `); + const baseVersion = versionResult[0]?.version ?? 0; + + const appendResult = await db.execute(sql` + SELECT append_workspace_event( + ${workspaceId}::uuid, + ${event.id}::text, + ${event.type}::text, + ${JSON.stringify(event.payload)}::jsonb, + ${event.timestamp}::bigint, + ${event.userId}::text, + ${baseVersion}::integer, + NULL::text + ) as result + `); + + const raw = appendResult[0]?.result as string | undefined; + const match = raw?.match(/\((\d+),(t|f)\)/); + if (match && match[2] === "t") { + throw new Error("Workspace was modified by another user, please retry"); + } +} From af88a61350e651660cb0de17dbbf9bb4b38df68b Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:17:34 -0500 Subject: [PATCH 41/56] fix: citations --- src/lib/ai/tools/workspace-search-utils.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/tools/workspace-search-utils.ts b/src/lib/ai/tools/workspace-search-utils.ts index d6534cec..a5eeb689 100644 --- a/src/lib/ai/tools/workspace-search-utils.ts +++ b/src/lib/ai/tools/workspace-search-utils.ts @@ -79,27 +79,32 @@ export function extractSearchableText(item: Item, items: Item[]): string { * Resolve an item by virtual path. * Path format: "Physics/notes/Thermodynamics.md" or "notes/My Note.md" */ +/** Known file extensions — avoid treating "4." in "4. Container Networking (2)" as extension */ +const KNOWN_EXTENSIONS = /\.(pdf|md|url|png|audio|txt)$/i; + export function resolveItemByPath(items: Item[], pathInput: string): Item | null { const normalized = pathInput.trim().replace(/\/+/g, "/").replace(/^\//, ""); if (!normalized) return null; + const stripExt = (s: string) => s.replace(KNOWN_EXTENSIONS, ""); + // Try exact match on getVirtualPath first const contentItems = items.filter((i) => i.type !== "folder"); const exact = contentItems.find((item) => getVirtualPath(item, items) === normalized); if (exact) return exact; // Try path without extension (user might omit .md etc.) - const withoutExt = normalized.replace(/\.[^.]+$/, ""); + const withoutExt = stripExt(normalized); const byPathNoExt = contentItems.find((item) => { const vp = getVirtualPath(item, items); - return vp.replace(/\.[^.]+$/, "") === withoutExt || vp === normalized; + return stripExt(vp) === withoutExt || vp === normalized; }); if (byPathNoExt) return byPathNoExt; // Try matching last segment as filename (e.g. "Thermodynamics.md" -> item named "Thermodynamics") const segments = normalized.split("/").filter(Boolean); const filename = segments[segments.length - 1]; - const nameWithoutExt = filename.replace(/\.[^.]+$/, ""); + const nameWithoutExt = stripExt(filename); const candidates = contentItems.filter((item) => { const vp = getVirtualPath(item, items); From 895a815b43db8a8c255ac8162186c9389549e6b3 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:34:33 -0500 Subject: [PATCH 42/56] fix: pdf citatons --- src/components/pdf/AppPdfViewer.tsx | 36 +++++++++++++++++------------ 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx index 315b3117..36754357 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -713,27 +713,28 @@ const PdfInitialPageScroll = ({ onScrolled?: () => void; }) => { const { provides: scrollCapability } = useScrollCapability(); + const { state: scrollState } = useScroll(documentId); const layoutReadyRef = useRef<{ totalPages: number } | null>(null); + // Always subscribe to onLayoutReady so we capture readiness even when PDF opened without citation useEffect(() => { - if (!scrollCapability || initialPage == null || initialPage < 1) return; - - const scrollToPage = (totalPages: number) => { - const scrollScope = scrollCapability.forDocument(documentId); - if (!scrollScope) return; - const page = Math.min(Math.max(1, initialPage), totalPages); - scrollScope.scrollToPage({ pageNumber: page }); - onScrolled?.(); - }; + if (!scrollCapability) return; const unsub = scrollCapability.onLayoutReady((ev) => { if (ev.documentId !== documentId || !ev.isInitial || ev.totalPages < 1) return; layoutReadyRef.current = { totalPages: ev.totalPages }; - scrollToPage(ev.totalPages); + if (initialPage != null && initialPage >= 1) { + const scrollScope = scrollCapability.forDocument(documentId); + if (scrollScope) { + const page = Math.min(Math.max(1, initialPage), ev.totalPages); + scrollScope.scrollToPage({ pageNumber: page }); + onScrolled?.(); + } + } }); return unsub; - }, [scrollCapability, documentId]); + }, [scrollCapability, documentId, initialPage, onScrolled]); // Clear ref when document changes so we don't use stale totalPages useEffect(() => { @@ -742,16 +743,21 @@ const PdfInitialPageScroll = ({ }; }, [documentId]); - // When initialPage changes after layout is ready, scroll without waiting for onLayoutReady again + // When initialPage changes after layout is ready (or when totalPages from useScroll indicates doc is ready), scroll. + // Use scrollState.totalPages as fallback when layoutReadyRef missed (PDF already open when we mounted). useEffect(() => { - if (!scrollCapability || initialPage == null || initialPage < 1 || !layoutReadyRef.current) return; - const { totalPages } = layoutReadyRef.current; + if (!scrollCapability || initialPage == null || initialPage < 1) return; + const scrollScope = scrollCapability.forDocument(documentId); if (!scrollScope) return; + + const totalPages = layoutReadyRef.current?.totalPages ?? scrollState?.totalPages ?? 0; + if (totalPages < 1) return; + const page = Math.min(Math.max(1, initialPage), totalPages); scrollScope.scrollToPage({ pageNumber: page }); onScrolled?.(); - }, [scrollCapability, documentId, initialPage, onScrolled]); + }, [scrollCapability, documentId, initialPage, onScrolled, scrollState?.totalPages]); return null; }; From 74d4091b13099eb0caa658532c2865b313e34e07 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:45:56 -0500 Subject: [PATCH 43/56] fix Pdfs --- .../assistant-ui/WorkspaceRuntimeProvider.tsx | 9 ++- src/components/pdf/AppPdfViewer.tsx | 62 ++++++++++++------- .../workspace-canvas/WorkspaceCard.tsx | 2 +- src/lib/stores/ui-store.ts | 17 ++++- src/lib/utils/format-workspace-context.ts | 32 +++++++--- 5 files changed, 87 insertions(+), 35 deletions(-) diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index e2d8739d..a08120a2 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -29,6 +29,7 @@ export function WorkspaceRuntimeProvider({ const selectedModelId = useUIStore((state) => state.selectedModelId); const activeFolderId = useUIStore((state) => state.activeFolderId); const selectedCardIdsSet = useUIStore((state) => state.selectedCardIds); + const activePdfPageByItemId = useUIStore((state) => state.activePdfPageByItemId); const { state: workspaceState } = useWorkspaceState(workspaceId); const selectedCardsContext = useMemo(() => { @@ -44,8 +45,12 @@ export function WorkspaceRuntimeProvider({ return ""; } - return formatSelectedCardsMetadata(selectedItems, workspaceState.items); - }, [workspaceState?.items, selectedCardIdsSet]); + return formatSelectedCardsMetadata( + selectedItems, + workspaceState.items, + activePdfPageByItemId + ); + }, [workspaceState?.items, selectedCardIdsSet, activePdfPageByItemId]); const handleChatError = useCallback((error: Error) => { console.error("[Chat Error]", error); diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx index 36754357..0546c7e9 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -713,28 +713,27 @@ const PdfInitialPageScroll = ({ onScrolled?: () => void; }) => { const { provides: scrollCapability } = useScrollCapability(); - const { state: scrollState } = useScroll(documentId); const layoutReadyRef = useRef<{ totalPages: number } | null>(null); - // Always subscribe to onLayoutReady so we capture readiness even when PDF opened without citation useEffect(() => { - if (!scrollCapability) return; + if (!scrollCapability || initialPage == null || initialPage < 1) return; + + const scrollToPage = (totalPages: number) => { + const scrollScope = scrollCapability.forDocument(documentId); + if (!scrollScope) return; + const page = Math.min(Math.max(1, initialPage), totalPages); + scrollScope.scrollToPage({ pageNumber: page }); + onScrolled?.(); + }; const unsub = scrollCapability.onLayoutReady((ev) => { if (ev.documentId !== documentId || !ev.isInitial || ev.totalPages < 1) return; layoutReadyRef.current = { totalPages: ev.totalPages }; - if (initialPage != null && initialPage >= 1) { - const scrollScope = scrollCapability.forDocument(documentId); - if (scrollScope) { - const page = Math.min(Math.max(1, initialPage), ev.totalPages); - scrollScope.scrollToPage({ pageNumber: page }); - onScrolled?.(); - } - } + scrollToPage(ev.totalPages); }); return unsub; - }, [scrollCapability, documentId, initialPage, onScrolled]); + }, [scrollCapability, documentId]); // Clear ref when document changes so we don't use stale totalPages useEffect(() => { @@ -743,21 +742,37 @@ const PdfInitialPageScroll = ({ }; }, [documentId]); - // When initialPage changes after layout is ready (or when totalPages from useScroll indicates doc is ready), scroll. - // Use scrollState.totalPages as fallback when layoutReadyRef missed (PDF already open when we mounted). + // When initialPage changes after layout is ready, scroll without waiting for onLayoutReady again useEffect(() => { - if (!scrollCapability || initialPage == null || initialPage < 1) return; - + if (!scrollCapability || initialPage == null || initialPage < 1 || !layoutReadyRef.current) return; + const { totalPages } = layoutReadyRef.current; const scrollScope = scrollCapability.forDocument(documentId); if (!scrollScope) return; - - const totalPages = layoutReadyRef.current?.totalPages ?? scrollState?.totalPages ?? 0; - if (totalPages < 1) return; - const page = Math.min(Math.max(1, initialPage), totalPages); scrollScope.scrollToPage({ pageNumber: page }); onScrolled?.(); - }, [scrollCapability, documentId, initialPage, onScrolled, scrollState?.totalPages]); + }, [scrollCapability, documentId, initialPage, onScrolled]); + + return null; +}; + +/** Syncs current PDF page to UI store when PDF is open — used for selected-card context so the AI knows which page the user is viewing. */ +const PdfActivePageSync = ({ documentId, itemId }: { documentId: string; itemId?: string }) => { + const { state: scrollState } = useScroll(documentId); + const setActivePdfPage = useUIStore((state) => state.setActivePdfPage); + + useEffect(() => { + if (!itemId) return; + const page = scrollState?.currentPage; + if (page != null && page >= 1) { + setActivePdfPage(itemId, page); + } + }, [itemId, scrollState?.currentPage, setActivePdfPage]); + + useEffect(() => { + if (!itemId) return; + return () => setActivePdfPage(itemId, null); + }, [itemId, setActivePdfPage]); return null; }; @@ -1023,7 +1038,10 @@ const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName, onScrolled={() => useUIStore.getState().setCitationHighlightQuery(null)} /> {itemId && ( - + <> + + + )} ) : ( - + )} {/* OCR processing indicator overlay */} {isOcrProcessing && ( diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index 0fd4d226..ef521345 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -59,6 +59,9 @@ interface UIState { // Citation highlight: when opening note/PDF from citation click, highlight/search this quote citationHighlightQuery: { itemId: string; query: string; pageNumber?: number } | null; + // PDF active page: itemId -> current page when PDF is open (for selected-card context) + activePdfPageByItemId: Record; + // Actions - Chat setIsChatExpanded: (expanded: boolean) => void; toggleChatExpanded: () => void; @@ -131,6 +134,7 @@ interface UIState { setBlockNoteSelection: (selection: { cardId: string; cardName: string; text: string } | null) => void; clearBlockNoteSelection: () => void; setCitationHighlightQuery: (query: { itemId: string; query: string; pageNumber?: number } | null) => void; + setActivePdfPage: (itemId: string, page: number | null) => void; // Utility actions resetChatState: () => void; @@ -184,6 +188,7 @@ const initialState = { // BlockNote selection blockNoteSelection: null, citationHighlightQuery: null, + activePdfPageByItemId: {}, }; export const useUIStore = create()( @@ -552,7 +557,17 @@ export const useUIStore = create()( setCitationHighlightQuery: (query) => { set({ citationHighlightQuery: query }); }, - + setActivePdfPage: (itemId, page) => { + set((state) => { + const next = { ...state.activePdfPageByItemId }; + if (page == null) { + delete next[itemId]; + } else { + next[itemId] = page; + } + return { activePdfPageByItemId: next }; + }); + }, // Utility actions resetChatState: () => set({ diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 42c55dc6..255e3465 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -5,8 +5,9 @@ import { getVirtualPath } from "./virtual-workspace-fs"; /** * Formats item metadata only (no content). Used for virtual FS in system context. + * When activePdfPages is provided and item is a PDF, includes activePage if user is currently viewing it. */ -function formatItemMetadata(item: Item, items: Item[]): string { +function formatItemMetadata(item: Item, items: Item[], activePdfPages?: Record): string { const path = getVirtualPath(item, items); const parts: string[] = [path, `type=${item.type}`, `name="${item.name}"`]; if (item.subtitle) parts.push(`subtitle="${item.subtitle}"`); @@ -15,6 +16,10 @@ function formatItemMetadata(item: Item, items: Item[]): string { case "pdf": { const d = item.data as PdfData; if (d?.filename) parts.push(`filename=${d.filename}`); + const activePage = activePdfPages?.[item.id]; + if (activePage != null && activePage >= 1) { + parts.push(`activePage=${activePage}`); + } break; } case "flashcard": { @@ -107,6 +112,7 @@ Use internal knowledge for: creative writing, coding, general concepts, summariz If uncertain about accuracy, prefer to search. PDF: Use readWorkspace for PDFs with content (pageStart/pageEnd for page ranges). If content is not yet extracted, you must call processFiles, do not ask the user. For image placeholders in readWorkspace output, call processFiles with pdfImageRefs. +When selected card metadata includes activePage=N, the user is currently viewing that page — use this to give better, more relevant responses. When they say "this", "here", "this page", or "what I'm looking at", treat it as referring to that page. YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search. @@ -129,19 +135,20 @@ Output citation HTML: REF where REF is one of: - Web URL: https://example.com/article - Workspace note: Note Title — or virtual path: notes/My Note.md - Workspace + quote: Note Title | exact excerpt — pipe with spaces; only when you have the exact text -- PDF: PDF Title or virtual path: pdfs/Syllabus.pdf -- PDF with page: PDF Title | exact excerpt | p. 5 — for PDFs, ALWAYS include page; use " | p. N" at end (1-indexed) -- PDF with page only: PDF Title | p. 5 or pdfs/Syllabus.pdf | p. 3 +- PDF (page REQUIRED): PDF Title | p. 5 or PDF Title | exact excerpt | p. 5 — for PDFs you MUST include " | p. N" at end (1-indexed). Quote is optional. Virtual path is OK: pdfs/MyFile.pdf | p. 3. + +WRONG — PDF without page (never do this): pdfs/SomeFile.pdf or PDF Title Examples (plain text only): - https://en.wikipedia.org/wiki/Supply_chain - My Calculus Notes - notes/My Calculus Notes.md -- pdfs/Syllabus.pdf -- My Calculus Notes | the derivative rule for power functions +- Math 240 Textbook | p. 42 - Math 240 Textbook | limit definition | p. 42 +- pdfs/Syllabus.pdf | p. 3 -NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt. If unsure, use Title without a quote. For PDFs, always include the page. +NEVER HALLUCINATE QUOTES: Only include a quote when you have the exact excerpt. If unsure, use Title without a quote. +PDF CITATIONS: Page number is MANDATORY. Every PDF citation must end with " | p. N". If you don't know the page, do not cite the PDF. CRITICAL — Punctuation: Put the period or comma BEFORE the citation. Correct: "...flow of goods and services." Source Title | comprehensive administration @@ -425,8 +432,13 @@ function formatRichContentSection(richContent: RichContent): string { /** * Formats selected cards as metadata only (paths, names, types). * Use when the AI has grep/read tools — it fetches content on demand. + * When activePdfPages is provided, PDF items include activePage (page user is currently viewing). */ -export function formatSelectedCardsMetadata(selectedItems: Item[], allItems?: Item[]): string { +export function formatSelectedCardsMetadata( + selectedItems: Item[], + allItems?: Item[], + activePdfPages?: Record +): string { if (selectedItems.length === 0) { return ` No cards selected. @@ -454,7 +466,9 @@ No cards selected. selectedItems.forEach((item) => processItem(item)); const contentItems = effectiveItems.filter((i) => i.type !== "folder"); - const entries = contentItems.map((item) => formatItemMetadata(item, allItems ?? effectiveItems)); + const entries = contentItems.map((item) => + formatItemMetadata(item, allItems ?? effectiveItems, activePdfPages) + ); return ` SELECTED CARDS (${contentItems.length}) — paths and metadata. Use searchWorkspace or readWorkspace to fetch content when needed. From 873ac5f8367919b381906c70767bd8f1f09d90b6 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:50:49 -0500 Subject: [PATCH 44/56] refactor: remove read-before-write enforcement; instruct agent to check instead Co-authored-by: Cursor --- src/lib/ai/tools/read-workspace.ts | 29 +-------- src/lib/ai/tools/workspace-tools.ts | 23 +------ src/lib/db/workspace-item-reads.ts | 79 ----------------------- src/lib/utils/format-workspace-context.ts | 2 + src/lib/utils/thread-id.ts | 16 ----- 5 files changed, 4 insertions(+), 145 deletions(-) delete mode 100644 src/lib/db/workspace-item-reads.ts delete mode 100644 src/lib/utils/thread-id.ts diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts index f8276f76..458ba796 100644 --- a/src/lib/ai/tools/read-workspace.ts +++ b/src/lib/ai/tools/read-workspace.ts @@ -5,9 +5,6 @@ import { fuzzyMatchItem } from "./tool-utils"; import { resolveItemByPath } from "./workspace-search-utils"; import { formatItemContent } from "@/lib/utils/format-workspace-context"; import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs"; -import { recordWorkspaceItemRead } from "@/lib/db/workspace-item-reads"; -import { logger } from "@/lib/utils/logger"; -import { isValidThreadIdForDb } from "@/lib/utils/thread-id"; import type { WorkspaceToolContext } from "./workspace-tools"; const DEFAULT_LIMIT = 500; @@ -17,7 +14,7 @@ const MAX_LINE_LENGTH = 2000; export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { return tool({ description: - "Read content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Usage: By default returns up to 500 lines from the start. The lineStart parameter is the 1-indexed line number to start from — call again with a larger lineStart to read later sections. For PDFs: use pageStart and pageEnd (1-indexed) to read only specific pages — e.g. pageStart=5, pageEnd=10 reads pages 5–10. Use searchWorkspace to find specific content in large items. Contents are returned with each line prefixed by its line number as : . Any line longer than 2000 characters is truncated. Avoid tiny repeated slices (e.g. 30-line chunks); read a larger window. REQUIRED before targeted updateNote edits — the tool will error otherwise.", + "Read content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Usage: By default returns up to 500 lines from the start. The lineStart parameter is the 1-indexed line number to start from — call again with a larger lineStart to read later sections. For PDFs: use pageStart and pageEnd (1-indexed) to read only specific pages — e.g. pageStart=5, pageEnd=10 reads pages 5–10. Use searchWorkspace to find specific content in large items. Contents are returned with each line prefixed by its line number as : . Any line longer than 2000 characters is truncated. Avoid tiny repeated slices (e.g. 30-line chunks); read a larger window. Call this before targeted updateNote edits to get the exact text for oldString.", inputSchema: zodSchema( z.object({ path: z @@ -146,30 +143,6 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { const vpath = getVirtualPath(item, items); - // Record read for read-before-write enforcement (targeted edits) - // Skip when threadId is a placeholder (e.g. DEFAULT_THREAD_ID before thread is created) - logger.debug("[readWorkspace] Recording read:", { - threadId: ctx.threadId, - threadIdType: typeof ctx.threadId, - isValidForDb: isValidThreadIdForDb(ctx.threadId), - itemId: item.id, - lastModified: item.lastModified ?? 0, - }); - if (isValidThreadIdForDb(ctx.threadId)) { - try { - await recordWorkspaceItemRead( - ctx.threadId, - item.id, - item.lastModified ?? 0 - ); - logger.debug("[readWorkspace] Recorded read successfully"); - } catch (err) { - logger.warn("[readWorkspace] Failed to record read:", err); - } - } else { - logger.debug("[readWorkspace] Skipping record (invalid threadId for DB)"); - } - return { success: true, itemName: item.name, diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 774483d3..93d20bd7 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -5,8 +5,6 @@ import { workspaceWorker } from "@/lib/ai/workers"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import type { Item } from "@/lib/workspace-state/types"; import { loadStateForTool, fuzzyMatchItem, getAvailableItemsList } from "./tool-utils"; -import { assertWorkspaceItemRead } from "@/lib/db/workspace-item-reads"; -import { isValidThreadIdForDb } from "@/lib/utils/thread-id"; export interface WorkspaceToolContext { workspaceId: string | null; @@ -76,7 +74,7 @@ export function createNoteTool(ctx: WorkspaceToolContext) { export function createUpdateNoteTool(ctx: WorkspaceToolContext) { return tool({ description: - "Update a note. You MUST use readWorkspace at least once before targeted edits — the tool will error otherwise. Full rewrite: oldString='', newString=entire note. Targeted edit: readWorkspace first, then oldString=exact text from the Content section (never include wrapper), newString=replacement. Preserve exact whitespace/indentation. Fails if oldString not found or matches multiple times — include more context or use replaceAll.", + "Update a note. Full rewrite: oldString='', newString=entire note. Targeted edit: call readWorkspace first to get current content, then oldString=exact text from the Content section (never include wrapper), newString=replacement. Preserve exact whitespace/indentation. Fails if oldString not found or matches multiple times — include more context or use replaceAll.", inputSchema: zodSchema( z .object({ @@ -172,25 +170,6 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { matchedId: matchedNote.id, }); - // Read-before-write: for targeted edits, assert item was read - // Skip assert when threadId is a placeholder (e.g. DEFAULT_THREAD_ID before thread exists) - // Use exact empty check so whitespace-only oldString still enforces read requirement - const isTargetedEdit = oldString !== ""; - if (isTargetedEdit && isValidThreadIdForDb(ctx.threadId)) { - const currentLastModified = matchedNote.lastModified ?? 0; - const assert = await assertWorkspaceItemRead( - ctx.threadId, - matchedNote.id, - currentLastModified - ); - if (!assert.ok) { - return { - success: false, - message: assert.message, - }; - } - } - const workerResult = await workspaceWorker("update", { workspaceId: ctx.workspaceId, itemId: matchedNote.id, diff --git a/src/lib/db/workspace-item-reads.ts b/src/lib/db/workspace-item-reads.ts deleted file mode 100644 index 2711f199..00000000 --- a/src/lib/db/workspace-item-reads.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Read-before-write tracking for workspace items (FileTime pattern). - * Records when a thread reads an item; asserts before targeted edits. - */ - -import { db, workspaceItemReads } from "@/lib/db/client"; -import { eq, and } from "drizzle-orm"; -import { logger } from "@/lib/utils/logger"; - -/** - * Record that a thread has read an item at the given lastModified. - * Upserts: if thread+item exists, updates lastModified and readAt. - */ -export async function recordWorkspaceItemRead( - threadId: string, - itemId: string, - lastModified: number -): Promise { - logger.debug("[workspace_item_reads] Writing to DB:", { - threadId, - itemId, - lastModified, - op: "upsert", - }); - await db - .insert(workspaceItemReads) - .values({ - threadId, - itemId, - lastModified, - }) - .onConflictDoUpdate({ - target: [workspaceItemReads.threadId, workspaceItemReads.itemId], - set: { - lastModified, - readAt: new Date().toISOString(), - }, - }); -} - -/** - * Assert that the thread has read the item and the lastModified matches. - * Used before targeted edits (oldString !== ''). - * @returns true if assertion passes - * @throws never - returns an error object on failure - */ -export async function assertWorkspaceItemRead( - threadId: string, - itemId: string, - currentLastModified: number -): Promise<{ ok: true } | { ok: false; message: string }> { - const [row] = await db - .select() - .from(workspaceItemReads) - .where( - and( - eq(workspaceItemReads.threadId, threadId), - eq(workspaceItemReads.itemId, itemId) - ) - ) - .limit(1); - - if (!row) { - return { - ok: false, - message: - "Read required before targeted edit. Use readWorkspace to read the note first, then retry updateNote with exact text from the content.", - }; - } - - if (row.lastModified !== currentLastModified) { - return { - ok: false, - message: `Note was modified since last read. Please use readWorkspace to get the latest content, then retry the edit.`, - }; - } - - return { ok: true }; -} diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 255e3465..25a92d7b 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -106,6 +106,8 @@ CORE BEHAVIORS: - You are allowed to complete homework or assignments for the user if they ask - Only use emojis if the user explicitly requests them +UPDATE NOTE (targeted edits): Before using updateNote with a non-empty oldString (targeted edit), you MUST call readWorkspace first to fetch the current content. Use the exact text from the Content section as oldString. If the note may have been modified since you last read it, call readWorkspace again before retrying. + WEB SEARCH GUIDELINES: Use webSearch when: temporal cues ("today", "latest", "current"), real-time data (scores, stocks, weather), fact verification, niche/recent info. Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content. diff --git a/src/lib/utils/thread-id.ts b/src/lib/utils/thread-id.ts deleted file mode 100644 index de64fa10..00000000 --- a/src/lib/utils/thread-id.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Thread ID validation for read-before-write enforcement. - * - * assistant-ui uses "DEFAULT_THREAD_ID" (from ExternalStoreThreadListRuntimeCore) - * as a placeholder before a thread is persisted. The real UUID is set when: - * - User sends first message -> adapter.initialize() POSTs to /api/threads -> returns remoteId - * - AssistantChatTransport uses: id = (await mainItem.initialize())?.remoteId ?? options.id - * - * Until then, body.id can be "DEFAULT_THREAD_ID", which is not a valid UUID and will fail - * DB inserts (thread_id references chat_threads.id). We skip recording/assert when invalid. - */ -const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export function isValidThreadIdForDb(threadId: string | null | undefined): threadId is string { - return !!threadId && UUID_REGEX.test(threadId); -} From e33a223aef90a61850e2114bfcddf972c9882488 Mon Sep 17 00:00:00 2001 From: 1shCha Date: Tue, 24 Feb 2026 09:15:44 -0500 Subject: [PATCH 45/56] ai packages update --- package.json | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 54c8f7a4..22e6629e 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,11 @@ "dependencies": { "@ai-sdk/devtools": "^0.0.12", "@ai-sdk/gateway": "^3.0.39", - "@ai-sdk/google": "^3.0.23", - "@assistant-ui/react": "0.12.9", + "@ai-sdk/google": "^3.0.30", + "@assistant-ui/react": "0.12.11", "@assistant-ui/react-ai-sdk": "^1.3.6", "@assistant-ui/react-devtools": "^0.2.3", - "@assistant-ui/react-markdown": "^0.12.3", + "@assistant-ui/react-markdown": "^0.12.4", "@blocknote/code-block": "^0.46.2", "@blocknote/core": "^0.46.2", "@blocknote/react": "^0.46.2", @@ -94,8 +94,8 @@ "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-virtual": "^3.13.14", "@vercel/speed-insights": "^1.3.1", - "ai": "^6.0.78", - "assistant-stream": "^0.3.2", + "ai": "^6.0.97", + "assistant-stream": "^0.3.3", "better-auth": "^1.4.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -179,9 +179,6 @@ "overrides": { "@types/react": "19.2.2", "@types/react-dom": "19.2.2" - }, - "patchedDependencies": { - "@assistant-ui/react@0.12.9": "patches/@assistant-ui__react@0.12.9.patch" } } } \ No newline at end of file From 9cb2904d27fc1cc7dcf02b856766569864290360 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:47:13 -0500 Subject: [PATCH 46/56] feat: show pdf page ui --- patches/@assistant-ui__react@0.12.11.patch | 105 --------------------- patches/@assistant-ui__react@0.12.9.patch | 105 --------------------- src/components/chat/CardContextDisplay.tsx | 10 +- 3 files changed, 9 insertions(+), 211 deletions(-) delete mode 100644 patches/@assistant-ui__react@0.12.11.patch delete mode 100644 patches/@assistant-ui__react@0.12.9.patch diff --git a/patches/@assistant-ui__react@0.12.11.patch b/patches/@assistant-ui__react@0.12.11.patch deleted file mode 100644 index 8d8d4843..00000000 --- a/patches/@assistant-ui__react@0.12.11.patch +++ /dev/null @@ -1,105 +0,0 @@ -diff --git a/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js b/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -index 8a6a9c91f6ae5437977f9b9d2057bdc35dd1228a..4680b8d0149476489b94f462dac93d83f46251d6 100644 ---- a/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -+++ b/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -@@ -60,22 +60,22 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses, - .pipeThrough(transform) - .pipeThrough(new AssistantMetaTransformStream()) - .pipeTo(new WritableStream({ -- write(chunk) { -- if (chunk.type === "result") { -- // the tool call result was already set by the backend -- if (lastToolStates.current[chunk.meta.toolCallId]?.hasResult) -- return; -- onResult({ -- type: "add-tool-result", -- toolCallId: chunk.meta.toolCallId, -- toolName: chunk.meta.toolName, -- result: chunk.result, -- isError: chunk.isError, -- ...(chunk.artifact && { artifact: chunk.artifact }), -- }); -- } -- }, -- })); -+ write(chunk) { -+ if (chunk.type === "result") { -+ // the tool call result was already set by the backend -+ if (lastToolStates.current[chunk.meta.toolCallId]?.hasResult) -+ return; -+ onResult({ -+ type: "add-tool-result", -+ toolCallId: chunk.meta.toolCallId, -+ toolName: chunk.meta.toolName, -+ result: chunk.result, -+ isError: chunk.isError, -+ ...(chunk.artifact && { artifact: chunk.artifact }), -+ }); -+ } -+ }, -+ })); - return controller; - }); - const ignoredToolIds = useRef(new Set()); -@@ -117,21 +117,49 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses, - } - else { - if (!content.argsText.startsWith(lastState.argsText)) { -- // Check if this is key reordering (both are complete JSON) -+ // Check if new argsText is complete JSON (handles key reordering) - // This happens when transitioning from streaming to complete state - // and the provider returns keys in a different order -- if (isArgsTextComplete(lastState.argsText) && -- isArgsTextComplete(content.argsText)) { -- lastState.controller.argsText.close(); -- lastToolStates.current[content.toolCallId] = { -- argsText: content.argsText, -- hasResult: lastState.hasResult, -- argsComplete: true, -- controller: lastState.controller, -- }; -- return; // Continue to next content part -+ if (isArgsTextComplete(content.argsText)) { -+ try { -+ const newArgs = JSON.parse(content.argsText); -+ -+ // If old is also complete, verify they're equivalent (just reordered) -+ if (isArgsTextComplete(lastState.argsText)) { -+ const oldArgs = JSON.parse(lastState.argsText); -+ // Normalize both by sorting keys and compare -+ const oldNormalized = JSON.stringify(oldArgs, Object.keys(oldArgs).sort()); -+ const newNormalized = JSON.stringify(newArgs, Object.keys(newArgs).sort()); -+ -+ if (oldNormalized === newNormalized) { -+ // Same data, just reordered - accept it -+ lastState.controller.argsText.close(); -+ lastToolStates.current[content.toolCallId] = { -+ argsText: content.argsText, -+ hasResult: lastState.hasResult, -+ argsComplete: true, -+ controller: lastState.controller, -+ }; -+ return; // Continue to next content part -+ } -+ } else { -+ // Old is incomplete, but new is complete -+ // Accept the new complete version (it's the final state) -+ lastState.controller.argsText.close(); -+ lastToolStates.current[content.toolCallId] = { -+ argsText: content.argsText, -+ hasResult: lastState.hasResult, -+ argsComplete: true, -+ controller: lastState.controller, -+ }; -+ return; // Continue to next content part -+ } -+ } catch (e) { -+ // Not valid JSON, fall through to error -+ } - } -- throw new Error(`Tool call argsText can only be appended, not updated: ${content.argsText} does not start with ${lastState.argsText}`); -+ throw new Error(` -+Tool call argsText can only be appended, not updated: ${content.argsText} does not start with ${lastState.argsText}`); - } - const argsTextDelta = content.argsText.slice(lastState.argsText.length); - lastState.controller.argsText.append(argsTextDelta); diff --git a/patches/@assistant-ui__react@0.12.9.patch b/patches/@assistant-ui__react@0.12.9.patch deleted file mode 100644 index 8d8d4843..00000000 --- a/patches/@assistant-ui__react@0.12.9.patch +++ /dev/null @@ -1,105 +0,0 @@ -diff --git a/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js b/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -index 8a6a9c91f6ae5437977f9b9d2057bdc35dd1228a..4680b8d0149476489b94f462dac93d83f46251d6 100644 ---- a/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -+++ b/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js -@@ -60,22 +60,22 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses, - .pipeThrough(transform) - .pipeThrough(new AssistantMetaTransformStream()) - .pipeTo(new WritableStream({ -- write(chunk) { -- if (chunk.type === "result") { -- // the tool call result was already set by the backend -- if (lastToolStates.current[chunk.meta.toolCallId]?.hasResult) -- return; -- onResult({ -- type: "add-tool-result", -- toolCallId: chunk.meta.toolCallId, -- toolName: chunk.meta.toolName, -- result: chunk.result, -- isError: chunk.isError, -- ...(chunk.artifact && { artifact: chunk.artifact }), -- }); -- } -- }, -- })); -+ write(chunk) { -+ if (chunk.type === "result") { -+ // the tool call result was already set by the backend -+ if (lastToolStates.current[chunk.meta.toolCallId]?.hasResult) -+ return; -+ onResult({ -+ type: "add-tool-result", -+ toolCallId: chunk.meta.toolCallId, -+ toolName: chunk.meta.toolName, -+ result: chunk.result, -+ isError: chunk.isError, -+ ...(chunk.artifact && { artifact: chunk.artifact }), -+ }); -+ } -+ }, -+ })); - return controller; - }); - const ignoredToolIds = useRef(new Set()); -@@ -117,21 +117,49 @@ export function useToolInvocations({ state, getTools, onResult, setToolStatuses, - } - else { - if (!content.argsText.startsWith(lastState.argsText)) { -- // Check if this is key reordering (both are complete JSON) -+ // Check if new argsText is complete JSON (handles key reordering) - // This happens when transitioning from streaming to complete state - // and the provider returns keys in a different order -- if (isArgsTextComplete(lastState.argsText) && -- isArgsTextComplete(content.argsText)) { -- lastState.controller.argsText.close(); -- lastToolStates.current[content.toolCallId] = { -- argsText: content.argsText, -- hasResult: lastState.hasResult, -- argsComplete: true, -- controller: lastState.controller, -- }; -- return; // Continue to next content part -+ if (isArgsTextComplete(content.argsText)) { -+ try { -+ const newArgs = JSON.parse(content.argsText); -+ -+ // If old is also complete, verify they're equivalent (just reordered) -+ if (isArgsTextComplete(lastState.argsText)) { -+ const oldArgs = JSON.parse(lastState.argsText); -+ // Normalize both by sorting keys and compare -+ const oldNormalized = JSON.stringify(oldArgs, Object.keys(oldArgs).sort()); -+ const newNormalized = JSON.stringify(newArgs, Object.keys(newArgs).sort()); -+ -+ if (oldNormalized === newNormalized) { -+ // Same data, just reordered - accept it -+ lastState.controller.argsText.close(); -+ lastToolStates.current[content.toolCallId] = { -+ argsText: content.argsText, -+ hasResult: lastState.hasResult, -+ argsComplete: true, -+ controller: lastState.controller, -+ }; -+ return; // Continue to next content part -+ } -+ } else { -+ // Old is incomplete, but new is complete -+ // Accept the new complete version (it's the final state) -+ lastState.controller.argsText.close(); -+ lastToolStates.current[content.toolCallId] = { -+ argsText: content.argsText, -+ hasResult: lastState.hasResult, -+ argsComplete: true, -+ controller: lastState.controller, -+ }; -+ return; // Continue to next content part -+ } -+ } catch (e) { -+ // Not valid JSON, fall through to error -+ } - } -- throw new Error(`Tool call argsText can only be appended, not updated: ${content.argsText} does not start with ${lastState.argsText}`); -+ throw new Error(` -+Tool call argsText can only be appended, not updated: ${content.argsText} does not start with ${lastState.argsText}`); - } - const argsTextDelta = content.argsText.slice(lastState.argsText.length); - lastState.controller.argsText.append(argsTextDelta); diff --git a/src/components/chat/CardContextDisplay.tsx b/src/components/chat/CardContextDisplay.tsx index 59cb3f2e..2dbb95e7 100644 --- a/src/components/chat/CardContextDisplay.tsx +++ b/src/components/chat/CardContextDisplay.tsx @@ -22,6 +22,7 @@ function CardContextDisplayImpl({ items }: CardContextDisplayProps) { useShallow(selectSelectedCardIdsArray) ); const selectedCardIds = useMemo(() => new Set(selectedCardIdsArray), [selectedCardIdsArray]); + const activePdfPageByItemId = useUIStore((state) => state.activePdfPageByItemId); const toggleCardSelection = useUIStore((state) => state.toggleCardSelection); const clearBlockNoteSelection = useUIStore((state) => state.clearBlockNoteSelection); const blockNoteSelection = useUIStore(selectBlockNoteSelection); @@ -114,10 +115,17 @@ function CardContextDisplayImpl({ items }: CardContextDisplayProps) {
- {/* Card Title */} + {/* Card Title + Page number for PDFs */} {item.name || "Untitled"} + {item.type === "pdf" && + activePdfPageByItemId[item.id] != null && + activePdfPageByItemId[item.id] >= 1 && ( + + p.{activePdfPageByItemId[item.id]} + + )}
))}
From cefa2ef3d605330aaf8b5ddf6e1e0646ed8fb4ff Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:54:11 -0500 Subject: [PATCH 47/56] Update package.json --- package.json | 74 ++++++++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/package.json b/package.json index 22e6629e..29c2c906 100644 --- a/package.json +++ b/package.json @@ -27,19 +27,19 @@ }, "packageManager": "pnpm@10.14.0", "dependencies": { - "@ai-sdk/devtools": "^0.0.12", - "@ai-sdk/gateway": "^3.0.39", + "@ai-sdk/devtools": "^0.0.15", + "@ai-sdk/gateway": "^3.0.53", "@ai-sdk/google": "^3.0.30", "@assistant-ui/react": "0.12.11", - "@assistant-ui/react-ai-sdk": "^1.3.6", - "@assistant-ui/react-devtools": "^0.2.3", + "@assistant-ui/react-ai-sdk": "^1.3.8", + "@assistant-ui/react-devtools": "^1.0.0", "@assistant-ui/react-markdown": "^0.12.4", - "@blocknote/code-block": "^0.46.2", - "@blocknote/core": "^0.46.2", - "@blocknote/react": "^0.46.2", - "@blocknote/server-util": "^0.46.2", - "@blocknote/shadcn": "^0.46.2", - "@dnd-kit/react": "^0.2.4", + "@blocknote/code-block": "^0.47.0", + "@blocknote/core": "^0.47.0", + "@blocknote/react": "^0.47.0", + "@blocknote/server-util": "^0.47.0", + "@blocknote/shadcn": "^0.47.0", + "@dnd-kit/react": "^0.3.2", "@embedpdf/core": "^2.6.2", "@embedpdf/engines": "^2.6.2", "@embedpdf/models": "^2.6.2", @@ -60,13 +60,13 @@ "@embedpdf/plugin-tiling": "^2.6.2", "@embedpdf/plugin-viewport": "^2.6.2", "@embedpdf/plugin-zoom": "^2.6.2", - "@floating-ui/react": "^0.27.17", - "@google/genai": "^1.40.0", + "@floating-ui/react": "^0.27.18", + "@google/genai": "^1.42.0", "@gsap/react": "^2.1.2", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.2.2", - "@lottiefiles/dotlottie-react": "^0.17.14", - "@posthog/ai": "^7.8.8", + "@lottiefiles/dotlottie-react": "^0.18.2", + "@posthog/ai": "^7.9.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-avatar": "^1.1.11", @@ -86,17 +86,17 @@ "@radix-ui/react-tooltip": "1.2.8", "@shikijs/themes": "^3.22.0", "@shikijs/types": "^3.22.0", - "@streamdown/code": "^1.0.2", + "@streamdown/code": "^1.0.3", "@streamdown/math": "^1.0.2", "@streamdown/mermaid": "^1.0.2", - "@supabase/supabase-js": "^2.95.3", + "@supabase/supabase-js": "^2.97.0", "@tanstack/react-query": "^5.90.20", "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-virtual": "^3.13.14", + "@tanstack/react-virtual": "^3.13.19", "@vercel/speed-insights": "^1.3.1", "ai": "^6.0.97", "assistant-stream": "^0.3.3", - "better-auth": "^1.4.18", + "better-auth": "^1.4.19", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.1.1", @@ -107,43 +107,43 @@ "geist": "^1.7.0", "gsap": "^3.14.2", "input-otp": "1.4.2", - "katex": "^0.16.28", - "lucide-react": "^0.563.0", + "katex": "^0.16.33", + "lucide-react": "^0.575.0", "mathlive": "^0.108.2", - "mermaid": "^11.12.2", - "motion": "^12.34.0", + "mermaid": "^11.12.3", + "motion": "^12.34.3", "next": "16.1.6", "next-themes": "^0.4.6", "parse-diff": "^0.11.1", "pdf-lib": "^1.17.1", "postgres": "^3.4.7", - "posthog-js": "^1.345.0", - "posthog-node": "^5.24.14", - "prosemirror-highlight": "^0.13.0", + "posthog-js": "^1.353.0", + "posthog-node": "^5.25.0", + "prosemirror-highlight": "^0.15.0", "prosemirror-search": "^1.1.0", "radix-ui": "^1.4.3", "react": "19.2.4", "react-color": "^2.19.3", "react-dom": "19.2.4", - "react-dropzone": "^14.4.0", + "react-dropzone": "^15.0.0", "react-grid-layout": "git+https://github.com/ThinkEx-OSS/react-grid-layout.git", "react-helmet-async": "^2.0.5", - "react-hook-form": "^7.69.0", + "react-hook-form": "^7.71.2", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-quizlet-flashcard": "^4.0.22", - "react-resizable-panels": "^4.6.2", + "react-resizable-panels": "^4.6.5", "react-shiki": "^0.9.1", "react-speech-recognition": "^4.0.1", "regenerator-runtime": "^0.14.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "resend": "^4.1.2", + "resend": "^6.9.2", "shiki": "^3.22.0", "sonner": "^2.0.7", - "streamdown": "^2.2.0", - "tailwind-merge": "^3.4.0", + "streamdown": "^2.3.0", + "tailwind-merge": "^3.5.0", "thinkex": "link:", "tw-shimmer": "^0.4.6", "workflow": "4.1.0-beta.60", @@ -151,10 +151,10 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.3", - "@tailwindcss/postcss": "^4.1.18", + "@eslint/eslintrc": "^3.3.4", + "@tailwindcss/postcss": "^4.2.1", "@types/bun": "^1.3.8", - "@types/node": "^24.10.4", + "@types/node": "^25.3.0", "@types/pg": "^8.16.0", "@types/react": "19.2.13", "@types/react-color": "^3.0.13", @@ -164,13 +164,13 @@ "babel-plugin-react-compiler": "^1.0.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "eslint": "^9.39.2", + "eslint": "^9.39.3", "eslint-config-next": "16.1.6", "geist": "^1.4.2", - "knip": "^5.83.1", + "knip": "^5.85.0", "postinstall-postinstall": "^2.1.0", "prettier": "^3.8.1", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.1", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3" From 4c6d5c01f5b7b9a279173bf11425b7efd5d46bb7 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:56:47 -0500 Subject: [PATCH 48/56] fix: types --- src/lib/chat/custom-thread-history-adapter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/chat/custom-thread-history-adapter.tsx b/src/lib/chat/custom-thread-history-adapter.tsx index d28824bd..fe464c14 100644 --- a/src/lib/chat/custom-thread-history-adapter.tsx +++ b/src/lib/chat/custom-thread-history-adapter.tsx @@ -71,7 +71,7 @@ export function useCustomThreadHistoryAdapter(): ThreadHistoryAdapter { async append() { // No-op: ExternalStoreRuntime uses withFormat for persistence }, - withFormat( + withFormat>( formatAdapter: MessageFormatAdapter ): GenericThreadHistoryAdapter { return { From b6db8d6904dab822a26a379e3a1e0bffc59381c7 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:14:49 -0500 Subject: [PATCH 49/56] fix: tools --- .../assistant-ui/FileProcessingToolUI.tsx | 22 +-------- src/lib/ai/tools/flashcard-tools.ts | 6 +-- src/lib/ai/tools/process-files.ts | 47 +++++++------------ src/lib/ai/tools/quiz-tools.ts | 6 +-- src/lib/ai/tools/tool-utils.ts | 25 ++++++++++ src/lib/ai/tools/workspace-tools.ts | 31 ++++-------- 6 files changed, 57 insertions(+), 80 deletions(-) diff --git a/src/components/assistant-ui/FileProcessingToolUI.tsx b/src/components/assistant-ui/FileProcessingToolUI.tsx index 3ba9a76c..5a721994 100644 --- a/src/components/assistant-ui/FileProcessingToolUI.tsx +++ b/src/components/assistant-ui/FileProcessingToolUI.tsx @@ -1,6 +1,6 @@ "use client"; -import { FileIcon, ChevronDownIcon, CheckIcon, ExternalLinkIcon, AlertCircleIcon, FileTextIcon, VideoIcon, ImageIcon } from "lucide-react"; +import { FileIcon, ChevronDownIcon, FileTextIcon, VideoIcon, ImageIcon } from "lucide-react"; import { memo, useCallback, @@ -32,7 +32,7 @@ import { Badge } from "@/components/ui/badge"; const ANIMATION_DURATION = 200; const SHIMMER_DURATION = 1000; -const MAX_DISPLAY_CHARS = 3000; +const MAX_DISPLAY_CHARS = 800; /** * Root collapsible container that manages open/closed state and scroll lock. @@ -226,7 +226,6 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ urls?: string[]; fileNames?: string[]; pdfImageRefs?: Array<{ pdfName: string; imageId: string }>; - instruction?: string; forceReprocess?: boolean; }, string>({ toolName: "processFiles", @@ -266,7 +265,6 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ // Parse args let urls: string[] = []; let pdfImageRefs: Array<{ pdfName: string; imageId: string }> = []; - let instruction: string | undefined; // Use the actual tool parameters if (args?.urls && Array.isArray(args.urls)) { @@ -275,9 +273,6 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ if (args?.pdfImageRefs && Array.isArray(args.pdfImageRefs)) { pdfImageRefs = args.pdfImageRefs; } - if (args?.instruction) { - instruction = args.instruction; - } const fileCount = urls.length + (args?.fileNames?.length ?? 0) + pdfImageRefs.length; @@ -287,8 +282,6 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ fileCount, urls, fileNames: args?.fileNames, - instruction, - forceReprocess: args?.forceReprocess, resultLength: result?.length || 0, isRunning, isComplete, @@ -312,17 +305,6 @@ export const FileProcessingToolUI = makeAssistantToolUI<{ Files: - {instruction && ( -
-
- -
- Custom instruction: -

{instruction}

-
-
-
- )}
{urls.map((url, index) => { const fileInfo = getFileType(url); diff --git a/src/lib/ai/tools/flashcard-tools.ts b/src/lib/ai/tools/flashcard-tools.ts index fae837a6..ed173556 100644 --- a/src/lib/ai/tools/flashcard-tools.ts +++ b/src/lib/ai/tools/flashcard-tools.ts @@ -3,7 +3,7 @@ import { tool, zodSchema } from "ai"; import { logger } from "@/lib/utils/logger"; import { workspaceWorker } from "@/lib/ai/workers"; import type { WorkspaceToolContext } from "./workspace-tools"; -import { loadStateForTool, fuzzyMatchItem, getAvailableItemsList } from "./tool-utils"; +import { loadStateForTool, resolveItem, getAvailableItemsList } from "./tool-utils"; /** * Create the createFlashcards tool @@ -121,8 +121,8 @@ export function createUpdateFlashcardsTool(ctx: WorkspaceToolContext) { const { state } = accessResult; - // Fuzzy match the deck by name - const matchedDeck = fuzzyMatchItem(state.items, deckName, "flashcard"); + // Resolve by virtual path or fuzzy name match + const matchedDeck = resolveItem(state.items, deckName, "flashcard"); if (!matchedDeck) { const availableDecks = getAvailableItemsList(state.items, "flashcard"); diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts index f0875252..0f4c830b 100644 --- a/src/lib/ai/tools/process-files.ts +++ b/src/lib/ai/tools/process-files.ts @@ -3,7 +3,7 @@ import { generateText, tool, zodSchema } from "ai"; import { z } from "zod"; import { logger } from "@/lib/utils/logger"; import { headers } from "next/headers"; -import { loadStateForTool, fuzzyMatchItem } from "./tool-utils"; +import { loadStateForTool, resolveItem } from "./tool-utils"; import type { WorkspaceToolContext } from "./workspace-tools"; import type { Item, PdfData } from "@/lib/workspace-state/types"; import { workspaceWorker } from "@/lib/ai/workers"; @@ -86,10 +86,7 @@ function buildFileProcessingPrompt( /** * Process Supabase storage files by sending URLs directly to Gemini */ -async function processSupabaseFiles( - supabaseUrls: string[], - instruction?: string -): Promise { +async function processSupabaseFiles(supabaseUrls: string[]): Promise { const fileInfos: FileInfo[] = supabaseUrls.map((fileUrl: string) => { const filename = decodeURIComponent(fileUrl.split('/').pop() || 'file'); const mediaType = getMediaTypeFromUrl(fileUrl); @@ -99,9 +96,7 @@ async function processSupabaseFiles( const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename}`).join('\n'); const { defaultInstruction, outputFormat } = buildFileProcessingPrompt(fileInfos); - const batchPrompt = instruction - ? `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${instruction}\n\n${outputFormat}` - : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`; + const batchPrompt = `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`; const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [ { type: "text", text: batchPrompt }, @@ -177,13 +172,12 @@ type PdfImageRef = { pdfName: string; imageId: string }; */ async function processPdfImages( pdfImageRefs: PdfImageRef[], - stateItems: Item[], - instruction?: string + stateItems: Item[] ): Promise { const fileInfos: Array<{ filename: string; mediaType: string; data: string }> = []; for (const ref of pdfImageRefs) { - const pdfItem = fuzzyMatchItem(stateItems, ref.pdfName); + const pdfItem = resolveItem(stateItems, ref.pdfName, "pdf"); if (!pdfItem || pdfItem.type !== "pdf") continue; const pdfData = pdfItem.data as PdfData; @@ -218,9 +212,7 @@ async function processPdfImages( const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename} (from PDF)`).join("\n"); const { defaultInstruction, outputFormat } = buildFileProcessingPrompt(fileInfos); - const batchPrompt = instruction - ? `Analyze the following ${fileInfos.length} image(s) extracted from PDFs:\n${fileListText}\n\n${instruction}\n\n${outputFormat}` - : `Analyze the following ${fileInfos.length} image(s) extracted from PDFs:\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`; + const batchPrompt = `Analyze the following ${fileInfos.length} image(s) extracted from PDFs:\n${fileListText}\n\n${defaultInstruction}\n\n${outputFormat}`; const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [ { type: "text", text: batchPrompt }, @@ -243,15 +235,10 @@ async function processPdfImages( /** * Process a YouTube video using Gemini's native video support */ -async function processYouTubeVideo( - youtubeUrl: string, - instruction?: string -): Promise { +async function processYouTubeVideo(youtubeUrl: string): Promise { logger.debug("📁 [FILE_TOOL] Processing YouTube URL natively:", youtubeUrl); - const videoPrompt = instruction - ? `Analyze this video. ${instruction}\n\nFormat your response as:\n**Summary:** [2-3 sentences]\n**Key points:** [bullet list]` - : `Analyze this video. Extract and summarize main topics, key points, important details, and any specific data or insights.\n\nFormat your response as:\n**Summary:** [2-3 sentences]\n**Key points:** [bullet list]`; + const videoPrompt = `Analyze this video. Extract and summarize main topics, key points, important details, and any specific data or insights.\n\nFormat your response as:\n**Summary:** [2-3 sentences]\n**Key points:** [bullet list]`; const { text: videoAnalysis } = await generateText({ model: google("gemini-2.5-flash-lite"), @@ -281,16 +268,15 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { inputSchema: zodSchema( z.object({ urls: z.array(z.string()).optional().describe("Array of file/video URLs to process (Supabase storage URLs or YouTube URLs)"), - fileNames: z.array(z.string()).optional().describe("Array of workspace item names to look up via fuzzy match (e.g. 'Annual Report')"), + fileNames: z.array(z.string()).optional().describe("Workspace item names or virtual paths (e.g. 'Annual Report' or pdfs/Annual Report.pdf)"), pdfImageRefs: z.array(z.object({ - pdfName: z.string().describe("Name of the PDF workspace item (fuzzy matched)"), + pdfName: z.string().describe("PDF name or virtual path (e.g. pdfs/Syllabus.pdf)"), imageId: z.string().describe("Image placeholder ID from PDF (e.g. img-0.jpeg)"), })).optional().describe("Images from PDFs — map placeholder names to base64 for analysis"), - instruction: z.string().optional().describe("Custom instruction for what to extract or focus on during analysis"), forceReprocess: z.boolean().optional().describe("Set to true to bypass cached PDF content and re-analyze the file"), }) ), - execute: async ({ urls, fileNames: fileNamesInput, pdfImageRefs, instruction, forceReprocess: forceReprocessInput }) => { + execute: async ({ urls, fileNames: fileNamesInput, pdfImageRefs, forceReprocess: forceReprocessInput }) => { let urlList = urls || []; const fileNames = fileNamesInput || []; const forceReprocess = forceReprocessInput === true; @@ -307,8 +293,8 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { const notFoundData: string[] = []; for (const name of fileNames) { - // Try to match any item type that might contain a file - const matchedItem = fuzzyMatchItem(state.items, name); + // Try to match by virtual path or name (any file-like type) + const matchedItem = resolveItem(state.items, name); if (matchedItem) { if (matchedItem.type === 'pdf') { @@ -394,8 +380,7 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { if (accessResult.success) { const result = await processPdfImages( pdfImageRefs, - accessResult.state.items, - instruction + accessResult.state.items ); pdfImageResults.push(result); } @@ -451,7 +436,7 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { // Handle Supabase file URLs if (supabaseUrls.length > 0) { processingPromises.push( - processSupabaseFiles(supabaseUrls, instruction) + processSupabaseFiles(supabaseUrls) .then(result => result) .catch(error => { logger.error("📁 [FILE_TOOL] Error in Supabase file processing:", error); @@ -469,7 +454,7 @@ export function createProcessFilesTool(ctx?: WorkspaceToolContext) { // Handle YouTube videos if (youtubeUrls.length > 0) { processingPromises.push( - processYouTubeVideo(youtubeUrls[0], instruction) + processYouTubeVideo(youtubeUrls[0]) .then(result => result) .catch(videoError => { logger.error("📁 [FILE_TOOL] Error processing YouTube video:", { diff --git a/src/lib/ai/tools/quiz-tools.ts b/src/lib/ai/tools/quiz-tools.ts index a5629627..028c5144 100644 --- a/src/lib/ai/tools/quiz-tools.ts +++ b/src/lib/ai/tools/quiz-tools.ts @@ -9,7 +9,7 @@ import { logger } from "@/lib/utils/logger"; import { quizWorker, workspaceWorker } from "@/lib/ai/workers"; import type { WorkspaceToolContext } from "./workspace-tools"; import type { QuizData } from "@/lib/workspace-state/types"; -import { loadStateForTool, fuzzyMatchItem, getAvailableItemsList } from "./tool-utils"; +import { loadStateForTool, resolveItem, getAvailableItemsList } from "./tool-utils"; /** * Create the createQuiz tool @@ -154,8 +154,8 @@ export function createUpdateQuizTool(ctx: WorkspaceToolContext) { const { state } = accessResult; - // Fuzzy match the quiz by name - const quizItem = fuzzyMatchItem(state.items, quizName, "quiz"); + // Resolve by virtual path or fuzzy name match + const quizItem = resolveItem(state.items, quizName, "quiz"); if (!quizItem) { const availableQuizzes = getAvailableItemsList(state.items, "quiz"); diff --git a/src/lib/ai/tools/tool-utils.ts b/src/lib/ai/tools/tool-utils.ts index 5b659228..5f37caa6 100644 --- a/src/lib/ai/tools/tool-utils.ts +++ b/src/lib/ai/tools/tool-utils.ts @@ -5,6 +5,7 @@ import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import type { Item } from "@/lib/workspace-state/types"; import type { WorkspaceToolContext } from "./workspace-tools"; +import { resolveItemByPath } from "./workspace-search-utils"; /** * Load workspace state for tool operations @@ -21,6 +22,30 @@ export async function loadStateForTool( return { success: true, state }; } +/** + * Resolve an item by virtual path or fuzzy name match. + * Tries virtual path first when input looks like a path (contains /), + * then falls back to fuzzyMatchItem for plain names. + * If itemType is provided, only returns items of that type. + */ +export function resolveItem( + items: Item[], + input: string, + itemType?: Item["type"] +): Item | undefined { + const trimmed = input.trim(); + if (!trimmed) return undefined; + + // 1. Try virtual path first when input looks like a path + if (trimmed.includes("/")) { + const byPath = resolveItemByPath(items, trimmed); + if (byPath && (!itemType || byPath.type === itemType)) return byPath; + } + + // 2. Fall back to fuzzy name match + return fuzzyMatchItem(items, trimmed, itemType); +} + /** * Fuzzy match an item by name within a list of items * Tries: exact match -> contains match -> reverse contains match diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 93d20bd7..44e22c35 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -4,7 +4,7 @@ import { logger } from "@/lib/utils/logger"; import { workspaceWorker } from "@/lib/ai/workers"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import type { Item } from "@/lib/workspace-state/types"; -import { loadStateForTool, fuzzyMatchItem, getAvailableItemsList } from "./tool-utils"; +import { loadStateForTool, resolveItem, getAvailableItemsList } from "./tool-utils"; export interface WorkspaceToolContext { workspaceId: string | null; @@ -154,7 +154,7 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { } const { state } = accessResult; - const matchedNote = fuzzyMatchItem(state.items, noteName, "note"); + const matchedNote = resolveItem(state.items, noteName, "note"); if (!matchedNote) { const availableNotes = getAvailableItemsList(state.items, "note"); @@ -210,7 +210,7 @@ export function createDeleteItemTool(ctx: WorkspaceToolContext) { description: "Permanently delete a card/note from the workspace by name.", inputSchema: zodSchema( z.object({ - itemName: z.string().describe("The name of the item to delete (will be matched using fuzzy search)"), + itemName: z.string().describe("Item name or virtual path (e.g. pdfs/Report.pdf) to delete"), }) ), execute: async ({ itemName }) => { @@ -232,8 +232,8 @@ export function createDeleteItemTool(ctx: WorkspaceToolContext) { const { state } = accessResult; - // Fuzzy match the item by name (any type) - const matchedItem = fuzzyMatchItem(state.items, itemName); + // Resolve by virtual path or fuzzy name match (any type) + const matchedItem = resolveItem(state.items, itemName); if (!matchedItem) { const availableItems = state.items.map(i => `"${i.name}" (${i.type})`).slice(0, 5).join(", "); @@ -315,28 +315,13 @@ export function createSelectCardsTool(ctx: WorkspaceToolContext) { }; } - // Perform fuzzy matching (matching client-side logic) - // 1. Exact match first - // 2. Contains match if no exact match + // Resolve by virtual path or fuzzy name match const selectedItems: Item[] = []; const processedIds = new Set(); for (const title of cardTitles) { - const searchTitle = title.toLowerCase().trim(); - - // Try exact match first - let match = state.items.find( - item => item.name.toLowerCase().trim() === searchTitle && !processedIds.has(item.id) - ); - - // If no exact match, try contains match - if (!match) { - match = state.items.find( - item => item.name.toLowerCase().includes(searchTitle) && !processedIds.has(item.id) - ); - } - - if (match) { + const match = resolveItem(state.items, title); + if (match && !processedIds.has(match.id)) { selectedItems.push(match); processedIds.add(match.id); } From bad9c87d51a95260f40b33a7dbda0803cbea213e Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:25:33 -0500 Subject: [PATCH 50/56] feat: smooth reasoning --- src/components/assistant-ui/markdown-text.tsx | 14 +++- src/components/assistant-ui/reasoning.tsx | 76 +++++++++++++------ src/components/pdf/AppPdfViewer.tsx | 24 ++++-- 3 files changed, 81 insertions(+), 33 deletions(-) diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index fc4355ae..78df0530 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -161,7 +161,12 @@ const CitationRenderer = memo( ); CitationRenderer.displayName = "CitationRenderer"; -const MarkdownTextImpl = () => { +interface MarkdownTextProps { + /** Use "reasoning" for smoother streaming in reasoning blocks (blurIn, longer duration) */ + streamingVariant?: "default" | "reasoning"; +} + +const MarkdownTextImpl = ({ streamingVariant = "default" }: MarkdownTextProps) => { // Get the text content from assistant-ui context const { text } = useMessagePartText(); @@ -172,6 +177,11 @@ const MarkdownTextImpl = () => { // Check if the message is currently streaming const isRunning = useAuiState(({ thread }) => (thread as any)?.isRunning ?? false); + const animateConfig = + streamingVariant === "reasoning" + ? { animation: "blurIn" as const, duration: 250, easing: "ease-out" } + : { animation: "fadeIn" as const, duration: 200, easing: "ease-out" }; + const containerRef = useRef(null); // Combine thread and message ID for unique key per message @@ -247,7 +257,7 @@ const MarkdownTextImpl = () => {
) { - return ( -
- ); -} +const ReasoningText = forwardRef>( + function ReasoningText({ className, ...props }, ref) { + return ( +
+ ); + } +); -const ReasoningImpl: ReasoningMessagePartComponent = () => ; +const ReasoningImpl: ReasoningMessagePartComponent = () => ( + +); const ReasoningGroupImpl: ReasoningGroupComponent = ({ children, startIndex, endIndex, }) => { + const textContainerRef = useRef(null); const isReasoningStreaming = useAuiState(({ message }) => { if (message.status?.type !== "running") return false; const lastIndex = message.parts.length - 1; @@ -229,9 +235,31 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ return lastIndex >= startIndex && lastIndex <= endIndex; }); + // Subscribe to reasoning text length so we re-run scroll effect on each stream chunk + const reasoningTextSnapshot = useAuiState(({ message }) => { + let len = 0; + for (let i = startIndex; i <= endIndex && i < message.parts.length; i++) { + const p = message.parts[i] as { type?: string; text?: string } | undefined; + if (p?.type === "reasoning" && typeof p.text === "string") len += p.text.length; + } + return len; + }); + const [isManuallyOpen, setIsManuallyOpen] = useState(false); const isOpen = isReasoningStreaming || isManuallyOpen; + // Auto-scroll to bottom as reasoning streams (like assistant-ui Viewport autoScroll) + // reasoningTextSnapshot ensures we run on every stream chunk + useLayoutEffect(() => { + if (!isReasoningStreaming || !textContainerRef.current) return; + const el = textContainerRef.current; + // Only skip scroll if user has clearly scrolled up (e.g. >80px from bottom) + const fromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (fromBottom <= 80) { + el.scrollTop = el.scrollHeight; + } + }, [isReasoningStreaming, reasoningTextSnapshot]); + // Auto-collapse when streaming finishes useEffect(() => { if (!isReasoningStreaming) { @@ -251,7 +279,7 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ - {children} + {children} ); diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx index 0546c7e9..49904a3f 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -60,6 +60,7 @@ const PdfStatePersister = ({ documentId, pdfSrc, itemId }: { documentId: string; const { state: zoomState, provides: zoomScope } = useZoom(documentId); const { state: scrollState } = useScroll(documentId); const debounceRef = useRef(undefined); + const restoreTimeoutRef = useRef | null>(null); const lastSavedRef = useRef(''); const storageKey = `${PDF_STATE_PREFIX}${encodeURIComponent(pdfSrc)}`; @@ -122,8 +123,12 @@ const PdfStatePersister = ({ documentId, pdfSrc, itemId }: { documentId: string; const state: PdfSavedState = JSON.parse(saved); // Delay restoration to ensure it runs AFTER the plugin's default zoom is applied - // The plugin applies defaultZoomLevel: ZoomMode.FitWidth after document loads - setTimeout(() => { + restoreTimeoutRef.current = setTimeout(() => { + // Re-check citation (may have been set after our effect ran) + if (itemId && useUIStore.getState().citationHighlightQuery?.itemId === itemId) { + restoreCompleted.add(restoreKey); + return; + } // Restore zoom if (typeof state.zoom === 'number' && state.zoom > 0) { zoomScope.requestZoom(state.zoom); @@ -131,16 +136,17 @@ const PdfStatePersister = ({ documentId, pdfSrc, itemId }: { documentId: string; // Restore scroll after zoom settles setTimeout(() => { + if (itemId && useUIStore.getState().citationHighlightQuery?.itemId === itemId) { + restoreCompleted.add(restoreKey); + return; + } const viewport = viewportCapability.forDocument(documentId); if (viewport) { viewport.scrollTo({ x: state.scrollLeft, y: state.scrollTop, behavior: 'instant' }); } - // Mark restore as complete AFTER scroll is applied - setTimeout(() => { - restoreCompleted.add(restoreKey); - }, 100); + setTimeout(() => restoreCompleted.add(restoreKey), 100); }, 200); - }, 300); // Wait for default zoom to be applied first + }, 300); } else { // No saved state, or citation is opening this PDF (citation scroll takes priority) restoreCompleted.add(restoreKey); @@ -152,6 +158,10 @@ const PdfStatePersister = ({ documentId, pdfSrc, itemId }: { documentId: string; // Cleanup on unmount return () => { + if (restoreTimeoutRef.current) { + clearTimeout(restoreTimeoutRef.current); + restoreTimeoutRef.current = null; + } saveCurrentState(); }; }, [viewportCapability, zoomScope, documentId, pdfSrc, storageKey, restoreKey, saveCurrentState, itemId]); From 729b3d78188bda128f82d845e78d67c7f8137c99 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:30:04 -0500 Subject: [PATCH 51/56] Update ui-store.ts --- src/lib/stores/ui-store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index ef521345..58e2c3c5 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -611,7 +611,7 @@ export const useUIStore = create()( }), }), { - name: 'thinkex-ui-preferences', + name: 'thinkex-ui-preferences-v2', storage: createJSONStorage(() => localStorage), partialize: (state) => ({ selectedModelId: state.selectedModelId }), }, From a117705651aa7cfcacc9e46931fca859c2924faa Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:45:36 -0500 Subject: [PATCH 52/56] fix: home page --- src/app/globals.css | 18 +++++++++++ src/components/home/HomeActionCards.tsx | 42 +++++++++++++++---------- src/components/home/HomeContent.tsx | 38 ++++++---------------- src/components/home/HomePromptInput.tsx | 2 +- 4 files changed, 54 insertions(+), 46 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index e1d6f83e..4a37db2c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -617,6 +617,24 @@ html { } } +/* Home action card icon hover animations - unified 0.8s ease-in-out, moderate intensity */ +@keyframes icon-upload-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } +} +@keyframes icon-link-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(0.92); } +} +@keyframes icon-link-sway { + 0%, 100% { transform: rotate(-4deg); } + 50% { transform: rotate(4deg); } +} +@keyframes icon-paste-pop { + 0%, 100% { transform: translateX(0); } + 50% { transform: translateX(2px); } +} + /* Subtle pulse animation for the add button */ .animate-pulse-subtle { animation: pulseSubtle 2s ease-in-out infinite; diff --git a/src/components/home/HomeActionCards.tsx b/src/components/home/HomeActionCards.tsx index 544d43b7..0e6fe3d8 100644 --- a/src/components/home/HomeActionCards.tsx +++ b/src/components/home/HomeActionCards.tsx @@ -1,30 +1,42 @@ -import { Upload, Link as LinkIcon, ClipboardPaste, Mic, FolderPlus, Loader2 } from "lucide-react"; +import { Upload, Link as LinkIcon, ClipboardPaste, Mic, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +type HoverVariant = "upload" | "link" | "paste" | "record"; + +const HOVER_VARIANT_STYLES: Record = { + upload: "hover:border-emerald-500/60 hover:bg-emerald-500/5 hover:shadow-[0_0_24px_-4px_rgba(16,185,129,0.35)] [&:hover_.action-icon]:text-emerald-600 dark:[&:hover_.action-icon]:text-emerald-400 [&:hover_.action-title]:text-emerald-700 dark:[&:hover_.action-title]:text-emerald-300 [&:hover_.action-icon]:animate-[icon-upload-bounce_0.8s_ease-in-out_infinite]", + link: "hover:border-blue-500/60 hover:bg-blue-500/5 hover:shadow-[0_0_24px_-4px_rgba(59,130,246,0.35)] [&:hover_.action-icon]:text-blue-600 dark:[&:hover_.action-icon]:text-blue-400 [&:hover_.action-title]:text-blue-700 dark:[&:hover_.action-title]:text-blue-300 [&:hover_.action-icon]:animate-[icon-link-sway_0.8s_ease-in-out_infinite]", + paste: "hover:border-amber-500/60 hover:bg-amber-500/5 hover:shadow-[0_0_24px_-4px_rgba(245,158,11,0.35)] [&:hover_.action-icon]:text-amber-600 dark:[&:hover_.action-icon]:text-amber-400 [&:hover_.action-title]:text-amber-700 dark:[&:hover_.action-title]:text-amber-300 [&:hover_.action-icon]:animate-[icon-paste-pop_0.8s_ease-in-out_infinite]", + record: "hover:border-rose-500/60 hover:bg-rose-500/5 hover:shadow-[0_0_24px_-4px_rgba(244,63,94,0.35)] [&:hover_.action-icon]:text-rose-600 dark:[&:hover_.action-icon]:text-rose-400 [&:hover_.action-title]:text-rose-700 dark:[&:hover_.action-title]:text-rose-300 [&:hover_.action-icon]:animate-[icon-link-pulse_0.8s_ease-in-out_infinite]", +}; + interface ActionCardProps { icon: React.ReactNode; title: string; subtitle: string; onClick?: () => void; isLoading?: boolean; + hoverVariant?: HoverVariant; /** When set, renders as label for native file picker—avoids JS round-trip and OS delay feels shorter */ htmlFor?: string; } -function ActionCard({ icon, title, subtitle, onClick, isLoading, htmlFor }: ActionCardProps) { +function ActionCard({ icon, title, subtitle, onClick, isLoading, hoverVariant = "upload", htmlFor }: ActionCardProps) { const sharedClassName = cn( - "flex flex-col items-start gap-2 p-4 min-h-[88px] w-full rounded-2xl border bg-white dark:bg-sidebar backdrop-blur-xl hover:bg-neutral-50 dark:hover:bg-accent hover:text-accent-foreground hover:scale-[1.02] active:scale-[0.98] transition-all duration-200 text-left cursor-pointer", + "group flex flex-col items-start gap-2 p-4 min-h-[88px] w-full rounded-2xl border bg-white dark:bg-sidebar backdrop-blur-xl", + "hover:scale-[1.02] hover:-translate-y-0.5 active:scale-[0.98] active:translate-y-0 transition-all duration-300 ease-out text-left cursor-pointer", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2", - "disabled:pointer-events-none disabled:opacity-50" + "disabled:pointer-events-none disabled:opacity-50", + !isLoading && HOVER_VARIANT_STYLES[hoverVariant] ); const content = ( <> -
+
{icon}
-
{title}
+
{title}
{subtitle}
@@ -55,21 +67,21 @@ interface HomeActionCardsProps { onLink: () => void; onPasteText: () => void; onRecord: () => void; - onStartFromScratch: () => void; isLoading?: boolean; /** ID of the hidden file input—enables native label click for instant file picker */ uploadInputId?: string; } -export function HomeActionCards({ onUpload, onLink, onPasteText, onRecord, onStartFromScratch, isLoading, uploadInputId }: HomeActionCardsProps) { +export function HomeActionCards({ onUpload, onLink, onPasteText, onRecord, isLoading, uploadInputId }: HomeActionCardsProps) { return ( -
+
} title="Upload" subtitle="PDF, Image, Audio" onClick={onUpload} isLoading={isLoading} + hoverVariant="upload" htmlFor={uploadInputId} /> } @@ -85,20 +98,15 @@ export function HomeActionCards({ onUpload, onLink, onPasteText, onRecord, onSta subtitle="From Clipboard" onClick={onPasteText} isLoading={isLoading} + hoverVariant="paste" /> } + icon={isLoading ? : } title="Record" subtitle="Lectures, Meetings" onClick={onRecord} isLoading={isLoading} - /> - : } - title="Start Fresh" - subtitle="Empty Workspace" - onClick={onStartFromScratch} - isLoading={isLoading} + hoverVariant="record" />
); diff --git a/src/components/home/HomeContent.tsx b/src/components/home/HomeContent.tsx index 975c7b95..92ed188f 100644 --- a/src/components/home/HomeContent.tsx +++ b/src/components/home/HomeContent.tsx @@ -11,7 +11,7 @@ import { HeroGlow } from "./HeroGlow"; import { Button } from "@/components/ui/button"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { FolderPlus, ChevronDown } from "lucide-react"; +import { ChevronDown } from "lucide-react"; import { useCreateWorkspace } from "@/hooks/workspace/use-create-workspace"; import { HoverCard, @@ -42,7 +42,6 @@ interface HeroAttachmentsSectionProps { fileInputRef: React.RefObject; showLinkDialog: boolean; setShowLinkDialog: (open: boolean) => void; - handleCreateBlankWorkspace: () => void; createWorkspacePending: boolean; onRecord: () => void; heroVisible: boolean; @@ -76,7 +75,6 @@ function HeroAttachmentsSection({ fileInputRef, showLinkDialog, setShowLinkDialog, - handleCreateBlankWorkspace, createWorkspacePending, onRecord, heroVisible, @@ -149,7 +147,6 @@ function HeroAttachmentsSection({ onLink={() => setShowLinkDialog(true)} onPasteText={handlePasteText} onRecord={onRecord} - onStartFromScratch={handleCreateBlankWorkspace} isLoading={createWorkspacePending} uploadInputId={uploadInputId} /> @@ -272,28 +269,6 @@ export function HomeContent() { workspacesRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }, []); - const handleCreateBlankWorkspace = () => { - // Guard against multiple rapid clicks - if (createWorkspace.isPending) return; - - createWorkspace.mutate( - { - name: "Blank Workspace", - icon: null, - color: null, - }, - { - onSuccess: ({ workspace }) => { - router.push(`/workspace/${workspace.slug}`); - }, - onError: (err) => { - const msg = err instanceof Error ? err.message : "Something went wrong"; - toast.error("Could not create workspace", { description: msg }); - }, - } - ); - }; - const handleRecordInNewWorkspace = () => { if (createWorkspace.isPending) return; setShowRecordDialog(false); @@ -320,6 +295,14 @@ export function HomeContent() { router.push(`/workspace/${slug}?${OPEN_RECORD_PARAM}=1`); }; + const handleRecord = () => { + if (!loadingWorkspaces && workspaces.length === 0) { + handleRecordInNewWorkspace(); + } else { + setShowRecordDialog(true); + } + }; + return ( <> setShowRecordDialog(true)} + onRecord={handleRecord} heroVisible={heroVisible} showPromptInput={showPromptInput} onRequestShowPromptInput={() => setShowPromptInput(true)} diff --git a/src/components/home/HomePromptInput.tsx b/src/components/home/HomePromptInput.tsx index d4c8ba5d..15557357 100644 --- a/src/components/home/HomePromptInput.tsx +++ b/src/components/home/HomePromptInput.tsx @@ -240,7 +240,7 @@ export function HomePromptInput({ shouldFocus, initialValue, onInitialValueAppli paddingBottom: '0.25rem', paddingLeft: '0', paddingRight: '0', - maxHeight: '50vh', + maxHeight: '28vh', overflowY: 'auto', }} className={cn( From 3245f70e14eb5788c3c1867e3611eda4c74a561e Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:51:42 -0500 Subject: [PATCH 53/56] fix: home page --- src/components/home/HomeContent.tsx | 6 ++ src/components/home/HomeTopBar.tsx | 12 +-- src/components/landing/Footer.tsx | 135 ++++++++++------------------ src/components/landing/Navbar.tsx | 2 +- 4 files changed, 56 insertions(+), 99 deletions(-) diff --git a/src/components/home/HomeContent.tsx b/src/components/home/HomeContent.tsx index 92ed188f..d136ad47 100644 --- a/src/components/home/HomeContent.tsx +++ b/src/components/home/HomeContent.tsx @@ -35,6 +35,7 @@ export const useSectionVisibility = () => useContext(SectionVisibilityContext); import { HomeActionCards } from "./HomeActionCards"; import { RecordWorkspaceDialog, OPEN_RECORD_PARAM } from "@/components/modals/RecordWorkspaceDialog"; +import { Footer } from "@/components/landing/Footer"; const ACCEPT_FILES = "application/pdf,image/*,audio/*"; @@ -422,6 +423,11 @@ export function HomeContent() {
+ + {/* Footer */} +
+
+
diff --git a/src/components/home/HomeTopBar.tsx b/src/components/home/HomeTopBar.tsx index 3987be8d..2a5ae0af 100644 --- a/src/components/home/HomeTopBar.tsx +++ b/src/components/home/HomeTopBar.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { Github, Search } from "lucide-react"; +import { Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { UserProfileDropdown } from "./UserProfileDropdown"; @@ -73,17 +73,9 @@ export function HomeTopBar({ scrollY, searchQuery, onSearchChange }: HomeTopBarP
)} - {/* Right: Theme toggle + Open source + User profile */} + {/* Right: Theme toggle + User profile */}
diff --git a/src/components/landing/Footer.tsx b/src/components/landing/Footer.tsx index f5ad503b..68518640 100644 --- a/src/components/landing/Footer.tsx +++ b/src/components/landing/Footer.tsx @@ -20,99 +20,58 @@ export function Footer() { }; return ( -
- {/* Big Watermark Text */} -
- - ThinkEx - -
- -
-
- - {/* Brand Column */} -
- -
- -
- - - - - -
- © {currentYear} ThinkEx Inc. All rights reserved. - Privacy Policy - Terms of Service - Cookie Policy -
-
- - {/* Product Column */} -
-

Product

-
    -
  • Home
  • -
  • Product
  • -
  • Use Cases
  • -
  • Comparison
  • -
  • Pricing
  • -
-
- - {/* Community Column */} - +
+
+ {/* Logo */} +
+ +
+ {/* Email */} + + {/* Community links */} + - {/* Mobile Only: Legal/Copyright at Bottom */} -
-
- © {currentYear} ThinkEx Inc. All rights reserved. - Privacy Policy - Terms of Service - Cookie Policy -
+ {/* Bottom: Copyright & Legal */} +
+ © {currentYear} ThinkEx Inc. All rights reserved. +
+ Privacy Policy + Terms of Service + Cookie Policy
- - {/* Bottom Bar: Copyright & Legal */} - {/* Bottom Bar Removed */}
); diff --git a/src/components/landing/Navbar.tsx b/src/components/landing/Navbar.tsx index 036572fc..4aba0a94 100644 --- a/src/components/landing/Navbar.tsx +++ b/src/components/landing/Navbar.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { Menu, Github } from "lucide-react"; +import { Menu } from "lucide-react"; import { useState, useEffect } from "react"; import { usePostHog } from 'posthog-js/react'; import { useSession } from "@/lib/auth-client"; From a23ff7db00895d00a11ad675f5850d6951c57921 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:55:32 -0500 Subject: [PATCH 54/56] Update HomeActionCards.tsx --- src/components/home/HomeActionCards.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/home/HomeActionCards.tsx b/src/components/home/HomeActionCards.tsx index 0e6fe3d8..658eacba 100644 --- a/src/components/home/HomeActionCards.tsx +++ b/src/components/home/HomeActionCards.tsx @@ -4,10 +4,10 @@ import { cn } from "@/lib/utils"; type HoverVariant = "upload" | "link" | "paste" | "record"; const HOVER_VARIANT_STYLES: Record = { - upload: "hover:border-emerald-500/60 hover:bg-emerald-500/5 hover:shadow-[0_0_24px_-4px_rgba(16,185,129,0.35)] [&:hover_.action-icon]:text-emerald-600 dark:[&:hover_.action-icon]:text-emerald-400 [&:hover_.action-title]:text-emerald-700 dark:[&:hover_.action-title]:text-emerald-300 [&:hover_.action-icon]:animate-[icon-upload-bounce_0.8s_ease-in-out_infinite]", - link: "hover:border-blue-500/60 hover:bg-blue-500/5 hover:shadow-[0_0_24px_-4px_rgba(59,130,246,0.35)] [&:hover_.action-icon]:text-blue-600 dark:[&:hover_.action-icon]:text-blue-400 [&:hover_.action-title]:text-blue-700 dark:[&:hover_.action-title]:text-blue-300 [&:hover_.action-icon]:animate-[icon-link-sway_0.8s_ease-in-out_infinite]", - paste: "hover:border-amber-500/60 hover:bg-amber-500/5 hover:shadow-[0_0_24px_-4px_rgba(245,158,11,0.35)] [&:hover_.action-icon]:text-amber-600 dark:[&:hover_.action-icon]:text-amber-400 [&:hover_.action-title]:text-amber-700 dark:[&:hover_.action-title]:text-amber-300 [&:hover_.action-icon]:animate-[icon-paste-pop_0.8s_ease-in-out_infinite]", - record: "hover:border-rose-500/60 hover:bg-rose-500/5 hover:shadow-[0_0_24px_-4px_rgba(244,63,94,0.35)] [&:hover_.action-icon]:text-rose-600 dark:[&:hover_.action-icon]:text-rose-400 [&:hover_.action-title]:text-rose-700 dark:[&:hover_.action-title]:text-rose-300 [&:hover_.action-icon]:animate-[icon-link-pulse_0.8s_ease-in-out_infinite]", + upload: "hover:border-emerald-500/60 hover:shadow-[0_0_24px_-4px_rgba(16,185,129,0.35)] [&:hover_.action-icon]:text-emerald-600 dark:[&:hover_.action-icon]:text-emerald-400 [&:hover_.action-icon]:animate-[icon-upload-bounce_0.8s_ease-in-out_infinite]", + link: "hover:border-blue-500/60 hover:shadow-[0_0_24px_-4px_rgba(59,130,246,0.35)] [&:hover_.action-icon]:text-blue-600 dark:[&:hover_.action-icon]:text-blue-400 [&:hover_.action-icon]:animate-[icon-link-sway_0.8s_ease-in-out_infinite]", + paste: "hover:border-amber-500/60 hover:shadow-[0_0_24px_-4px_rgba(245,158,11,0.35)] [&:hover_.action-icon]:text-amber-600 dark:[&:hover_.action-icon]:text-amber-400 [&:hover_.action-icon]:animate-[icon-paste-pop_0.8s_ease-in-out_infinite]", + record: "hover:border-rose-500/60 hover:shadow-[0_0_24px_-4px_rgba(244,63,94,0.35)] [&:hover_.action-icon]:text-rose-600 dark:[&:hover_.action-icon]:text-rose-400 [&:hover_.action-icon]:animate-[icon-link-pulse_0.8s_ease-in-out_infinite]", }; interface ActionCardProps { From f010c721e46a2586d0fbc931d7d1c8b9dd3aa5ff Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:58:40 -0500 Subject: [PATCH 55/56] Update Footer.tsx --- src/components/landing/Footer.tsx | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/components/landing/Footer.tsx b/src/components/landing/Footer.tsx index 68518640..7e7c3de7 100644 --- a/src/components/landing/Footer.tsx +++ b/src/components/landing/Footer.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import Link from "next/link"; import { ThinkExLogo } from "@/components/ui/thinkex-logo"; @@ -27,16 +26,18 @@ export function Footer() {
- {/* Email */} - - {/* Community links */} - {/* Bottom: Copyright & Legal */} + {/* Bottom: Copyright */}
© {currentYear} ThinkEx Inc. All rights reserved. -
- Privacy Policy - Terms of Service - Cookie Policy -
From 6a5d2cb3215c9f17f6ab474d4e1828fc1f7aa3d8 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:10:33 -0500 Subject: [PATCH 56/56] fix --- src/components/assistant-ui/markdown-text.tsx | 10 ++++++---- src/components/workspace-canvas/WorkspaceSidebar.tsx | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx index 78df0530..91047f51 100644 --- a/src/components/assistant-ui/markdown-text.tsx +++ b/src/components/assistant-ui/markdown-text.tsx @@ -5,7 +5,7 @@ import "streamdown/styles.css"; import { createCodePlugin } from "@streamdown/code"; import { mermaid } from "@streamdown/mermaid"; import { createMathPlugin } from "@streamdown/math"; -import { useMessagePartText, useAuiState } from "@assistant-ui/react"; +import { useMessagePartText, useAuiState, type TextMessagePartProps } from "@assistant-ui/react"; import { Children, isValidElement, @@ -161,12 +161,14 @@ const CitationRenderer = memo( ); CitationRenderer.displayName = "CitationRenderer"; -interface MarkdownTextProps { +/** Props from assistant-ui when used as Text component, or optional when used directly (e.g. in Reasoning) */ +type MarkdownTextProps = Partial & { /** Use "reasoning" for smoother streaming in reasoning blocks (blurIn, longer duration) */ streamingVariant?: "default" | "reasoning"; -} +}; -const MarkdownTextImpl = ({ streamingVariant = "default" }: MarkdownTextProps) => { +const MarkdownTextImpl = (props: MarkdownTextProps) => { + const streamingVariant = props.streamingVariant ?? "default"; // Get the text content from assistant-ui context const { text } = useMessagePartText(); diff --git a/src/components/workspace-canvas/WorkspaceSidebar.tsx b/src/components/workspace-canvas/WorkspaceSidebar.tsx index 94f0d88b..beec56d7 100644 --- a/src/components/workspace-canvas/WorkspaceSidebar.tsx +++ b/src/components/workspace-canvas/WorkspaceSidebar.tsx @@ -45,6 +45,7 @@ import type { WorkspaceWithState } from "@/lib/workspace-state/types"; import { IconRenderer } from "@/hooks/use-icon-picker"; import { cn } from "@/lib/utils"; import { ThinkExLogo } from "@/components/ui/thinkex-logo"; +import { ThemeToggle } from "@/components/ui/theme-toggle"; interface WorkspaceSidebarProps { showJsonView: boolean; @@ -379,8 +380,9 @@ function WorkspaceSidebar({
- {/* Support button */} + {/* Theme toggle & Support button */}
+