From 6d28536d991ad5ed8b03cb1029d8b47c2e6b4d7a 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/20] 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 ae4b7002..6e1c1ac5 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 13e044ed8c164ff3bf68591fb3c9e564b30ceb5f 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/20] 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 6e1c1ac5..506fd681 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 0a607b24..cfe01e45 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -212,6 +212,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; @@ -694,7 +696,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(); @@ -862,6 +936,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 b6dc404f154c200c71edbb848dd2183b3b6af131 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/20] 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 3fb038d20a205573057d3df09d2f438285295a9e 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/20] 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 506fd681..e74bcfde 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 fdee92a797258e9ecd32237b0240edeb585ba48d 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/20] 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 50103fa008a97f12b007bb8b714c242b3cb9ad51 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/20] 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 bce535551a58274cf48da7346498997f45467978 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/20] 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 456a0d8144442b8d6a34b5e23a2894557d1f31b1 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 08/20] more changes --- .../assistant-ui/ReadWorkspaceToolUI.tsx | 76 ++++++++++ .../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 | 96 +++++++++++++ src/lib/ai/tools/search-workspace.ts | 130 ++++++++++++++++++ src/lib/ai/tools/workspace-tools.ts | 2 +- src/lib/utils/format-workspace-context.ts | 49 ++++++- 8 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 src/components/assistant-ui/ReadWorkspaceToolUI.tsx create mode 100644 src/components/assistant-ui/SearchWorkspaceToolUI.tsx create mode 100644 src/lib/ai/tools/read-workspace.ts create mode 100644 src/lib/ai/tools/search-workspace.ts diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx new file mode 100644 index 00000000..289327da --- /dev/null +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { FileText } 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 ReadArgs = { path?: string; itemName?: string }; +type ReadResult = { + success: boolean; + itemName?: string; + type?: string; + path?: string; + content?: string; + message?: string; +}; + +function stripExtension(s: string): string { + return s.replace(/\.[^.]+$/, ""); +} + +export const ReadWorkspaceToolUI = makeAssistantToolUI({ + toolName: "readWorkspace", + render: ({ args, status, result }) => { + let content: React.ReactNode = null; + + if (status.type === "running") { + const label = args?.path + ? `Reading ${stripExtension(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) { + content = ( +
+ + + Read -{" "} + {result.path + ? stripExtension(result.path) + : result.itemName} + {result.type && ( + + {result.type} + + )} + +
+ ); + } + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } + + return ( + + {content} + + ); + }, +}); 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 55f8c1ad..8ed12516 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 { SearchWorkspaceToolUI } from "@/components/assistant-ui/SearchWorkspaceToolUI"; +import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI"; import { DeleteCardToolUI } from "@/components/assistant-ui/DeleteCardToolUI"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; @@ -198,6 +200,8 @@ export const Thread: FC = ({ items = [] }) => { {/* */} + + { // Search & code execution webSearch: createWebSearchTool(), executeCode: createExecuteCodeTool(), + searchWorkspace: createSearchWorkspaceTool(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..d2eb4d8f --- /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 searchWorkspace 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/search-workspace.ts b/src/lib/ai/tools/search-workspace.ts new file mode 100644 index 00000000..ee4a6143 --- /dev/null +++ b/src/lib/ai/tools/search-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 createSearchWorkspaceTool(ctx: WorkspaceToolContext) { + return tool({ + description: + "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("Search pattern (plain text or regex) — the term/phrase to find in workspace content"), + 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/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 76d3f657..edf30e18 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -318,7 +318,7 @@ 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 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 a60fc077..fc168aa4 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 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 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 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. @@ -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 searchWorkspace 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) { From 279fa5ac02e7d5638086bbbb4668fe36cd69482b 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 09/20] 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 e74bcfde..21366c3b 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 edf30e18..ea1fe2e1 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -69,28 +69,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 { @@ -99,10 +119,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.", }; } @@ -114,22 +148,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."}`, }; } @@ -142,8 +173,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 2515e78f..3cd8e367 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"; @@ -12,6 +13,8 @@ import { markdownToBlocks } from "@/lib/editor/markdown-to-blocks"; import { executeWorkspaceOperation } from "./common"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; 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 = { @@ -182,8 +185,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?: { @@ -463,18 +472,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 @@ -546,13 +598,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 fc168aa4..89555dbf 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. @@ -550,26 +555,26 @@ function formatSelectedCardFull(item: Item, index: number): 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 f7e8ab1547581b6f8b70bac119b53abea8de6658 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 10/20] fix: more features --- src/app/api/workspaces/[id]/events/route.ts | 33 +++++ .../assistant-ui/GrepWorkspaceToolUI.tsx | 86 ++++++++++++ .../assistant-ui/ReadWorkspaceToolUI.tsx | 27 ++-- .../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 | 2 +- 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 | 21 +-- src/lib/workspace/unique-name.ts | 33 +++++ 15 files changed, 537 insertions(+), 50 deletions(-) create mode 100644 src/components/assistant-ui/GrepWorkspaceToolUI.tsx create mode 100644 src/lib/ai/tools/grep-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 index 8616e889..52522f86 100644 --- a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -1,7 +1,8 @@ "use client"; -import { Eye } from "lucide-react"; +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"; @@ -16,10 +17,6 @@ type ReadResult = { message?: string; }; -function stripExtension(s: string): string { - return s.replace(/\.[^.]+$/, ""); -} - export const ReadWorkspaceToolUI = makeAssistantToolUI({ toolName: "readWorkspace", render: ({ args, status, result }) => { @@ -27,7 +24,7 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({ if (status.type === "running") { const label = args?.path - ? `Reading ${stripExtension(args.path)}` + ? `Reading ${args.path}` : args?.itemName ? `Reading "${args.itemName}"` : "Reading workspace item..."; @@ -36,20 +33,17 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({ if (!result.success && result.message) { content = ( ); - } else if (result.success) { + } else if (result.success && result.content) { content = (
-
- +
+ - Read -{" "} - {result.path - ? stripExtension(result.path) - : result.itemName} + {result.path ?? result.itemName} {result.type && ( {result.type} @@ -57,13 +51,16 @@ export const ReadWorkspaceToolUI = makeAssistantToolUI({ )}
+
+ {result.content} +
); } } else if (status.type === "incomplete" && status.reason === "error") { 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 8ed12516..82eb4453 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -75,7 +75,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 { SearchWorkspaceToolUI } from "@/components/assistant-ui/SearchWorkspaceToolUI"; +import { GrepWorkspaceToolUI } from "@/components/assistant-ui/GrepWorkspaceToolUI"; import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI"; import { DeleteCardToolUI } from "@/components/assistant-ui/DeleteCardToolUI"; @@ -112,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"; @@ -200,7 +199,7 @@ 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 2f180aa9..ad3df2fb 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -21,7 +21,7 @@ import { createUpdatePdfContentTool } from "./pdf-tools"; import { createSearchYoutubeTool, createAddYoutubeVideoTool } from "./youtube-tools"; import { createSearchImagesTool, createAddImageTool } from "./image-tools"; import { createWebSearchTool } from "./web-search"; -import { createSearchWorkspaceTool } from "./search-workspace"; +import { createGrepWorkspaceTool } from "./grep-workspace"; import { createReadWorkspaceTool } from "./read-workspace"; import { logger } from "@/lib/utils/logger"; @@ -59,7 +59,7 @@ export function createChatTools(config: ChatToolsConfig): Record { // Search & code execution webSearch: createWebSearchTool(), executeCode: createExecuteCodeTool(), - searchWorkspace: createSearchWorkspaceTool(ctx), + grepWorkspace: createGrepWorkspaceTool(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 d2eb4d8f..ae489464 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 searchWorkspace to search." + sample ? `Example paths: ${sample}` : "Workspace may be empty. Use grepWorkspace to search." }`, }; } 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 ea1fe2e1..7b44ae81 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"; @@ -342,9 +341,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; @@ -352,13 +348,12 @@ export function createSelectCardsTool(ctx: WorkspaceToolContext) { if (notFoundCount > 0) { message += ` (${notFoundCount} not found)`; } - message += `: ${selectedItems.map(item => item.name).join(", ")}. Use searchWorkspace or readWorkspace to fetch content when needed.`; + 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) { @@ -366,7 +361,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 3cd8e367..e6d5ca28 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -12,6 +12,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"; import { replace as applyReplace, trimDiff, normalizeLineEndings } from "@/lib/utils/edit-replace"; import { getNoteContentAsMarkdown } from "@/lib/utils/format-workspace-context"; @@ -291,6 +292,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 @@ -554,6 +562,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); @@ -679,6 +697,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; } @@ -773,6 +797,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; } @@ -871,6 +901,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 89555dbf..bf5a4b9a 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 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 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, 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 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. @@ -464,7 +464,7 @@ No cards selected. const entries = contentItems.map((item) => formatItemMetadata(item, allItems ?? effectiveItems)); return ` -SELECTED CARDS (${contentItems.length}) — paths and metadata. Use searchWorkspace or readWorkspace to fetch content when needed. +SELECTED CARDS (${contentItems.length}) — paths and metadata. Use grepWorkspace or readWorkspace to fetch content when needed. ${entries.join("\n")} `; @@ -520,11 +520,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)); @@ -547,10 +551,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 d3baa7588f53e1bb25305b43d3d41d2aaf22ea97 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 11/20] 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 56bfeff9735f8acabdc6f2e8d15130024413e232 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:12:45 -0500 Subject: [PATCH 12/20] chore: use searchWorkspace over grepWorkspace; exclude tmp from tsconfig Co-authored-by: Cursor --- src/components/assistant-ui/thread.tsx | 4 ++-- src/lib/ai/tools/index.ts | 4 ++-- src/lib/ai/tools/read-workspace.ts | 2 +- src/lib/ai/tools/workspace-tools.ts | 2 +- src/lib/utils/format-workspace-context.ts | 6 +++--- tsconfig.json | 3 ++- 6 files changed, 11 insertions(+), 10 deletions(-) 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 ae489464..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 grepWorkspace to search." + sample ? `Example paths: ${sample}` : "Workspace may be empty. Use searchWorkspace to search." }`, }; } diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 7b44ae81..0de4f982 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -348,7 +348,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 bf5a4b9a..022c0a10 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. @@ -464,7 +464,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")} `; 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 c5460fa033a67d8959dccd042bd1de8b2b2535d4 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 13/20] fix: image instructions --- src/lib/utils/format-workspace-context.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 022c0a10..bae113f9 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -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 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 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. YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search. From 870a3d0e8d49e0bd65a767930e8b6efa6c4ff9ea 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 14/20] 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 0f581fd6aa4443a9e9fbbc7b1416a491532d2a5d 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 15/20] fix: system prompt --- src/lib/utils/format-workspace-context.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index bae113f9..210c1f68 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -103,13 +103,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: Use readWorkspace for PDFs with content (pageStart/pageEnd for page ranges). If content is not yet available, use processFiles. YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search. @@ -124,19 +125,21 @@ 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. +NOTE EDITING: See updateNote and readWorkspace tool descriptions for oldString/newString conventions and exact-match rules. 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. +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: 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"}] +Examples (plain text only): +- https://en.wikipedia.org/wiki/Supply_chain +- My Calculus Notes +- My Calculus Notes | the derivative rule for power functions +- Math 240 Textbook | limit definition | p. 42 Inline format: N or N | exact excerpt - Without quote: 1 — use when you cannot cite an exact excerpt. From 0be89c8ee3d96927991e5d77dcc7fa1c403d0e94 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 16/20] 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 e4d6032c..842fd8d9 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 e7cfad31ea603a5cde7be2005bdfce0b3e960d48 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 18/20] 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 69e7d1f1..74ffcc3e 100644 --- a/src/components/assistant-ui/FileProcessingToolUI.tsx +++ b/src/components/assistant-ui/FileProcessingToolUI.tsx @@ -29,6 +29,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. @@ -334,7 +335,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 b66914aeec2c3aabdffb15b83c398d48734c1881 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 19/20] 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 cf2d6dee..5046fc71 100644 --- a/src/components/assistant-ui/reasoning.tsx +++ b/src/components/assistant-ui/reasoning.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useRef, useState } 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 f439cd6391b3eb07ec3dd28ec556a272b22ee18f Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:23:44 -0500 Subject: [PATCH 20/20] fix: build error --- src/app/globals.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index e1d6f83e..3ac437b9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,8 +3,8 @@ @import "tw-shimmer"; -/* Path to your installed `@blocknote/shadcn` package. */ -@source "../node_modules/@blocknote/shadcn"; +/* BlockNote shadcn: use prebuilt styles to avoid Tailwind v4 parsing bug with [is(ol,ul)] arbitrary variant */ +@import "@blocknote/shadcn/style.css"; /* Streamdown markdown library - custom components handle styling, no @source needed */