diff --git a/.gitignore b/.gitignore index 32f44c5..57ef722 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ data/ +.next/ *.log .DS_Store -web/dist/ +*.tsbuildinfo +.env.local diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..7fb5855 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,5 @@ +import { handleChatRequest } from "@/src/server/chat-handler"; + +export async function POST(req: Request) { + return handleChatRequest(req); +} diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts new file mode 100644 index 0000000..26d3666 --- /dev/null +++ b/app/api/chats/[id]/route.ts @@ -0,0 +1,8 @@ +import { getChat } from "@/src/store/chats"; + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const chat = getChat(id); + if (!chat) return Response.json({ error: "Not found" }, { status: 404 }); + return Response.json(chat); +} diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts new file mode 100644 index 0000000..9f6dd3e --- /dev/null +++ b/app/api/chats/route.ts @@ -0,0 +1,12 @@ +import { createChat, listChats, titleFromPrompt } from "@/src/store/chats"; + +export async function GET() { + return Response.json({ chats: listChats() }); +} + +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as { title?: unknown }; + const title = typeof body.title === "string" ? body.title : ""; + const chat = createChat(titleFromPrompt(title || "Untitled session")); + return Response.json({ id: chat.id, title: chat.title }); +} diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx new file mode 100644 index 0000000..e463947 --- /dev/null +++ b/app/chat/[id]/page.tsx @@ -0,0 +1,26 @@ +import { notFound } from "next/navigation"; +import { Chat } from "@/components/chat"; +import { getChat } from "@/src/store/chats"; +import type { TeacherUIMessage } from "@/src/agent/ui-message"; + +export const dynamic = "force-dynamic"; + +export default async function ChatPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ prompt?: string }>; +}) { + const [{ id }, { prompt }] = await Promise.all([params, searchParams]); + const chat = getChat(id); + if (!chat) notFound(); + return ( + + ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..937c93b --- /dev/null +++ b/app/globals.css @@ -0,0 +1,86 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +/* + * Design tokens — the single place to restyle the app. + * (The owner has a specific visual design planned; it lands here and in + * component edits, not in structural changes.) + */ + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); +} + +* { + border-color: var(--color-border); +} + +body { + background: var(--color-background); + color: var(--color-foreground); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..34e07e7 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Learning Harness", + description: "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..518248c --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,8 @@ +import { listChats } from "@/src/store/chats"; +import { Home } from "@/components/home"; + +export const dynamic = "force-dynamic"; + +export default function HomePage() { + return ; +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..6e57ca9 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/ai-elements/conversation.tsx b/components/ai-elements/conversation.tsx new file mode 100644 index 0000000..21e5d82 --- /dev/null +++ b/components/ai-elements/conversation.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { UIMessage } from "ai"; +import { ArrowDownIcon, DownloadIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; + +const getMessageText = (message: UIMessage): string => + message.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); + +export type ConversationDownloadProps = Omit< + ComponentProps, + "onClick" +> & { + messages: UIMessage[]; + filename?: string; + formatMessage?: (message: UIMessage, index: number) => string; +}; + +const defaultFormatMessage = (message: UIMessage): string => { + const roleLabel = + message.role.charAt(0).toUpperCase() + message.role.slice(1); + return `**${roleLabel}:** ${getMessageText(message)}`; +}; + +export const messagesToMarkdown = ( + messages: UIMessage[], + formatMessage: ( + message: UIMessage, + index: number + ) => string = defaultFormatMessage +): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n"); + +export const ConversationDownload = ({ + messages, + filename = "conversation.md", + formatMessage = defaultFormatMessage, + className, + children, + ...props +}: ConversationDownloadProps) => { + const handleDownload = useCallback(() => { + const markdown = messagesToMarkdown(messages, formatMessage); + const blob = new Blob([markdown], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }, [messages, filename, formatMessage]); + + return ( + + ); +}; diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx new file mode 100644 index 0000000..f75eded --- /dev/null +++ b/components/ai-elements/message.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + ButtonGroup, + ButtonGroupText, +} from "@/components/ui/button-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { cjk } from "@streamdown/cjk"; +import { code } from "@streamdown/code"; +import { math } from "@streamdown/math"; +import { mermaid } from "@streamdown/mermaid"; +import type { UIMessage } from "ai"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { Streamdown, type PluginConfig } from "streamdown"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageActionsProps = ComponentProps<"div">; + +export const MessageActions = ({ + className, + children, + ...props +}: MessageActionsProps) => ( +
+ {children} +
+); + +export type MessageActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const MessageAction = ({ + tooltip, + children, + label, + variant = "ghost", + size = "icon-sm", + ...props +}: MessageActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +interface MessageBranchContextType { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +} + +const MessageBranchContext = createContext( + null +); + +const useMessageBranch = () => { + const context = useContext(MessageBranchContext); + + if (!context) { + throw new Error( + "MessageBranch components must be used within MessageBranch" + ); + } + + return context; +}; + +export type MessageBranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const MessageBranch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: MessageBranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = useCallback( + (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }, + [onBranchChange] + ); + + const goToPrevious = useCallback(() => { + const newBranch = + currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }, [currentBranch, branches.length, handleBranchChange]); + + const goToNext = useCallback(() => { + const newBranch = + currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }, [currentBranch, branches.length, handleBranchChange]); + + const contextValue = useMemo( + () => ({ + branches, + currentBranch, + goToNext, + goToPrevious, + setBranches, + totalBranches: branches.length, + }), + [branches, currentBranch, goToNext, goToPrevious] + ); + + return ( + +
div]:pb-0", className)} + {...props} + /> + + ); +}; + +export type MessageBranchContentProps = HTMLAttributes; + +export const MessageBranchContent = ({ + children, + ...props +}: MessageBranchContentProps) => { + const { currentBranch, setBranches, branches } = useMessageBranch(); + const childrenArray = useMemo( + () => (Array.isArray(children) ? children : [children]), + [children] + ); + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0", + index === currentBranch ? "block" : "hidden" + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type MessageBranchSelectorProps = ComponentProps; + +export const MessageBranchSelector = ({ + className, + ...props +}: MessageBranchSelectorProps) => { + const { totalBranches } = useMessageBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( + *:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md", + className + )} + orientation="horizontal" + {...props} + /> + ); +}; + +export type MessageBranchPreviousProps = ComponentProps; + +export const MessageBranchPrevious = ({ + children, + ...props +}: MessageBranchPreviousProps) => { + const { goToPrevious, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchNextProps = ComponentProps; + +export const MessageBranchNext = ({ + children, + ...props +}: MessageBranchNextProps) => { + const { goToNext, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchPageProps = HTMLAttributes; + +export const MessageBranchPage = ({ + className, + ...props +}: MessageBranchPageProps) => { + const { currentBranch, totalBranches } = useMessageBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; + +export type MessageResponseProps = ComponentProps; + +// Type-only version skew: @streamdown/code bundles shiki@3 types while +// streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its +// own highlighter); the cast papers over the .d.ts mismatch. +const streamdownPlugins = { cjk, code, math, mermaid } as unknown as PluginConfig; + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0", + className + )} + plugins={streamdownPlugins} + {...props} + /> + ), + (prevProps, nextProps) => + prevProps.children === nextProps.children && + nextProps.isAnimating === prevProps.isAnimating +); + +MessageResponse.displayName = "MessageResponse"; + +export type MessageToolbarProps = ComponentProps<"div">; + +export const MessageToolbar = ({ + className, + children, + ...props +}: MessageToolbarProps) => ( +
+ {children} +
+); diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx new file mode 100644 index 0000000..5be6019 --- /dev/null +++ b/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1463 @@ +"use client"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai"; +import { + CornerDownLeftIcon, + ImageIcon, + Monitor, + PlusIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { nanoid } from "nanoid"; +import type { + ChangeEvent, + ChangeEventHandler, + ClipboardEventHandler, + ComponentProps, + FormEvent, + FormEventHandler, + HTMLAttributes, + KeyboardEventHandler, + PropsWithChildren, + ReactNode, + RefObject, +} from "react"; +import { + Children, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +// ============================================================================ +// Helpers +// ============================================================================ + +const convertBlobUrlToDataUrl = async (url: string): Promise => { + try { + const response = await fetch(url); + const blob = await response.blob(); + // FileReader uses callback-based API, wrapping in Promise is necessary + // oxlint-disable-next-line eslint-plugin-promise(avoid-new) + return new Promise((resolve) => { + const reader = new FileReader(); + // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) + reader.onloadend = () => resolve(reader.result as string); + // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } +}; + +const captureScreenshot = async (): Promise => { + if ( + typeof navigator === "undefined" || + !navigator.mediaDevices?.getDisplayMedia + ) { + return null; + } + + let stream: MediaStream | null = null; + const video = document.createElement("video"); + video.muted = true; + video.playsInline = true; + + try { + stream = await navigator.mediaDevices.getDisplayMedia({ + audio: false, + video: true, + }); + + video.srcObject = stream; + + // Video element uses callback-based API, wrapping in Promise is necessary + // oxlint-disable-next-line eslint-plugin-promise(avoid-new) + await new Promise((resolve, reject) => { + // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) + video.onloadedmetadata = () => resolve(); + // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) + video.onerror = () => reject(new Error("Failed to load screen stream")); + }); + + await video.play(); + + const width = video.videoWidth; + const height = video.videoHeight; + if (!width || !height) { + return null; + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) { + return null; + } + + context.drawImage(video, 0, 0, width, height); + // canvas.toBlob uses callback-based API, wrapping in Promise is necessary + // oxlint-disable-next-line eslint-plugin-promise(avoid-new) + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, "image/png"); + }); + if (!blob) { + return null; + } + + const timestamp = new Date() + .toISOString() + .replaceAll(/[:.]/g, "-") + .replace("T", "_") + .replace("Z", ""); + + return new File([blob], `screenshot-${timestamp}.png`, { + lastModified: Date.now(), + type: "image/png", + }); + } finally { + if (stream) { + for (const track of stream.getTracks()) { + track.stop(); + } + } + video.pause(); + video.srcObject = null; + } +}; + +// ============================================================================ +// Provider Context & Types +// ============================================================================ + +export interface AttachmentsContext { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +} + +export interface TextInputContext { + value: string; + setInput: (v: string) => void; + clear: () => void; +} + +export interface PromptInputControllerProps { + textInput: TextInputContext; + attachments: AttachmentsContext; + /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ + __registerFileInput: ( + ref: RefObject, + open: () => void + ) => void; +} + +const PromptInputController = createContext( + null +); +const ProviderAttachmentsContext = createContext( + null +); + +export const usePromptInputController = () => { + const ctx = useContext(PromptInputController); + if (!ctx) { + throw new Error( + "Wrap your component inside to use usePromptInputController()." + ); + } + return ctx; +}; + +// Optional variants (do NOT throw). Useful for dual-mode components. +const useOptionalPromptInputController = () => + useContext(PromptInputController); + +export const useProviderAttachments = () => { + const ctx = useContext(ProviderAttachmentsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderAttachments()." + ); + } + return ctx; +}; + +const useOptionalProviderAttachments = () => + useContext(ProviderAttachmentsContext); + +export type PromptInputProviderProps = PropsWithChildren<{ + initialInput?: string; +}>; + +/** + * Optional global provider that lifts PromptInput state outside of PromptInput. + * If you don't use it, PromptInput stays fully self-managed. + */ +export const PromptInputProvider = ({ + initialInput: initialTextInput = "", + children, +}: PromptInputProviderProps) => { + // ----- textInput state + const [textInput, setTextInput] = useState(initialTextInput); + const clearInput = useCallback(() => setTextInput(""), []); + + // ----- attachments state (global when wrapped) + const [attachmentFiles, setAttachmentFiles] = useState< + (FileUIPart & { id: string })[] + >([]); + const fileInputRef = useRef(null); + // oxlint-disable-next-line eslint(no-empty-function) + const openRef = useRef<() => void>(() => {}); + + const add = useCallback((files: File[] | FileList) => { + const incoming = [...files]; + if (incoming.length === 0) { + return; + } + + setAttachmentFiles((prev) => [ + ...prev, + ...incoming.map((file) => ({ + filename: file.name, + id: nanoid(), + mediaType: file.type, + type: "file" as const, + url: URL.createObjectURL(file), + })), + ]); + }, []); + + const remove = useCallback((id: string) => { + setAttachmentFiles((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachmentFiles((prev) => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + // Keep a ref to attachments for cleanup on unmount (avoids stale closure) + const attachmentsRef = useRef(attachmentFiles); + + useEffect(() => { + attachmentsRef.current = attachmentFiles; + }, [attachmentFiles]); + + // Cleanup blob URLs on unmount to prevent memory leaks + useEffect( + () => () => { + for (const f of attachmentsRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + }, + [] + ); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + add, + clear, + fileInputRef, + files: attachmentFiles, + openFileDialog, + remove, + }), + [attachmentFiles, add, remove, clear, openFileDialog] + ); + + const __registerFileInput = useCallback( + (ref: RefObject, open: () => void) => { + fileInputRef.current = ref.current; + openRef.current = open; + }, + [] + ); + + const controller = useMemo( + () => ({ + __registerFileInput, + attachments, + textInput: { + clear: clearInput, + setInput: setTextInput, + value: textInput, + }, + }), + [textInput, clearInput, attachments, __registerFileInput] + ); + + return ( + + + {children} + + + ); +}; + +// ============================================================================ +// Component Context & Hooks +// ============================================================================ + +const LocalAttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + // Prefer local context (inside PromptInput) as it has validation, fall back to provider + const provider = useOptionalProviderAttachments(); + const local = useContext(LocalAttachmentsContext); + const context = local ?? provider; + if (!context) { + throw new Error( + "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider" + ); + } + return context; +}; + +// ============================================================================ +// Referenced Sources (Local to PromptInput) +// ============================================================================ + +export interface ReferencedSourcesContext { + sources: (SourceDocumentUIPart & { id: string })[]; + add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void; + remove: (id: string) => void; + clear: () => void; +} + +export const LocalReferencedSourcesContext = + createContext(null); + +export const usePromptInputReferencedSources = () => { + const ctx = useContext(LocalReferencedSourcesContext); + if (!ctx) { + throw new Error( + "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider" + ); + } + return ctx; +}; + +export type PromptInputActionAddAttachmentsProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddAttachments = ({ + label = "Add photos or files", + ...props +}: PromptInputActionAddAttachmentsProps) => { + const attachments = usePromptInputAttachments(); + + const handleSelect = useCallback( + (e: Event) => { + e.preventDefault(); + attachments.openFileDialog(); + }, + [attachments] + ); + + return ( + + {label} + + ); +}; + +export type PromptInputActionAddScreenshotProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddScreenshot = ({ + label = "Take screenshot", + onSelect, + ...props +}: PromptInputActionAddScreenshotProps) => { + const attachments = usePromptInputAttachments(); + + const handleSelect = useCallback( + async (event: Event) => { + onSelect?.(event); + if (event.defaultPrevented) { + return; + } + + try { + const screenshot = await captureScreenshot(); + if (screenshot) { + attachments.add([screenshot]); + } + } catch (error) { + if ( + error instanceof DOMException && + (error.name === "NotAllowedError" || error.name === "AbortError") + ) { + return; + } + throw error; + } + }, + [onSelect, attachments] + ); + + return ( + + + {label} + + ); +}; + +export interface PromptInputMessage { + text: string; + files: FileUIPart[]; +} + +export type PromptInputProps = Omit< + HTMLAttributes, + "onSubmit" | "onError" +> & { + // e.g., "image/*" or leave undefined for any + accept?: string; + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + // bytes + maxFileSize?: number; + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => void | Promise; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + + // Refs + const inputRef = useRef(null); + const formRef = useRef(null); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + // ----- Local referenced sources (always local to PromptInput) + const [referencedSources, setReferencedSources] = useState< + (SourceDocumentUIPart & { id: string })[] + >([]); + + // Keep a ref to files for cleanup on unmount (avoids stale closure) + const filesRef = useRef(files); + + useEffect(() => { + filesRef.current = files; + }, [files]); + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + + const patterns = accept + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + return patterns.some((pattern) => { + if (pattern.endsWith("/*")) { + // e.g: image/* -> image/ + const prefix = pattern.slice(0, -1); + return f.type.startsWith(prefix); + } + return f.type === pattern; + }); + }, + [accept] + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = [...fileList]; + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + filename: file.name, + id: nanoid(), + mediaType: file.type, + type: "file", + url: URL.createObjectURL(file), + }); + } + return [...prev, ...next]; + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError] + ); + + const removeLocal = useCallback( + (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }), + [] + ); + + // Wrapper that validates files before calling provider's add + const addWithProviderValidation = useCallback( + (fileList: File[] | FileList) => { + const incoming = [...fileList]; + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + const currentCount = files.length; + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - currentCount) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + + if (capped.length > 0) { + controller?.attachments.add(capped); + } + }, + [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller] + ); + + const clearAttachments = useCallback( + () => + usingProvider + ? controller?.attachments.clear() + : setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }), + [usingProvider, controller] + ); + + const clearReferencedSources = useCallback( + () => setReferencedSources([]), + [] + ); + + const add = usingProvider ? addWithProviderValidation : addLocal; + const remove = usingProvider ? controller.attachments.remove : removeLocal; + const openFileDialog = usingProvider + ? controller.attachments.openFileDialog + : openFileDialogLocal; + + const clear = useCallback(() => { + clearAttachments(); + clearReferencedSources(); + }, [clearAttachments, clearReferencedSources]); + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) { + return; + } + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) { + return; + } + if (globalDrop) { + // when global drop is on, let the document-level handler own drops + return; + } + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect(() => { + if (!globalDrop) { + return; + } + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of filesRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + } + }, + [usingProvider] + ); + + const handleChange: ChangeEventHandler = useCallback( + (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + // Reset input value to allow selecting files that were previously removed + event.currentTarget.value = ""; + }, + [add] + ); + + const attachmentsCtx = useMemo( + () => ({ + add, + clear: clearAttachments, + fileInputRef: inputRef, + files: files.map((item) => ({ ...item, id: item.id })), + openFileDialog, + remove, + }), + [files, add, remove, clearAttachments, openFileDialog] + ); + + const refsCtx = useMemo( + () => ({ + add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => { + const array = Array.isArray(incoming) ? incoming : [incoming]; + setReferencedSources((prev) => [ + ...prev, + ...array.map((s) => ({ ...s, id: nanoid() })), + ]); + }, + clear: clearReferencedSources, + remove: (id: string) => { + setReferencedSources((prev) => prev.filter((s) => s.id !== id)); + }, + sources: referencedSources, + }), + [referencedSources, clearReferencedSources] + ); + + const handleSubmit: FormEventHandler = useCallback( + async (event) => { + event.preventDefault(); + + const form = event.currentTarget; + const text = usingProvider + ? controller.textInput.value + : (() => { + const formData = new FormData(form); + return (formData.get("message") as string) || ""; + })(); + + // Reset form immediately after capturing text to avoid race condition + // where user input during async blob conversion would be lost + if (!usingProvider) { + form.reset(); + } + + try { + // Convert blob URLs to data URLs asynchronously + const convertedFiles: FileUIPart[] = await Promise.all( + files.map(async ({ id: _id, ...item }) => { + if (item.url?.startsWith("blob:")) { + const dataUrl = await convertBlobUrlToDataUrl(item.url); + // If conversion failed, keep the original blob URL + return { + ...item, + url: dataUrl ?? item.url, + }; + } + return item; + }) + ); + + const result = onSubmit({ files: convertedFiles, text }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + try { + await result; + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } catch { + // Don't clear on error - user may want to retry + } + } else { + // Sync function completed without throwing, clear inputs + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch { + // Don't clear on error - user may want to retry + } + }, + [usingProvider, controller, files, onSubmit, clear] + ); + + // Render with or without local provider + const inner = ( + <> + +
+ {children} +
+ + ); + + const withReferencedSources = ( + + {inner} + + ); + + // Always provide LocalAttachmentsContext so children get validated add function + return ( + + {withReferencedSources} + + ); +}; + +export type PromptInputBodyProps = HTMLAttributes; + +export const PromptInputBody = ({ + className, + ...props +}: PromptInputBodyProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps< + typeof InputGroupTextarea +>; + +export const PromptInputTextarea = ({ + onChange, + onKeyDown, + className, + placeholder = "What would you like to know?", + ...props +}: PromptInputTextareaProps) => { + const controller = useOptionalPromptInputController(); + const attachments = usePromptInputAttachments(); + const [isComposing, setIsComposing] = useState(false); + + const handleKeyDown: KeyboardEventHandler = useCallback( + (e) => { + // Call the external onKeyDown handler first + onKeyDown?.(e); + + // If the external handler prevented default, don't run internal logic + if (e.defaultPrevented) { + return; + } + + if (e.key === "Enter") { + if (isComposing || e.nativeEvent.isComposing) { + return; + } + if (e.shiftKey) { + return; + } + e.preventDefault(); + + // Check if the submit button is disabled before submitting + const { form } = e.currentTarget; + const submitButton = form?.querySelector( + 'button[type="submit"]' + ) as HTMLButtonElement | null; + if (submitButton?.disabled) { + return; + } + + form?.requestSubmit(); + } + + // Remove last attachment when Backspace is pressed and textarea is empty + if ( + e.key === "Backspace" && + e.currentTarget.value === "" && + attachments.files.length > 0 + ) { + e.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + } + } + }, + [onKeyDown, isComposing, attachments] + ); + + const handlePaste: ClipboardEventHandler = useCallback( + (event) => { + const items = event.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }, + [attachments] + ); + + const handleCompositionEnd = useCallback(() => setIsComposing(false), []); + const handleCompositionStart = useCallback(() => setIsComposing(true), []); + + const controlledProps = controller + ? { + onChange: (e: ChangeEvent) => { + controller.textInput.setInput(e.currentTarget.value); + onChange?.(e); + }, + value: controller.textInput.value, + } + : { + onChange, + }; + + return ( + + ); +}; + +export type PromptInputHeaderProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputHeader = ({ + className, + ...props +}: PromptInputHeaderProps) => ( + +); + +export type PromptInputFooterProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputFooter = ({ + className, + ...props +}: PromptInputFooterProps) => ( + +); + +export type PromptInputToolsProps = HTMLAttributes; + +export const PromptInputTools = ({ + className, + ...props +}: PromptInputToolsProps) => ( +
+); + +export type PromptInputButtonTooltip = + | string + | { + content: ReactNode; + shortcut?: string; + side?: ComponentProps["side"]; + }; + +export type PromptInputButtonProps = ComponentProps & { + tooltip?: PromptInputButtonTooltip; +}; + +export const PromptInputButton = ({ + variant = "ghost", + className, + size, + tooltip, + ...props +}: PromptInputButtonProps) => { + const newSize = + size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); + + const button = ( + + ); + + if (!tooltip) { + return button; + } + + const tooltipContent = + typeof tooltip === "string" ? tooltip : tooltip.content; + const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut; + const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top"); + + return ( + + {button} + + {tooltipContent} + {shortcut && ( + {shortcut} + )} + + + ); +}; + +export type PromptInputActionMenuProps = ComponentProps; +export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( + +); + +export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; + +export const PromptInputActionMenuTrigger = ({ + className, + children, + ...props +}: PromptInputActionMenuTriggerProps) => ( + + + {children ?? } + + +); + +export type PromptInputActionMenuContentProps = ComponentProps< + typeof DropdownMenuContent +>; +export const PromptInputActionMenuContent = ({ + className, + ...props +}: PromptInputActionMenuContentProps) => ( + +); + +export type PromptInputActionMenuItemProps = ComponentProps< + typeof DropdownMenuItem +>; +export const PromptInputActionMenuItem = ({ + className, + ...props +}: PromptInputActionMenuItemProps) => ( + +); + +// Note: Actions that perform side-effects (like opening a file dialog) +// are provided in opt-in modules (e.g., prompt-input-attachments). + +export type PromptInputSubmitProps = ComponentProps & { + status?: ChatStatus; + onStop?: () => void; +}; + +export const PromptInputSubmit = ({ + className, + variant = "default", + size = "icon-sm", + status, + onStop, + onClick, + children, + ...props +}: PromptInputSubmitProps) => { + const isGenerating = status === "submitted" || status === "streaming"; + + let Icon = ; + + if (status === "submitted") { + Icon = ; + } else if (status === "streaming") { + Icon = ; + } else if (status === "error") { + Icon = ; + } + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (isGenerating && onStop) { + e.preventDefault(); + onStop(); + return; + } + onClick?.(e); + }, + [isGenerating, onStop, onClick] + ); + + return ( + + {children ?? Icon} + + ); +}; + +export type PromptInputSelectProps = ComponentProps; + +export const PromptInputSelect = (props: PromptInputSelectProps) => ( + setTopic(e.target.value)} + placeholder="What do you want to learn?" + autoFocus + /> + + + +

