refactor: anonymous/free chat experience

- Enhanced lambda function formatting in `_after_commit` for better clarity.
- Simplified generator expression in `_match_condition` for improved readability.
- Streamlined function signature in `_eligible` for consistency.
- Updated imports and refactored anonymous chat routes to use a new agent creation method.
- Added a new function `_load_anon_document` to handle document loading from Redis.
- Improved UI components by replacing legacy structures with modern alternatives, including alerts and separators.
- Refactored quota-related components to utilize new alert structures for better user feedback.
- Cleaned up unused variables and optimized component states for performance.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-31 15:58:21 -07:00
parent 0cce9b7e64
commit 0f2e3c7655
17 changed files with 493 additions and 278 deletions

View file

@ -51,7 +51,9 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
doc
</span>
</TooltipTrigger>
<TooltipContent>{isDocsChunk ? "Documentation reference" : "Uploaded document"}</TooltipContent>
<TooltipContent>
{isDocsChunk ? "Documentation reference" : "Uploaded document"}
</TooltipContent>
</Tooltip>
);
}

View file

@ -15,6 +15,7 @@ import {
type TokenUsageData,
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { TimelineDataUI } from "@/features/chat-messages/timeline";
import {
@ -101,11 +102,16 @@ export function FreeChatPage() {
const anonMode = useAnonymousMode();
const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
const resetKey = anonMode.isAnonymous ? anonMode.resetKey : 0;
const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true;
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [tokenUsageStore] = useState(() => createTokenUsageStore());
const abortControllerRef = useRef<AbortController | null>(null);
// Mirror the latest messages into a ref so onNew stays a stable callback
// (it reads history on demand instead of depending on the array).
const messagesRef = useRef<ThreadMessageLike[]>([]);
messagesRef.current = messages;
// Turnstile CAPTCHA state
const [captchaRequired, setCaptchaRequired] = useState(false);
@ -152,6 +158,7 @@ export function FreeChatPage() {
model_slug: modelSlug,
messages: messageHistory,
};
if (!webSearchEnabled) reqBody.disabled_tools = ["web_search"];
if (turnstileToken) reqBody.turnstile_token = turnstileToken;
const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, {
@ -301,7 +308,7 @@ export function FreeChatPage() {
throw err;
}
},
[modelSlug, tokenUsageStore]
[modelSlug, tokenUsageStore, webSearchEnabled]
);
const onNew = useCallback(
@ -345,7 +352,7 @@ export function FreeChatPage() {
},
]);
const messageHistory = messages
const messageHistory = messagesRef.current
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => {
let text = "";
@ -395,7 +402,7 @@ export function FreeChatPage() {
abortControllerRef.current = null;
}
},
[messages, doStream]
[modelSlug, anonMode, doStream]
);
/** Called when Turnstile resolves successfully. Stores the token and auto-retries. */
@ -481,19 +488,21 @@ export function FreeChatPage() {
</div>
{captchaRequired && TURNSTILE_SITE_KEY && (
<div className="flex flex-col items-center gap-3 border-b border-border/40 bg-muted/30 py-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck className="h-4 w-4" />
<span>Quick verification to continue chatting</span>
</div>
<Turnstile
ref={turnstileRef}
siteKey={TURNSTILE_SITE_KEY}
onSuccess={handleTurnstileSuccess}
onError={() => turnstileRef.current?.reset()}
onExpire={() => turnstileRef.current?.reset()}
options={{ theme: "auto", size: "normal" }}
/>
<div className="flex justify-center border-b bg-muted/30 px-4 py-4">
<Alert className="w-auto max-w-md">
<ShieldCheck />
<AlertTitle>Quick verification to continue chatting</AlertTitle>
<AlertDescription>
<Turnstile
ref={turnstileRef}
siteKey={TURNSTILE_SITE_KEY}
onSuccess={handleTurnstileSuccess}
onError={() => turnstileRef.current?.reset()}
onExpire={() => turnstileRef.current?.reset()}
options={{ theme: "auto", size: "normal" }}
/>
</AlertDescription>
</Alert>
</div>
)}

View file

@ -6,6 +6,7 @@ import { type FC, useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
@ -71,10 +72,11 @@ export const FreeComposer: FC = () => {
const { gate } = useLoginGate();
const anonMode = useAnonymousMode();
const [text, setText] = useState("");
const [webSearchEnabled, setWebSearchEnabled] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const hasUploadedDoc = anonMode.isAnonymous && anonMode.uploadedDoc !== null;
const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true;
const setWebSearchEnabled = anonMode.isAnonymous ? anonMode.setWebSearchEnabled : () => {};
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
@ -189,14 +191,11 @@ export const FreeComposer: FC = () => {
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleUploadClick}
className={cn(
"h-auto gap-1.5 rounded-md px-2 py-1 text-xs transition-colors",
"text-muted-foreground hover:text-accent-foreground hover:bg-accent",
hasUploadedDoc && "text-primary"
)}
className={cn(hasUploadedDoc && "text-primary")}
>
<Paperclip className="size-3.5" />
<Paperclip data-icon="inline-start" />
{hasUploadedDoc ? "1/1" : "Upload"}
</Button>
</TooltipTrigger>
@ -207,13 +206,13 @@ export const FreeComposer: FC = () => {
</TooltipContent>
</Tooltip>
<div className="h-4 w-px bg-border/60" />
<Separator orientation="vertical" className="h-4" />
<Tooltip>
<TooltipTrigger asChild>
<label
htmlFor="free-web-search-toggle"
className="flex items-center gap-1.5 cursor-pointer select-none rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-accent-foreground hover:bg-accent transition-colors"
className="flex cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Globe className="size-3.5" />
<span className="hidden sm:inline">Web</span>
@ -221,7 +220,6 @@ export const FreeComposer: FC = () => {
id="free-web-search-toggle"
checked={webSearchEnabled}
onCheckedChange={setWebSearchEnabled}
className="scale-75"
/>
</label>
</TooltipTrigger>

View file

@ -1,10 +1,18 @@
"use client";
import { Bot, Check, ChevronDown, Search } from "lucide-react";
import { Bot, Check, ChevronDown } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import type { AnonModel } from "@/contracts/types/anonymous-chat.types";
@ -19,21 +27,18 @@ export function FreeModelSelector({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const [models, setModels] = useState<AnonModel[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [focusedIndex, setFocusedIndex] = useState(-1);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
anonymousChatApiService.getModels().then(setModels).catch(console.error);
}, []);
const handleOpenChange = useCallback((next: boolean) => {
if (next) {
setSearchQuery("");
setFocusedIndex(-1);
requestAnimationFrame(() => searchInputRef.current?.focus());
}
setOpen(next);
const controller = new AbortController();
anonymousChatApiService
.getModels()
.then((data) => {
if (!controller.signal.aborted) setModels(data);
})
.catch((err) => {
if (!controller.signal.aborted) console.error(err);
});
return () => controller.abort();
}, []);
const currentModel = useMemo(
@ -41,22 +46,12 @@ export function FreeModelSelector({ className }: { className?: string }) {
[models, currentSlug]
);
// Free models first, premium last; immutable sort to avoid mutating state.
const sortedModels = useMemo(
() => [...models].sort((a, b) => Number(a.is_premium) - Number(b.is_premium)),
() => models.toSorted((a, b) => Number(a.is_premium) - Number(b.is_premium)),
[models]
);
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return sortedModels;
const q = searchQuery.toLowerCase();
return sortedModels.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.model_name.toLowerCase().includes(q) ||
m.provider.toLowerCase().includes(q)
);
}, [sortedModels, searchQuery]);
const handleSelect = useCallback(
(model: AnonModel) => {
setOpen(false);
@ -70,42 +65,15 @@ export function FreeModelSelector({ className }: { className?: string }) {
[currentSlug, anonMode, router]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
const count = filteredModels.length;
if (count === 0) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setFocusedIndex((p) => (p < count - 1 ? p + 1 : 0));
break;
case "ArrowUp":
e.preventDefault();
setFocusedIndex((p) => (p > 0 ? p - 1 : count - 1));
break;
case "Enter":
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < count) {
handleSelect(filteredModels[focusedIndex]);
}
break;
}
},
[filteredModels, focusedIndex, handleSelect]
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
role="combobox"
aria-expanded={open}
className={cn(
"h-8 gap-2 px-3 text-sm bg-muted hover:bg-muted/80 border-0 select-none",
className
)}
className={cn("gap-2 bg-muted hover:bg-muted/80", className)}
>
{currentModel ? (
<>
@ -118,90 +86,47 @@ export function FreeModelSelector({ className }: { className?: string }) {
<span className="text-muted-foreground">Select Model</span>
</>
)}
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0" />
<ChevronDown className="ml-1 size-3.5 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[320px] p-0 rounded-lg shadow-lg overflow-hidden select-none"
align="start"
sideOffset={8}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
ref={searchInputRef}
placeholder="Search models"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full pl-8 pr-3 py-2.5 text-sm bg-transparent focus:outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="overflow-y-auto max-h-[320px] py-1 space-y-0.5">
{filteredModels.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 px-4">
<Search className="size-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No models found</p>
</div>
) : (
filteredModels.map((model, index) => {
const isSelected = model.seo_slug === currentSlug;
const isFocused = focusedIndex === index;
return (
<div
key={model.id}
role="option"
tabIndex={0}
aria-selected={isSelected}
onClick={() => handleSelect(model)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect(model);
}
}}
onMouseEnter={() => setFocusedIndex(index)}
className={cn(
"group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer",
"transition-colors duration-150 mx-2",
"hover:bg-accent hover:text-accent-foreground",
isFocused && "bg-accent text-accent-foreground",
isSelected && "bg-accent text-accent-foreground"
)}
>
<div className="shrink-0">
{getProviderIcon(model.provider, { className: "size-5" })}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm truncate">{model.name}</span>
{model.is_premium ? (
<Badge
variant="secondary"
className="text-[9px] px-1 py-0 h-3.5 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
>
Premium
</Badge>
) : (
<Badge
variant="secondary"
className="text-[9px] px-1 py-0 h-3.5 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
>
Free
</Badge>
)}
<PopoverContent className="w-[320px] p-0" align="start" sideOffset={8}>
<Command
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput placeholder="Search models" />
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
<CommandGroup>
{sortedModels.map((model) => {
const isSelected = model.seo_slug === currentSlug;
return (
<CommandItem
key={model.id}
value={`${model.name} ${model.model_name} ${model.provider}`}
onSelect={() => handleSelect(model)}
className="gap-2.5"
>
<div className="shrink-0">
{getProviderIcon(model.provider, { className: "size-5" })}
</div>
<span className="text-xs text-muted-foreground truncate block">
{model.model_name}
</span>
</div>
{isSelected && <Check className="size-4 text-primary shrink-0" />}
</div>
);
})
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{model.name}</span>
<Badge variant={model.is_premium ? "default" : "secondary"}>
{model.is_premium ? "Premium" : "Free"}
</Badge>
</div>
<span className="block truncate text-xs text-muted-foreground">
{model.model_name}
</span>
</div>
{isSelected && <Check className="size-4 shrink-0 text-primary" />}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);

View file

@ -4,6 +4,14 @@ import { Lock } from "lucide-react";
import Link from "next/link";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
interface GatedTabProps {
title: string;
@ -11,16 +19,20 @@ interface GatedTabProps {
}
const GatedTab: FC<GatedTabProps> = ({ title, description }) => (
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center">
<div className="rounded-full bg-muted p-3">
<Lock className="size-5 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium">{title}</h3>
<p className="text-xs text-muted-foreground max-w-[200px]">{description}</p>
<Button size="sm" asChild>
<Link href="/register">Create Free Account</Link>
</Button>
</div>
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Lock />
</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button size="sm" asChild>
<Link href="/register">Create Free Account</Link>
</Button>
</EmptyContent>
</Empty>
);
export const ReportsGatedPlaceholder: FC = () => (

View file

@ -2,6 +2,7 @@
import { OctagonAlert, Orbit } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
@ -19,38 +20,30 @@ export function QuotaBar({ used, limit, warningThreshold, className }: QuotaBarP
const isExceeded = used >= limit;
return (
<div className={cn("space-y-1.5", className)}>
<div className="flex justify-between items-center text-xs">
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{used.toLocaleString()} / {limit.toLocaleString()} tokens
</span>
{isExceeded ? (
<span className="font-medium text-red-500">Limit reached</span>
<span className="font-medium text-destructive">Limit reached</span>
) : isWarning ? (
<span className="font-medium text-amber-500 flex items-center gap-1">
<OctagonAlert className="h-3 w-3" />
<span className="flex items-center gap-1 font-medium text-highlight">
<OctagonAlert className="size-3" />
{remaining.toLocaleString()} remaining
</span>
) : (
<span className="font-medium">{percentage.toFixed(0)}%</span>
)}
</div>
<Progress
value={percentage}
className={cn(
"h-1.5",
isExceeded && "[&>div]:bg-red-500",
isWarning && !isExceeded && "[&>div]:bg-amber-500"
)}
/>
<Progress value={percentage} className="h-1.5" />
{isExceeded && (
<Link
href="/register"
className="flex items-center justify-center gap-1.5 rounded-md bg-linear-to-r from-purple-600 to-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-opacity hover:opacity-90"
>
<Orbit className="h-3 w-3" />
Create free account for 5M more tokens
</Link>
<Button asChild size="sm" className="mt-0.5 w-full">
<Link href="/register">
<Orbit data-icon="inline-start" />
Create free account for 5M more tokens
</Link>
</Button>
)}
</div>
);

View file

@ -3,6 +3,7 @@
import { OctagonAlert, Orbit, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@ -27,61 +28,46 @@ export function QuotaWarningBanner({
if (isExceeded) {
return (
<div
className={cn(
"rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/50 p-4",
className
)}
>
<div className="flex items-start gap-3">
<OctagonAlert className="h-5 w-5 text-red-500 shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
Free token limit reached
</p>
<p className="text-xs text-red-600 dark:text-red-300">
You&apos;ve used all {limit.toLocaleString()} free tokens. Create a free account to
get $5 of premium credit and access to all models.
</p>
<Link
href="/register"
className="inline-flex items-center gap-1.5 rounded-md bg-linear-to-r from-purple-600 to-blue-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
>
<Orbit className="h-4 w-4" />
<Alert variant="destructive" className={className}>
<OctagonAlert />
<AlertTitle>Free token limit reached</AlertTitle>
<AlertDescription>
<p>
You&apos;ve used all {limit.toLocaleString()} free tokens. Create a free account to get
$5 of premium credit and access to all models.
</p>
<Button asChild size="sm" className="mt-1">
<Link href="/register">
<Orbit data-icon="inline-start" />
Create Free Account
</Link>
</div>
</div>
</div>
</Button>
</AlertDescription>
</Alert>
);
}
return (
<div
className={cn(
"rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/50 p-3",
className
)}
>
<div className="flex items-center gap-3">
<OctagonAlert className="h-4 w-4 text-amber-500 shrink-0" />
<p className="flex-1 text-xs text-amber-700 dark:text-amber-300">
You&apos;ve used {used.toLocaleString()} of {limit.toLocaleString()} free tokens.{" "}
<Link href="/register" className="font-medium underline hover:no-underline">
Create an account
</Link>{" "}
for $5 of premium credit.
</p>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setDismissed(true)}
className="size-6 text-amber-400 hover:bg-transparent hover:text-amber-600 dark:hover:text-amber-200"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<Alert variant="warning" className={cn("pr-10", className)}>
<OctagonAlert />
<AlertTitle>Running low on free tokens</AlertTitle>
<AlertDescription>
You&apos;ve used {used.toLocaleString()} of {limit.toLocaleString()} free tokens.{" "}
<Link href="/register" className="font-medium underline hover:no-underline">
Create an account
</Link>{" "}
for $5 of premium credit.
</AlertDescription>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
className="absolute top-2 right-2 size-6"
>
<X />
</Button>
</Alert>
);
}

View file

@ -89,10 +89,7 @@ const DesktopLocalTabContent = dynamic(
{ ssr: false }
);
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = [
"USER_MEMORY",
"TEAM_MEMORY",
];
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["USER_MEMORY", "TEAM_MEMORY"];
const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [
{
id: -1001,

View file

@ -220,13 +220,7 @@ export const DocumentMentionPicker = forwardRef<
DocumentMentionPickerRef,
DocumentMentionPickerProps
>(function DocumentMentionPicker(
{
searchSpaceId,
onSelectionChange,
onDone,
initialSelectedDocuments = [],
externalSearch = "",
},
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
ref
) {
const search = externalSearch;

View file

@ -0,0 +1,94 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2 text-center", className)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
);
}
export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };

View file

@ -9,6 +9,8 @@ export interface AnonymousModeContextValue {
setModelSlug: (slug: string) => void;
uploadedDoc: { filename: string; sizeBytes: number } | null;
setUploadedDoc: (doc: { filename: string; sizeBytes: number } | null) => void;
webSearchEnabled: boolean;
setWebSearchEnabled: (enabled: boolean) => void;
resetKey: number;
resetChat: () => void;
}
@ -34,6 +36,7 @@ export function AnonymousModeProvider({
const [uploadedDoc, setUploadedDoc] = useState<{ filename: string; sizeBytes: number } | null>(
null
);
const [webSearchEnabled, setWebSearchEnabled] = useState(true);
const [resetKey, setResetKey] = useState(0);
const resetChat = () => setResetKey((k) => k + 1);
@ -56,10 +59,12 @@ export function AnonymousModeProvider({
setModelSlug,
uploadedDoc,
setUploadedDoc,
webSearchEnabled,
setWebSearchEnabled,
resetKey,
resetChat,
}),
[modelSlug, uploadedDoc, resetKey]
[modelSlug, uploadedDoc, webSearchEnabled, resetKey]
);
return <AnonymousModeContext.Provider value={value}>{children}</AnonymousModeContext.Provider>;