Sessions

+ {chats.length === 0 ? ( +

No sessions yet.

+ ) : ( +
+ {chats.map((c) => ( + + ))} +
+ )} + + ); +} diff --git a/components/quiz-card.tsx b/components/quiz-card.tsx new file mode 100644 index 0000000..6ee8446 --- /dev/null +++ b/components/quiz-card.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { AskToolInput, QuizToolInput } from "@/src/agent/tools"; +import { + buildAnswer, + coerceCorrectAnswer, + normalizeOptions, + resolveCorrect, + shuffleOptions, + type AnswerRequest, + type QuestionAnswerData, + type QuestionData, + type QuestionMode, +} from "@/src/core/question-core"; + +/** + * The pending interaction card — renders when a quiz/ask tool part is in + * `input-available` state. Owns selection state (single source of truth — + * buttons, not native inputs), grades via question-core, and submits the + * structured answer record as the tool output. + * + * Quiz: graded (single/multi-select + "I don't know"). + * Ask: not graded (single/multi-select + "Other" free text, or text mode). + */ + +export interface QuizCardProps { + toolName: "quiz" | "ask_user_question"; + toolCallId: string; + input: QuizToolInput | AskToolInput; + onSubmit: (output: QuestionAnswerData) => void; +} + +export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProps) { + const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const); + + // Build the canonical QuestionData once per question. Quiz options are + // shuffled so position never leaks correctness; the display order is + // snapshotted into the answer record at submit time. + const qd = useMemo(() => { + const quizInput = kind === "quiz" ? (input as QuizToolInput) : null; + const askInput = kind === "ask" ? (input as AskToolInput) : null; + const multi = input.multiSelect === true; + const mode: QuestionMode = + kind === "quiz" + ? multi + ? "multi-select" + : "single-select" + : (askInput?.mode ?? (askInput?.options?.length ? (multi ? "multi-select" : "single-select") : "text")); + const options = normalizeOptions((quizInput?.options ?? askInput?.options ?? []) as { label: string; value?: string }[]); + const ordered = kind === "quiz" && options.length > 0 ? shuffleOptions(options) : options; + const q: QuestionData = { + id: toolCallId, + kind, + question: input.question, + context: input.details, + mode, + options: ordered, + createdAt: new Date().toISOString(), + }; + if (quizInput) { + const resolved = resolveCorrect(coerceCorrectAnswer(quizInput.correctAnswer), ordered); + if (resolved.error) console.error("[quiz] correctAnswer did not resolve:", resolved.error); + q.correctIndices = resolved.indices; + q.explanation = quizInput.explanation; + } + return q; + }, [toolCallId, kind, input]); + + const [selected, setSelected] = useState>(new Set()); // 0-based indices + const [dontKnow, setDontKnow] = useState(false); + const [showOther, setShowOther] = useState(false); + const [other, setOther] = useState(""); + const [text, setText] = useState(""); + const [note, setNote] = useState(""); + + const toggle = (idx: number) => { + setDontKnow(false); + setShowOther(false); + setSelected((prev) => { + if (qd.mode === "multi-select") { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + } + return new Set([idx]); + }); + }; + + const pickDontKnow = () => { + setSelected(new Set()); + setShowOther(false); + setDontKnow((v) => !v); + }; + + const pickOther = () => { + setSelected(new Set()); + setDontKnow(false); + setShowOther(true); + }; + + const canSubmit = + qd.mode === "text" + ? text.trim().length > 0 + : dontKnow || (showOther && other.trim().length > 0) || selected.size > 0; + + const submit = () => { + if (!canSubmit) return; + const req: AnswerRequest = { + dontKnow, + values: [...selected].map((i) => qd.options[i]!.value), + other: showOther ? other : undefined, + text: qd.mode === "text" ? text : undefined, + note: note.trim() || undefined, + }; + onSubmit(buildAnswer(qd, req)); + }; + + return ( +
+
+ {kind === "quiz" ? "Quiz" : "Question"} + {qd.mode === "multi-select" && " · select all that apply"} +
+
{qd.question}
+ {qd.context &&
{qd.context}
} + + {qd.mode !== "text" && ( +
+ {qd.options.map((opt, i) => { + const isSel = selected.has(i); + return ( + + ); + })} + + {kind === "quiz" && ( + + )} + + {kind === "ask" && ( + + )} +
+ )} + + {(qd.mode === "text" || showOther) && ( +