feat: no login experience and prem tokens
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-15 17:02:00 -07:00
parent 87452bb315
commit ff4e0f9b62
68 changed files with 5914 additions and 121 deletions

View file

@ -10,4 +10,8 @@ DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/post
NEXT_PUBLIC_DEPLOYMENT_MODE="self-hosted" or "cloud"
# PostHog analytics (optional, leave empty to disable)
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_KEY=
# Cloudflare Turnstile CAPTCHA for anonymous chat abuse prevention
# Get your site key from https://dash.cloudflare.com/ -> Turnstile
NEXT_PUBLIC_TURNSTILE_SITE_KEY=

View file

@ -0,0 +1,24 @@
"use client";
import type { ReactNode } from "react";
import { use } from "react";
import { FreeLayoutDataProvider } from "@/components/layout/providers/FreeLayoutDataProvider";
import { AnonymousModeProvider } from "@/contexts/anonymous-mode";
import { LoginGateProvider } from "@/contexts/login-gate";
interface FreeModelLayoutProps {
children: ReactNode;
params: Promise<{ model_slug: string }>;
}
export default function FreeModelLayout({ children, params }: FreeModelLayoutProps) {
const { model_slug } = use(params);
return (
<AnonymousModeProvider initialModelSlug={model_slug}>
<LoginGateProvider>
<FreeLayoutDataProvider>{children}</FreeLayoutDataProvider>
</LoginGateProvider>
</AnonymousModeProvider>
);
}

View file

@ -0,0 +1,254 @@
import { SquareArrowOutUpRight } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { FreeChatPage } from "@/components/free-chat/free-chat-page";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { AnonModel } from "@/contracts/types/anonymous-chat.types";
import { BACKEND_URL } from "@/lib/env-config";
interface PageProps {
params: Promise<{ model_slug: string }>;
}
async function getModel(slug: string): Promise<AnonModel | null> {
try {
const res = await fetch(
`${BACKEND_URL}/api/v1/public/anon-chat/models/${encodeURIComponent(slug)}`,
{ next: { revalidate: 300 } }
);
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
async function getAllModels(): Promise<AnonModel[]> {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/models`, {
next: { revalidate: 300 },
});
if (!res.ok) return [];
return res.json();
} catch {
return [];
}
}
function buildSeoTitle(model: AnonModel): string {
if (model.seo_title) return model.seo_title;
return `${model.name} Free Online Without Login | No Sign-Up AI Chat | SurfSense`;
}
function buildSeoDescription(model: AnonModel): string {
if (model.seo_description) return model.seo_description;
return `Use ${model.name} free online without login. No sign-up required. Chat with ${model.name} by ${model.provider} instantly on SurfSense, the open source ChatGPT alternative with no login.`;
}
function buildModelFaq(model: AnonModel) {
return [
{
question: `Can I use ${model.name} without login?`,
answer: `Yes. ${model.name} is available on SurfSense without login. No account creation, no email, no password. Just open the page and start chatting with ${model.name} for free.`,
},
{
question: `Is ${model.name} really free on SurfSense?`,
answer: `Yes! You can use ${model.name} completely free without login or sign-up. SurfSense gives you 1 million free tokens to use across any model, including ${model.name}.`,
},
{
question: `How do I use ${model.name} with no login?`,
answer: `Just start typing in the chat box above. ${model.name} will respond instantly. No login wall, no sign-up form, no verification. Your conversations are not stored in any database.`,
},
{
question: `What can I do with ${model.name} on SurfSense?`,
answer: `You can ask questions, get explanations, write content, brainstorm ideas, debug code, and more. ${model.name} is a powerful AI assistant available for free without login on SurfSense.`,
},
{
question: `How is SurfSense different from using ${model.name} directly?`,
answer: `SurfSense gives you free access without login to ${model.name} and many other AI models in one place. Create a free account to unlock document Q&A, team collaboration, and integrations with Slack, Google Drive, Notion, and more.`,
},
];
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { model_slug } = await params;
const model = await getModel(model_slug);
if (!model) return { title: "Model Not Found | SurfSense" };
const title = buildSeoTitle(model);
const description = buildSeoDescription(model);
const canonicalUrl = `https://surfsense.com/free/${model.seo_slug}`;
const modelNameLower = model.name.toLowerCase();
return {
title,
description,
alternates: { canonical: canonicalUrl },
keywords: [
`${modelNameLower} free`,
`free ${modelNameLower}`,
`${modelNameLower} online`,
`${modelNameLower} online free`,
`${modelNameLower} without login`,
`${modelNameLower} no login`,
`${modelNameLower} no sign up`,
`${modelNameLower} login free`,
`${modelNameLower} free without login`,
`${modelNameLower} free no login`,
`${modelNameLower} chat free`,
`${modelNameLower} free online`,
`use ${modelNameLower} for free`,
`use ${modelNameLower} without login`,
`${modelNameLower} alternative`,
`${modelNameLower} alternative free`,
"chatgpt no login",
"chatgpt without login",
"free ai chat no login",
"ai chat without login",
"free ai apps",
],
openGraph: {
title,
description,
url: canonicalUrl,
siteName: "SurfSense",
type: "website",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: `${model.name} Free Chat on SurfSense`,
},
],
},
twitter: {
card: "summary_large_image",
title,
description,
images: ["/og-image.png"],
},
};
}
export async function generateStaticParams() {
const models = await getAllModels();
return models.filter((m) => m.seo_slug).map((m) => ({ model_slug: m.seo_slug! }));
}
export default async function FreeModelPage({ params }: PageProps) {
const { model_slug } = await params;
const [model, allModels] = await Promise.all([getModel(model_slug), getAllModels()]);
if (!model) notFound();
const description = buildSeoDescription(model);
const faqItems = buildModelFaq(model);
const relatedModels = allModels
.filter((m) => m.seo_slug && m.seo_slug !== model.seo_slug)
.slice(0, 4);
return (
<>
{/* Invisible SEO metadata */}
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "WebApplication",
name: `${model.name} Free Chat Without Login - SurfSense`,
description,
url: `https://surfsense.com/free/${model.seo_slug}`,
applicationCategory: "ChatApplication",
operatingSystem: "Web",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
description: `Free access to ${model.name} AI chat without login`,
},
provider: {
"@type": "Organization",
name: "SurfSense",
url: "https://surfsense.com",
},
isPartOf: {
"@type": "WebSite",
name: "SurfSense",
url: "https://surfsense.com",
},
}}
/>
<FAQJsonLd questions={faqItems} />
{/* Chat fills the entire viewport area inside LayoutShell */}
<div className="h-full">
<FreeChatPage />
</div>
{/* SEO content: in DOM for crawlers, clipped by parent overflow-hidden */}
<div className="border-t bg-background">
<article className="container mx-auto px-4 py-10 max-w-3xl">
<BreadcrumbNav
items={[
{ name: "Home", href: "/" },
{ name: "Free AI Chat", href: "/free" },
{ name: model.name, href: `/free/${model.seo_slug}` },
]}
/>
<header className="mt-6 mb-6">
<h1 className="text-2xl font-bold mb-2">Chat with {model.name} Free, No Login</h1>
<p className="text-sm text-muted-foreground leading-relaxed">
Use <strong>{model.name}</strong> free online without login or sign-up. No account, no
email, no password needed. Powered by SurfSense.
</p>
</header>
<Separator className="my-8" />
<section>
<h2 className="text-xl font-bold mb-4">
{model.name} Free: Frequently Asked Questions
</h2>
<dl className="flex flex-col gap-3">
{faqItems.map((item) => (
<div key={item.question} className="rounded-lg border bg-card p-4">
<dt className="font-medium text-sm">{item.question}</dt>
<dd className="mt-1.5 text-sm text-muted-foreground leading-relaxed">
{item.answer}
</dd>
</div>
))}
</dl>
</section>
{relatedModels.length > 0 && (
<>
<Separator className="my-8" />
<nav aria-label="Other free AI models">
<h2 className="text-xl font-bold mb-4">Try Other Free AI Models</h2>
<div className="flex flex-wrap gap-2">
{relatedModels.map((m) => (
<Button key={m.id} variant="outline" size="sm" asChild>
<Link href={`/free/${m.seo_slug}`}>
{m.name}
<SquareArrowOutUpRight className="size-3" />
</Link>
</Button>
))}
<Button variant="outline" size="sm" asChild>
<Link href="/free">View All Models</Link>
</Button>
</div>
</nav>
</>
)}
</article>
</div>
</>
);
}

View file

@ -0,0 +1,387 @@
import { SquareArrowOutUpRight } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AnonModel } from "@/contracts/types/anonymous-chat.types";
import { BACKEND_URL } from "@/lib/env-config";
export const metadata: Metadata = {
title: "ChatGPT Free Online Without Login | Chat GPT No Login, Claude AI Free | SurfSense",
description:
"Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and more for free. No sign-up required. Open source NotebookLM alternative with free AI chat and document Q&A.",
keywords: [
"chatgpt free",
"chat gpt free",
"free chatgpt",
"free chat gpt",
"chatgpt online",
"chat gpt online",
"online chatgpt",
"chatgpt free online",
"chatgpt online free",
"chat gpt free online",
"chatgpt no login",
"chatgpt without login",
"chat gpt login free",
"chat gpt login",
"free chatgpt without login",
"free chatgpt no login",
"ai chat no login",
"ai chat without login",
"claude ai without login",
"claude no login",
"chatgpt for free",
"gpt chat free",
"claude ai free",
"claude free",
"free claude ai",
"free claude",
"chatgpt alternative free",
"free chatgpt alternative",
"chatgpt free alternative",
"free alternative to chatgpt",
"alternative to chatgpt free",
"ai like chatgpt",
"sites like chatgpt",
"free ai chatbot like chatgpt",
"free ai chatbots like chatgpt",
"apps like chatgpt for free",
"best free alternative to chatgpt",
"free ai apps",
"ai with no restrictions",
"notebooklm alternative",
],
alternates: {
canonical: "https://surfsense.com/free",
},
openGraph: {
title: "ChatGPT Free Online Without Login | Claude AI Free No Login | SurfSense",
description:
"Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and 100+ AI models. Open source NotebookLM alternative.",
url: "https://surfsense.com/free",
siteName: "SurfSense",
type: "website",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "SurfSense - ChatGPT Free Online, Claude AI Free, No Login Required",
},
],
},
twitter: {
card: "summary_large_image",
title: "ChatGPT Free Online Without Login | Claude AI Free No Login | SurfSense",
description:
"Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and more. No sign-up needed.",
images: ["/og-image.png"],
},
};
async function getModels(): Promise<AnonModel[]> {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/models`, {
next: { revalidate: 300 },
});
if (!res.ok) return [];
return res.json();
} catch {
return [];
}
}
const FAQ_ITEMS = [
{
question: "Can I use ChatGPT without login?",
answer:
"Yes. SurfSense lets you use ChatGPT without login or any sign-up. Just pick a model and start chatting. No email, no password, no account needed. You get 1 million free tokens to use across ChatGPT, Claude AI, Gemini, and other models.",
},
{
question: "Is ChatGPT really free on SurfSense?",
answer:
"Yes. SurfSense gives you free access to ChatGPT (GPT-4), Claude AI, Gemini, and other models without login. You get 1 million free tokens across any model with no sign-up required.",
},
{
question: "How do I use ChatGPT no login?",
answer:
"Go to any model page on SurfSense and start typing your message. There is no login wall, no account creation, and no verification step. ChatGPT no login works instantly in your browser.",
},
{
question: "What AI models can I use for free without login?",
answer:
"SurfSense offers free access without login to models from OpenAI (GPT-4, GPT-4 Turbo), Anthropic (Claude 3, Claude free), Google (Gemini), DeepSeek, Mistral, Llama, and more. All available as a free ChatGPT alternative online with no login required.",
},
{
question: "What happens after I use 1 million free tokens?",
answer:
"After your free tokens, create a free SurfSense account to unlock 5 million more. Premium model tokens can be purchased at $1 per million tokens. Non-premium models remain unlimited for registered users.",
},
{
question: "Is Claude AI available without login?",
answer:
"Yes. You can use Claude AI free without login on SurfSense. Both Claude 3 and other Anthropic models are available with no sign-up, alongside ChatGPT and Gemini.",
},
{
question: "How is SurfSense different from ChatGPT?",
answer:
"SurfSense is an open source NotebookLM alternative that gives you access to multiple AI models in one place without login. Unlike ChatGPT alone, SurfSense includes document Q&A with citations, integrations with Slack, Google Drive, Notion, and Confluence, plus team collaboration features.",
},
{
question: "Is SurfSense a free ChatGPT alternative?",
answer:
"Yes. SurfSense is a free, open source alternative to ChatGPT that works without login. It gives you access to Claude AI free, Gemini, and other AI models alongside document Q&A with citations, team collaboration, and 30+ integrations.",
},
{
question: "Is my data private when using free AI chat without login?",
answer:
"Anonymous chat sessions are not stored in any database. No account means no personal data collected. SurfSense is open source, so you can self-host for complete data control and privacy.",
},
];
export default async function FreeHubPage() {
const models = await getModels();
const seoModels = models.filter((m) => m.seo_slug);
return (
<main className="min-h-screen pt-20">
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "CollectionPage",
name: "ChatGPT Free Online Without Login - SurfSense",
description:
"Use ChatGPT, Claude AI, Gemini and more AI models free online without login or sign-up. Open source NotebookLM alternative with no login required.",
url: "https://surfsense.com/free",
isPartOf: { "@type": "WebSite", name: "SurfSense", url: "https://surfsense.com" },
mainEntity: {
"@type": "ItemList",
numberOfItems: seoModels.length,
itemListElement: seoModels.map((m, i) => ({
"@type": "ListItem",
position: i + 1,
name: m.name,
url: `https://surfsense.com/free/${m.seo_slug}`,
})),
},
}}
/>
<FAQJsonLd questions={FAQ_ITEMS} />
<article className="container mx-auto px-4 pb-20">
<BreadcrumbNav
items={[
{ name: "Home", href: "/" },
{ name: "Free AI Chat", href: "/free" },
]}
/>
{/* Hero */}
<section className="mt-8 text-center max-w-3xl mx-auto">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">
ChatGPT Free Online Without Login
</h1>
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto leading-relaxed">
Use <strong>ChatGPT</strong>, <strong>Claude AI</strong>, <strong>Gemini</strong>, and
other AI models free online without login. No sign-up, no email, no password. Pick a
model and start chatting instantly.
</p>
<div className="flex flex-wrap items-center justify-center gap-3 mt-6">
<Badge variant="secondary" className="px-3 py-1.5 text-sm">
No login required
</Badge>
<Badge variant="secondary" className="px-3 py-1.5 text-sm">
1M free tokens
</Badge>
<Badge variant="secondary" className="px-3 py-1.5 text-sm">
{seoModels.length} AI models
</Badge>
<Badge variant="secondary" className="px-3 py-1.5 text-sm">
Open source
</Badge>
</div>
</section>
<Separator className="my-12 max-w-4xl mx-auto" />
{/* Model Table */}
{seoModels.length > 0 ? (
<section
className="max-w-4xl mx-auto"
aria-label="Free AI models available without login"
>
<h2 className="text-2xl font-bold mb-2">Free AI Models Available Without Login</h2>
<p className="text-sm text-muted-foreground mb-6">
All models below work without login or sign-up. Click any model to start a free AI
chat instantly.
</p>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[45%]">Model</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Tier</TableHead>
<TableHead className="text-right w-[100px]" />
</TableRow>
</TableHeader>
<TableBody>
{seoModels.map((model) => (
<TableRow key={model.id}>
<TableCell>
<Link
href={`/free/${model.seo_slug}`}
className="group flex flex-col gap-0.5"
>
<span className="font-medium group-hover:underline">{model.name}</span>
{model.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{model.description}
</span>
)}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline">{model.provider}</Badge>
</TableCell>
<TableCell>
{model.is_premium ? (
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0">
Premium
</Badge>
) : (
<Badge variant="secondary">Free</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link href={`/free/${model.seo_slug}`}>
Chat
<SquareArrowOutUpRight className="size-3" />
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
) : (
<section className="mt-12 text-center max-w-4xl mx-auto">
<p className="text-muted-foreground">
No models are currently available. Please check back later.
</p>
</section>
)}
<Separator className="my-12 max-w-4xl mx-auto" />
{/* Why SurfSense */}
<section className="max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-6">
Why Use SurfSense as Your Free ChatGPT Alternative
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="rounded-lg border bg-card p-5">
<h3 className="font-semibold mb-1.5">Multiple AI Models in One Place</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Access ChatGPT, Claude AI free, Gemini, DeepSeek, and more. Works like sites like
ChatGPT but with all AI models available, not just GPT. A true free AI chatbot like
ChatGPT and beyond.
</p>
</div>
<div className="rounded-lg border bg-card p-5">
<h3 className="font-semibold mb-1.5">No Login, No Sign-Up Required</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Start using ChatGPT free online immediately. No email, no password, no verification.
Get ChatGPT no login access and Claude AI free access from one platform. AI with no
restrictions on which model you can use.
</p>
</div>
<div className="rounded-lg border bg-card p-5">
<h3 className="font-semibold mb-1.5">Open Source NotebookLM Alternative</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
SurfSense is a free, open source NotebookLM alternative with document Q&A and
citations, integrations with Slack, Google Drive, Notion, and Confluence, plus team
collaboration and self-hosting support.
</p>
</div>
</div>
</section>
<Separator className="my-12 max-w-4xl mx-auto" />
{/* CTA */}
<section className="max-w-3xl mx-auto text-center">
<h2 className="text-2xl font-bold mb-3">Want More Features?</h2>
<p className="text-muted-foreground mb-6 leading-relaxed">
Create a free SurfSense account to unlock 5 million tokens, document uploads with
citations, team collaboration, and integrations with Slack, Google Drive, Notion, and
30+ more tools.
</p>
<Button size="lg" asChild>
<Link href="/register">Create Free Account</Link>
</Button>
</section>
<Separator className="my-12 max-w-4xl mx-auto" />
{/* FAQ */}
<section className="max-w-3xl mx-auto">
<h2 className="text-2xl font-bold text-center mb-8">Frequently Asked Questions</h2>
<dl className="flex flex-col gap-4">
{FAQ_ITEMS.map((item) => (
<div key={item.question} className="rounded-lg border bg-card p-5">
<dt className="font-medium text-sm">{item.question}</dt>
<dd className="mt-2 text-sm text-muted-foreground leading-relaxed">
{item.answer}
</dd>
</div>
))}
</dl>
</section>
{/* Internal links */}
<nav aria-label="Related pages" className="mt-16 max-w-3xl mx-auto">
<h2 className="text-lg font-semibold mb-3">Explore SurfSense</h2>
<ul className="flex flex-wrap gap-2">
<li>
<Button variant="outline" size="sm" asChild>
<Link href="/pricing">Pricing</Link>
</Button>
</li>
<li>
<Button variant="outline" size="sm" asChild>
<Link href="/docs">Documentation</Link>
</Button>
</li>
<li>
<Button variant="outline" size="sm" asChild>
<Link href="/blog">Blog</Link>
</Button>
</li>
<li>
<Button variant="outline" size="sm" asChild>
<Link href="/register">Sign Up Free</Link>
</Button>
</li>
</ul>
</nav>
</article>
</main>
);
}

View file

@ -7,6 +7,11 @@ import { Navbar } from "@/components/homepage/navbar";
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isAuthPage = pathname === "/login" || pathname === "/register";
const isFreeModelChat = /^\/free\/[^/]+$/.test(pathname);
if (isFreeModelChat) {
return <>{children}</>;
}
return (
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">

View file

@ -0,0 +1,49 @@
"use client";
import { motion } from "motion/react";
import { useState } from "react";
import { BuyPagesContent } from "@/components/settings/buy-pages-content";
import { BuyTokensContent } from "@/components/settings/buy-tokens-content";
import { cn } from "@/lib/utils";
const TABS = [
{ id: "pages", label: "Pages" },
{ id: "tokens", label: "Premium Tokens" },
] as const;
type TabId = (typeof TABS)[number]["id"];
export default function BuyMorePage() {
const [activeTab, setActiveTab] = useState<TabId>("pages");
return (
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-md space-y-6"
>
<div className="flex items-center justify-center rounded-lg border bg-muted/30 p-1">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
activeTab === tab.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{tab.label}
</button>
))}
</div>
{activeTab === "pages" ? <BuyPagesContent /> : <BuyTokensContent />}
</motion.div>
</div>
);
}

View file

@ -1,19 +1,16 @@
"use client";
import { motion } from "motion/react";
import { BuyPagesContent } from "@/components/settings/buy-pages-content";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
export default function BuyPagesPage() {
return (
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-md space-y-6"
>
<BuyPagesContent />
</motion.div>
</div>
);
const router = useRouter();
const params = useParams();
const searchSpaceId = params?.search_space_id ?? "";
useEffect(() => {
router.replace(`/dashboard/${searchSpaceId}/buy-more`);
}, [router, searchSpaceId]);
return null;
}

View file

@ -0,0 +1,16 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
export default function BuyTokensPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params?.search_space_id ?? "";
useEffect(() => {
router.replace(`/dashboard/${searchSpaceId}/buy-more`);
}, [router, searchSpaceId]);
return null;
}

View file

@ -23,16 +23,14 @@ export default function PurchaseCancelPage() {
<CardHeader className="text-center">
<CircleSlash2 className="mx-auto h-10 w-10 text-muted-foreground" />
<CardTitle className="text-2xl">Checkout canceled</CardTitle>
<CardDescription>
No charge was made and your current pages are unchanged.
</CardDescription>
<CardDescription>No charge was made and your account is unchanged.</CardDescription>
</CardHeader>
<CardContent className="text-center text-sm text-muted-foreground">
You can return to the pricing options and try again whenever you&apos;re ready.
</CardContent>
<CardFooter className="flex flex-col gap-2 sm:flex-row">
<Button asChild className="w-full">
<Link href={`/dashboard/${searchSpaceId}/more-pages`}>Back to Buy Pages</Link>
<Link href={`/dashboard/${searchSpaceId}/buy-more`}>Back to Pricing</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Back to Dashboard</Link>

View file

@ -23,6 +23,7 @@ export default function PurchaseSuccessPage() {
useEffect(() => {
void queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
void queryClient.invalidateQueries({ queryKey: ["token-status"] });
}, [queryClient]);
return (
@ -31,13 +32,11 @@ export default function PurchaseSuccessPage() {
<CardHeader className="text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
<CardTitle className="text-2xl">Purchase complete</CardTitle>
<CardDescription>
Your additional pages are being applied to your account now.
</CardDescription>
<CardDescription>Your purchase is being applied to your account now.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-center">
<p className="text-sm text-muted-foreground">
Your sidebar usage meter should refresh automatically in a moment.
Your usage meters should refresh automatically in a moment.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-2">
@ -45,7 +44,7 @@ export default function PurchaseSuccessPage() {
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Back to Dashboard</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href={`/dashboard/${searchSpaceId}/more-pages`}>Buy More Pages</Link>
<Link href={`/dashboard/${searchSpaceId}/buy-more`}>Buy More</Link>
</Button>
</CardFooter>
</Card>

View file

@ -14,14 +14,31 @@ const changelogSource = loader({
});
const BASE_URL = "https://surfsense.com";
const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
export default function sitemap(): MetadataRoute.Sitemap {
async function getFreeModelSlugs(): Promise<string[]> {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/models`, {
next: { revalidate: 3600 },
});
if (!res.ok) return [];
const models = await res.json();
return models
.filter((m: { seo_slug?: string }) => m.seo_slug)
.map((m: { seo_slug: string }) => m.seo_slug);
} catch {
return [];
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const now = new Date();
now.setMinutes(0, 0, 0);
const lastModified = now;
const staticPages: MetadataRoute.Sitemap = [
{ url: `${BASE_URL}/`, lastModified, changeFrequency: "daily", priority: 1 },
{ url: `${BASE_URL}/free`, lastModified, changeFrequency: "daily", priority: 0.95 },
{ url: `${BASE_URL}/pricing`, lastModified, changeFrequency: "weekly", priority: 0.9 },
{ url: `${BASE_URL}/contact`, lastModified, changeFrequency: "monthly", priority: 0.7 },
{ url: `${BASE_URL}/blog`, lastModified, changeFrequency: "daily", priority: 0.9 },
@ -34,6 +51,14 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${BASE_URL}/register`, lastModified, changeFrequency: "monthly", priority: 0.5 },
];
const slugs = await getFreeModelSlugs();
const freeModelPages: MetadataRoute.Sitemap = slugs.map((slug) => ({
url: `${BASE_URL}/free/${slug}`,
lastModified,
changeFrequency: "daily" as const,
priority: 0.9,
}));
const docsPages: MetadataRoute.Sitemap = docsSource.getPages().map((page) => ({
url: `${BASE_URL}${page.url}`,
lastModified,
@ -55,5 +80,5 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.5,
}));
return [...staticPages, ...docsPages, ...blogPages, ...changelogPages];
return [...staticPages, ...freeModelPages, ...docsPages, ...blogPages, ...changelogPages];
}

View file

@ -2,6 +2,7 @@ import { atomWithQuery } from "jotai-tanstack-query";
import type { LLMModel } from "@/contracts/enums/llm-models";
import { LLM_MODELS } from "@/contracts/enums/llm-models";
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
@ -30,6 +31,7 @@ export const globalNewLLMConfigsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.newLLMConfigs.global(),
staleTime: 10 * 60 * 1000, // 10 minutes - global configs rarely change
enabled: !!getBearerToken(),
queryFn: async () => {
return newLLMConfigApiService.getGlobalConfigs();
},

View file

@ -76,9 +76,7 @@ export function AnnouncementToastProvider() {
for (let i = 0; i < importantUntoasted.length; i++) {
const announcement = importantUntoasted[i];
staggerTimers.push(
setTimeout(() => showAnnouncementToast(announcement), i * 800)
);
staggerTimers.push(setTimeout(() => showAnnouncementToast(announcement), i * 800));
}
}, 1500);

View file

@ -36,12 +36,14 @@ interface DocumentUploadDialogContextType {
const DocumentUploadDialogContext = createContext<DocumentUploadDialogContextType | null>(null);
export const useDocumentUploadDialog = () => {
const NOOP_DIALOG: DocumentUploadDialogContextType = {
openDialog: () => {},
closeDialog: () => {},
};
export const useDocumentUploadDialog = (): DocumentUploadDialogContextType => {
const context = useContext(DocumentUploadDialogContext);
if (!context) {
throw new Error("useDocumentUploadDialog must be used within DocumentUploadDialogProvider");
}
return context;
return context ?? NOOP_DIALOG;
};
// Provider component

View file

@ -1,10 +1,12 @@
"use client";
import { FileText } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
import { Citation } from "@/components/tool-ui/citation";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface InlineCitationProps {
chunkId: number;
@ -14,10 +16,28 @@ interface InlineCitationProps {
/**
* Inline citation for knowledge-base chunks (numeric chunk IDs).
* Renders a clickable badge showing the actual chunk ID that opens the SourceDetailPanel.
* Negative chunk IDs indicate anonymous/synthetic uploads and render as a static badge.
*/
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk = false }) => {
const [isOpen, setIsOpen] = useState(false);
if (chunkId < 0) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-primary/10 px-1.5 text-[11px] font-medium text-primary align-baseline shadow-sm"
role="note"
>
<FileText className="size-3" />
doc
</span>
</TooltipTrigger>
<TooltipContent>Uploaded document</TooltipContent>
</Tooltip>
);
}
return (
<SourceDetailPanel
open={isOpen}

View file

@ -106,11 +106,11 @@ function preprocessMarkdown(content: string): string {
return content;
}
// Matches [citation:...] with numeric IDs (incl. doc- prefix, comma-separated),
// Matches [citation:...] with numeric IDs (incl. negative, doc- prefix, comma-separated),
// URL-based IDs from live web search, or urlciteN placeholders from preprocess.
// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts.
const CITATION_REGEX =
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?\d+(?:\s*,\s*(?:doc-)?\d+)*)\s*\u200B?[\]】]/g;
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
/**
* Parses text and replaces [citation:XXX] patterns with citation components.

View file

@ -27,6 +27,7 @@ export function DocumentsFilters({
aiSortEnabled = false,
aiSortBusy = false,
onToggleAiSort,
onUploadClick,
}: {
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
onSearch: (v: string) => void;
@ -37,12 +38,14 @@ export function DocumentsFilters({
aiSortEnabled?: boolean;
aiSortBusy?: boolean;
onToggleAiSort?: () => void;
onUploadClick?: () => void;
}) {
const t = useTranslations("documents");
const id = React.useId();
const inputRef = useRef<HTMLInputElement>(null);
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const handleUpload = onUploadClick ?? openUploadDialog;
const [typeSearchQuery, setTypeSearchQuery] = useState("");
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
@ -254,7 +257,7 @@ export function DocumentsFilters({
{/* Upload Button */}
<Button
data-joyride="upload-button"
onClick={openUploadDialog}
onClick={handleUpload}
variant="outline"
size="sm"
className="h-9 shrink-0 gap-1.5 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"

View file

@ -0,0 +1,271 @@
"use client";
import { ArrowUp, Loader2, Square } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { readSSEStream } from "@/lib/chat/streaming-state";
import { cn } from "@/lib/utils";
import { QuotaBar } from "./quota-bar";
import { QuotaWarningBanner } from "./quota-warning-banner";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
}
interface AnonymousChatProps {
model: AnonModel;
}
export function AnonymousChat({ model }: AnonymousChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [quota, setQuota] = useState<AnonQuotaResponse | null>(null);
const abortRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
anonymousChatApiService.getQuota().then(setQuota).catch(console.error);
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const autoResizeTextarea = useCallback(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}, []);
const handleSubmit = useCallback(async () => {
const trimmed = input.trim();
if (!trimmed || isStreaming) return;
if (quota && quota.used >= quota.limit) return;
const userMsg: Message = { id: crypto.randomUUID(), role: "user", content: trimmed };
const assistantId = crypto.randomUUID();
const assistantMsg: Message = { id: assistantId, role: "assistant", content: "" };
setMessages((prev) => [...prev, userMsg, assistantMsg]);
setInput("");
setIsStreaming(true);
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
const controller = new AbortController();
abortRef.current = controller;
try {
const chatHistory = [...messages, userMsg].map((m) => ({
role: m.role,
content: m.content,
}));
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"}/api/v1/public/anon-chat/stream`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
model_slug: model.seo_slug,
messages: chatHistory,
}),
signal: controller.signal,
}
);
if (!response.ok) {
if (response.status === 429) {
const errorData = await response.json();
setQuota({
used: errorData.detail?.used ?? quota?.limit ?? 1000000,
limit: errorData.detail?.limit ?? quota?.limit ?? 1000000,
remaining: 0,
status: "exceeded",
warning_threshold: quota?.warning_threshold ?? 800000,
});
setMessages((prev) => prev.filter((m) => m.id !== assistantId));
return;
}
throw new Error(`Stream error: ${response.status}`);
}
for await (const event of readSSEStream(response)) {
if (controller.signal.aborted) break;
if (event.type === "text-delta") {
setMessages((prev) =>
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + event.delta } : m))
);
} else if (event.type === "error") {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: m.content || event.errorText } : m
)
);
} else if ("type" in event && event.type === "data-token-usage") {
// After streaming completes, refresh quota
anonymousChatApiService.getQuota().then(setQuota).catch(console.error);
}
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
console.error("Chat stream error:", err);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId && !m.content
? { ...m, content: "An error occurred. Please try again." }
: m
)
);
} finally {
setIsStreaming(false);
abortRef.current = null;
anonymousChatApiService.getQuota().then(setQuota).catch(console.error);
}
}, [input, isStreaming, messages, model.seo_slug, quota]);
const handleCancel = useCallback(() => {
abortRef.current?.abort();
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const isExceeded = quota ? quota.used >= quota.limit : false;
return (
<div className="flex flex-col h-[calc(100vh-8rem)] max-w-3xl mx-auto">
{quota && (
<QuotaWarningBanner
used={quota.used}
limit={quota.limit}
warningThreshold={quota.warning_threshold}
className="mb-3"
/>
)}
<div className="flex-1 overflow-y-auto space-y-4 pb-4 min-h-0">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="rounded-full bg-linear-to-r from-purple-500/10 to-blue-500/10 p-4 mb-4">
<div className="h-10 w-10 rounded-full bg-linear-to-r from-purple-500 to-blue-500 flex items-center justify-center">
<span className="text-white text-lg font-bold">
{model.name.charAt(0).toUpperCase()}
</span>
</div>
</div>
<h2 className="text-xl font-semibold mb-2">{model.name}</h2>
{model.description && (
<p className="text-sm text-muted-foreground max-w-md">{model.description}</p>
)}
<p className="text-xs text-muted-foreground mt-4">
Free to use &middot; No login required &middot; Start typing below
</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={cn("flex gap-3 px-4", msg.role === "user" ? "justify-end" : "justify-start")}
>
{msg.role === "assistant" && (
<div className="h-7 w-7 rounded-full bg-linear-to-r from-purple-500 to-blue-500 flex items-center justify-center shrink-0 mt-0.5">
<span className="text-white text-xs font-bold">
{model.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div
className={cn(
"rounded-2xl px-4 py-2.5 max-w-[80%] text-sm leading-relaxed",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
{msg.role === "assistant" && !msg.content && isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<div className="whitespace-pre-wrap wrap-break-word">{msg.content}</div>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="border-t pt-3 pb-2 space-y-2">
{quota && (
<QuotaBar
used={quota.used}
limit={quota.limit}
warningThreshold={quota.warning_threshold}
/>
)}
<div className="relative">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
autoResizeTextarea();
}}
onKeyDown={handleKeyDown}
placeholder={
isExceeded
? "Token limit reached. Create a free account to continue."
: `Message ${model.name}...`
}
disabled={isExceeded}
rows={1}
className={cn(
"w-full resize-none rounded-xl border bg-background px-4 py-3 pr-12 text-sm",
"placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
"min-h-[44px] max-h-[200px]"
)}
/>
{isStreaming ? (
<button
type="button"
onClick={handleCancel}
className="absolute right-2 bottom-2 flex h-8 w-8 items-center justify-center rounded-lg bg-foreground text-background transition-colors hover:opacity-80"
>
<Square className="h-3.5 w-3.5" fill="currentColor" />
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={!input.trim() || isExceeded}
className="absolute right-2 bottom-2 flex h-8 w-8 items-center justify-center rounded-lg bg-foreground text-background transition-colors hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
<p className="text-center text-[10px] text-muted-foreground">
{model.name} via SurfSense &middot; Responses may be inaccurate
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,395 @@
"use client";
import {
type AppendMessage,
AssistantRuntimeProvider,
type ThreadMessageLike,
useExternalStoreRuntime,
} from "@assistant-ui/react";
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
import { ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import {
createTokenUsageStore,
type TokenUsageData,
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import {
addToolCall,
appendText,
buildContentForUI,
type ContentPartsState,
FrameBatchedUpdater,
readSSEStream,
type ThinkingStepData,
updateThinkingSteps,
updateToolCall,
} from "@/lib/chat/streaming-state";
import { BACKEND_URL } from "@/lib/env-config";
import { FreeModelSelector } from "./free-model-selector";
import { FreeThread } from "./free-thread";
const TOOLS_WITH_UI = new Set(["web_search", "document_qna"]);
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "";
/** Try to parse a CAPTCHA_REQUIRED or CAPTCHA_INVALID code from a non-ok response. */
function parseCaptchaError(status: number, body: string): string | null {
if (status !== 403) return null;
try {
const json = JSON.parse(body);
const code = json?.detail?.code ?? json?.error?.code;
if (code === "CAPTCHA_REQUIRED" || code === "CAPTCHA_INVALID") return code;
} catch {
/* not JSON */
}
return null;
}
export function FreeChatPage() {
const anonMode = useAnonymousMode();
const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
const resetKey = anonMode.isAnonymous ? anonMode.resetKey : 0;
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [tokenUsageStore] = useState(() => createTokenUsageStore());
const abortControllerRef = useRef<AbortController | null>(null);
// Turnstile CAPTCHA state
const [captchaRequired, setCaptchaRequired] = useState(false);
const turnstileRef = useRef<TurnstileInstance | null>(null);
const turnstileTokenRef = useRef<string | null>(null);
const pendingRetryRef = useRef<{
messageHistory: { role: string; content: string }[];
userMsgId: string;
} | null>(null);
useEffect(() => {
setMessages([]);
tokenUsageStore.clear();
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsRunning(false);
setCaptchaRequired(false);
turnstileTokenRef.current = null;
pendingRetryRef.current = null;
}, [resetKey, modelSlug, tokenUsageStore]);
const cancelRun = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsRunning(false);
}, []);
/**
* Core streaming logic shared by initial sends and CAPTCHA retries.
* Returns "captcha" if the server demands a CAPTCHA, otherwise void.
*/
const doStream = useCallback(
async (
messageHistory: { role: string; content: string }[],
assistantMsgId: string,
signal: AbortSignal,
turnstileToken: string | null
): Promise<"captcha" | void> => {
const reqBody: Record<string, unknown> = {
model_slug: modelSlug,
messages: messageHistory,
};
if (turnstileToken) reqBody.turnstile_token = turnstileToken;
const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(reqBody),
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "");
const captchaCode = parseCaptchaError(response.status, body);
if (captchaCode) return "captcha";
throw new Error(body || `Server error: ${response.status}`);
}
const currentThinkingSteps = new Map<string, ThinkingStepData>();
const batcher = new FrameBatchedUpdater();
const contentPartsState: ContentPartsState = {
contentParts: [],
currentTextPartIndex: -1,
toolCallIndices: new Map(),
};
const { toolCallIndices } = contentPartsState;
const flushMessages = () => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
};
const scheduleFlush = () => batcher.schedule(flushMessages);
try {
for await (const parsed of readSSEStream(response)) {
switch (parsed.type) {
case "text-delta":
appendText(contentPartsState, parsed.delta);
scheduleFlush();
break;
case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
batcher.flush();
break;
case "tool-input-available":
if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
} else {
addToolCall(
contentPartsState,
TOOLS_WITH_UI,
parsed.toolCallId,
parsed.toolName,
parsed.input || {}
);
}
batcher.flush();
break;
case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
batcher.flush();
break;
case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
if (updateThinkingSteps(contentPartsState, currentThinkingSteps)) scheduleFlush();
}
break;
}
case "data-token-usage":
tokenUsageStore.set(assistantMsgId, parsed.data as TokenUsageData);
break;
case "error":
throw new Error(parsed.errorText || "Server error");
}
}
batcher.flush();
} catch (err) {
batcher.dispose();
throw err;
}
},
[modelSlug, tokenUsageStore]
);
const onNew = useCallback(
async (message: AppendMessage) => {
let userQuery = "";
for (const part of message.content) {
if (part.type === "text") userQuery += part.text;
}
if (!userQuery.trim()) return;
const userMsgId = `msg-user-${Date.now()}`;
setMessages((prev) => [
...prev,
{
id: userMsgId,
role: "user" as const,
content: [{ type: "text" as const, text: userQuery }],
createdAt: new Date(),
},
]);
setIsRunning(true);
const controller = new AbortController();
abortControllerRef.current = controller;
const assistantMsgId = `msg-assistant-${Date.now()}`;
setMessages((prev) => [
...prev,
{
id: assistantMsgId,
role: "assistant" as const,
content: [{ type: "text" as const, text: "" }],
createdAt: new Date(),
},
]);
const messageHistory = messages
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => {
let text = "";
for (const part of m.content) {
if (typeof part === "object" && part.type === "text" && "text" in part) {
text += (part as { type: "text"; text: string }).text;
}
}
return { role: m.role as string, content: text };
})
.filter((m) => m.content.length > 0);
messageHistory.push({ role: "user", content: userQuery.trim() });
try {
const result = await doStream(
messageHistory,
assistantMsgId,
controller.signal,
turnstileTokenRef.current
);
// Consume the token after use regardless of outcome
turnstileTokenRef.current = null;
if (result === "captcha" && TURNSTILE_SITE_KEY) {
// Remove the empty assistant placeholder; keep the user message
setMessages((prev) => prev.filter((m) => m.id !== assistantMsgId));
pendingRetryRef.current = { messageHistory, userMsgId };
setCaptchaRequired(true);
setIsRunning(false);
abortControllerRef.current = null;
return;
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") return;
console.error("[FreeChatPage] Chat error:", error);
const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: [{ type: "text" as const, text: `Error: ${errorText}` }] }
: m
)
);
} finally {
setIsRunning(false);
abortControllerRef.current = null;
}
},
[messages, doStream]
);
/** Called when Turnstile resolves successfully. Stores the token and auto-retries. */
const handleTurnstileSuccess = useCallback(
async (token: string) => {
turnstileTokenRef.current = token;
setCaptchaRequired(false);
const pending = pendingRetryRef.current;
if (!pending) return;
pendingRetryRef.current = null;
setIsRunning(true);
const controller = new AbortController();
abortControllerRef.current = controller;
const assistantMsgId = `msg-assistant-${Date.now()}`;
setMessages((prev) => [
...prev,
{
id: assistantMsgId,
role: "assistant" as const,
content: [{ type: "text" as const, text: "" }],
createdAt: new Date(),
},
]);
try {
const result = await doStream(
pending.messageHistory,
assistantMsgId,
controller.signal,
token
);
turnstileTokenRef.current = null;
if (result === "captcha") {
setMessages((prev) => prev.filter((m) => m.id !== assistantMsgId));
pendingRetryRef.current = pending;
setCaptchaRequired(true);
turnstileRef.current?.reset();
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") return;
console.error("[FreeChatPage] Retry error:", error);
const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: [{ type: "text" as const, text: `Error: ${errorText}` }] }
: m
)
);
} finally {
setIsRunning(false);
abortControllerRef.current = null;
}
},
[doStream]
);
const convertMessage = useCallback(
(message: ThreadMessageLike): ThreadMessageLike => message,
[]
);
const runtime = useExternalStoreRuntime({
messages,
isRunning,
onNew,
convertMessage,
onCancel: cancelRun,
});
return (
<TokenUsageProvider store={tokenUsageStore}>
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<div className="flex h-full flex-col overflow-hidden">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-4">
<FreeModelSelector />
</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>
)}
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0">
<FreeThread />
</div>
</div>
</div>
</AssistantRuntimeProvider>
</TokenUsageProvider>
);
}

View file

@ -0,0 +1,262 @@
"use client";
import { ComposerPrimitive, useAui, useAuiState } from "@assistant-ui/react";
import { ArrowUpIcon, Globe, Paperclip, SquareIcon } from "lucide-react";
import { type FC, useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { BACKEND_URL } from "@/lib/env-config";
import { cn } from "@/lib/utils";
const ANON_ALLOWED_EXTENSIONS = new Set([
".md",
".markdown",
".txt",
".text",
".json",
".jsonl",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".conf",
".xml",
".css",
".scss",
".py",
".js",
".jsx",
".ts",
".tsx",
".java",
".kt",
".go",
".rs",
".rb",
".php",
".c",
".h",
".cpp",
".hpp",
".cs",
".swift",
".sh",
".sql",
".log",
".rst",
".tex",
".vue",
".svelte",
".astro",
".tf",
".proto",
".csv",
".tsv",
".html",
".htm",
".xhtml",
]);
const ACCEPT_EXTENSIONS = Array.from(ANON_ALLOWED_EXTENSIONS).join(",");
export const FreeComposer: FC = () => {
const aui = useAui();
const isRunning = useAuiState(({ thread }) => thread.isRunning);
const isEmpty = useAuiState(({ thread }) => thread.isEmpty);
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 handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(e.target.value);
aui.composer().setText(e.target.value);
},
[aui]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "/" && text === "") {
e.preventDefault();
gate("use saved prompts");
return;
}
if (e.key === "@") {
e.preventDefault();
gate("mention documents");
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (text.trim()) {
aui.composer().send();
setText("");
}
}
},
[text, aui, gate]
);
const handleUploadClick = useCallback(() => {
if (hasUploadedDoc) {
gate("upload more documents");
return;
}
fileInputRef.current?.click();
}, [hasUploadedDoc, gate]);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const ext = `.${file.name.split(".").pop()?.toLowerCase()}`;
if (!ANON_ALLOWED_EXTENSIONS.has(ext)) {
gate("upload PDFs, Word documents, images, and more");
return;
}
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/upload`, {
method: "POST",
credentials: "include",
body: formData,
});
if (res.status === 409) {
gate("upload more documents");
return;
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Upload failed: ${res.status}`);
}
const data = await res.json();
if (anonMode.isAnonymous) {
anonMode.setUploadedDoc({
filename: data.filename,
sizeBytes: data.size_bytes,
});
}
toast.success(`Uploaded "${data.filename}"`);
} catch (err) {
console.error("Upload failed:", err);
toast.error(err instanceof Error ? err.message : "Upload failed");
}
},
[gate, anonMode]
);
return (
<ComposerPrimitive.Root className="aui-composer-root relative mx-auto flex w-full max-w-(--thread-max-width) flex-col rounded-2xl border border-border/40 bg-background shadow-xs transition-shadow focus-within:shadow-md dark:bg-neutral-900">
{hasUploadedDoc && anonMode.isAnonymous && (
<div className="flex items-center gap-2 px-3 pt-2">
<Paperclip className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground truncate">
{anonMode.uploadedDoc?.filename}
</span>
<span className="text-xs text-muted-foreground/60">(1/1)</span>
</div>
)}
<textarea
placeholder="Ask anything..."
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
rows={1}
className={cn(
"w-full resize-none bg-transparent px-4 pt-3 pb-0 text-sm",
"placeholder:text-muted-foreground focus:outline-none",
"min-h-[44px] max-h-[200px]"
)}
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
<div className="flex items-center justify-between gap-2 px-3 pb-2 pt-1">
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_EXTENSIONS}
className="hidden"
onChange={handleFileChange}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleUploadClick}
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors",
"text-muted-foreground hover:text-foreground hover:bg-accent/50",
hasUploadedDoc && "text-primary"
)}
>
<Paperclip className="size-3.5" />
{hasUploadedDoc ? "1/1" : "Upload"}
</button>
</TooltipTrigger>
<TooltipContent>
{hasUploadedDoc
? "Document limit reached. Create an account for more."
: "Upload a document (text files only)"}
</TooltipContent>
</Tooltip>
<div className="h-4 w-px bg-border/60" />
<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-foreground hover:bg-accent/50 transition-colors">
<Globe className="size-3.5" />
<span className="hidden sm:inline">Web</span>
<Switch
id="free-web-search-toggle"
checked={webSearchEnabled}
onCheckedChange={setWebSearchEnabled}
className="scale-75"
/>
</label>
</TooltipTrigger>
<TooltipContent>Toggle web search</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1">
{!isRunning ? (
<ComposerPrimitive.Send asChild>
<TooltipIconButton tooltip="Send" variant="default" className="size-8 rounded-full">
<ArrowUpIcon />
</TooltipIconButton>
</ComposerPrimitive.Send>
) : (
<ComposerPrimitive.Cancel asChild>
<TooltipIconButton
tooltip="Cancel"
variant="destructive"
className="size-8 rounded-full"
>
<SquareIcon className="size-3.5" />
</TooltipIconButton>
</ComposerPrimitive.Cancel>
)}
</div>
</div>
</ComposerPrimitive.Root>
);
};

View file

@ -0,0 +1,195 @@
"use client";
import { Bot, Check, ChevronDown, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import type { AnonModel } from "@/contracts/types/anonymous-chat.types";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
export function FreeModelSelector({ className }: { className?: string }) {
const router = useRouter();
const anonMode = useAnonymousMode();
const currentSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
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);
}, []);
useEffect(() => {
if (open) {
setSearchQuery("");
setFocusedIndex(-1);
requestAnimationFrame(() => searchInputRef.current?.focus());
}
}, [open]);
const currentModel = useMemo(
() => models.find((m) => m.seo_slug === currentSlug) ?? null,
[models, currentSlug]
);
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models;
const q = searchQuery.toLowerCase();
return models.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.model_name.toLowerCase().includes(q) ||
m.provider.toLowerCase().includes(q)
);
}, [models, searchQuery]);
const handleSelect = useCallback(
(model: AnonModel) => {
setOpen(false);
if (model.seo_slug === currentSlug) return;
if (anonMode.isAnonymous) {
anonMode.setModelSlug(model.seo_slug ?? "");
anonMode.resetChat();
}
router.replace(`/free/${model.seo_slug}`);
},
[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={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
role="combobox"
aria-expanded={open}
className={cn(
"h-8 gap-2 px-3 text-sm bg-main-panel hover:bg-accent/50 dark:hover:bg-white/6 border border-border/40 select-none",
className
)}
>
{currentModel ? (
<>
{getProviderIcon(currentModel.provider, { className: "size-4" })}
<span className="max-w-[160px] truncate">{currentModel.name}</span>
</>
) : (
<>
<Bot className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">Select Model</span>
</>
)}
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[320px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 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-all duration-150 mx-2",
"hover:bg-accent/40",
isSelected && "bg-primary/6 dark:bg-primary/8",
isFocused && "bg-accent/50"
)}
>
<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>
)}
</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>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,45 @@
"use client";
import { Lock } from "lucide-react";
import Link from "next/link";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
interface GatedTabProps {
title: string;
description: string;
}
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>
);
export const ReportsGatedPlaceholder: FC = () => (
<GatedTab
title="Generate Reports"
description="Create a free account to generate structured reports from your conversations."
/>
);
export const EditorGatedPlaceholder: FC = () => (
<GatedTab
title="Document Editor"
description="Create a free account to use the AI-powered document editor."
/>
);
export const HitlGatedPlaceholder: FC = () => (
<GatedTab
title="Human-in-the-Loop Editing"
description="Create a free account to collaborate with AI on document edits."
/>
);

View file

@ -0,0 +1,82 @@
"use client";
import { AuiIf, ThreadPrimitive } from "@assistant-ui/react";
import { ArrowDownIcon } from "lucide-react";
import type { FC } from "react";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { EditComposer } from "@/components/assistant-ui/edit-composer";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message";
import { FreeComposer } from "./free-composer";
const FreeThreadWelcome: FC = () => {
return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-5xl select-none">
What can I help with?
</h1>
</div>
<div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
<FreeComposer />
</div>
</div>
);
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
export const FreeThread: FC = () => {
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
style={{
["--thread-max-width" as string]: "44rem",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
style={{ scrollbarGutter: "stable" }}
>
<AuiIf condition={({ thread }) => thread.isEmpty}>
<FreeThreadWelcome />
</AuiIf>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage,
}}
/>
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="grow" />
</AuiIf>
<ThreadPrimitive.ViewportFooter
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
>
<ThreadScrollToBottom />
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<FreeComposer />
</AuiIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
};

View file

@ -0,0 +1,57 @@
"use client";
import { OctagonAlert, Orbit } from "lucide-react";
import Link from "next/link";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
interface QuotaBarProps {
used: number;
limit: number;
warningThreshold: number;
className?: string;
}
export function QuotaBar({ used, limit, warningThreshold, className }: QuotaBarProps) {
const percentage = Math.min((used / limit) * 100, 100);
const remaining = Math.max(limit - used, 0);
const isWarning = used >= warningThreshold;
const isExceeded = used >= limit;
return (
<div className={cn("space-y-1.5", className)}>
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{used.toLocaleString()} / {limit.toLocaleString()} tokens
</span>
{isExceeded ? (
<span className="font-medium text-red-500">Limit reached</span>
) : isWarning ? (
<span className="font-medium text-amber-500 flex items-center gap-1">
<OctagonAlert className="h-3 w-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"
)}
/>
{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>
)}
</div>
);
}

View file

@ -0,0 +1,84 @@
"use client";
import { OctagonAlert, Orbit, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface QuotaWarningBannerProps {
used: number;
limit: number;
warningThreshold: number;
className?: string;
}
export function QuotaWarningBanner({
used,
limit,
warningThreshold,
className,
}: QuotaWarningBannerProps) {
const [dismissed, setDismissed] = useState(false);
const isWarning = used >= warningThreshold && used < limit;
const isExceeded = used >= limit;
if (dismissed || (!isWarning && !isExceeded)) return null;
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 million tokens 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" />
Create Free Account
</Link>
</div>
</div>
</div>
);
}
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 5M free tokens.
</p>
<button
type="button"
onClick={() => setDismissed(true)}
className="text-amber-400 hover:text-amber-600 dark:hover:text-amber-200"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}

View file

@ -35,6 +35,7 @@ export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {
const [isScrolled, setIsScrolled] = useState(false);
const navItems = [
{ name: "Free\u00A0AI", link: "/free" },
{ name: "Pricing", link: "/pricing" },
{ name: "Blog", link: "/blog" },
{ name: "Changelog", link: "/changelog" },

View file

@ -0,0 +1,142 @@
"use client";
import { Inbox, Megaphone, SquareLibrary } from "lucide-react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { useIsMobile } from "@/hooks/use-mobile";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import type { ChatItem, NavItem, PageUsage, SearchSpace } from "../types/layout.types";
import { LayoutShell } from "../ui/shell";
interface FreeLayoutDataProviderProps {
children: ReactNode;
}
const GUEST_SPACE: SearchSpace = {
id: 0,
name: "SurfSense Free",
description: "Free AI chat without login",
isOwner: false,
memberCount: 1,
};
export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps) {
const router = useRouter();
const { gate } = useLoginGate();
const anonMode = useAnonymousMode();
const isMobile = useIsMobile();
const [quota, setQuota] = useState<{ used: number; limit: number } | null>(null);
const [isDocsSidebarOpen, setIsDocsSidebarOpen] = useState(false);
// Keep docs sidebar closed on mobile; auto-open only on desktop after hydration
useEffect(() => {
setIsDocsSidebarOpen(!isMobile);
}, [isMobile]);
useEffect(() => {
anonymousChatApiService
.getQuota()
.then((q) => {
setQuota({ used: q.used, limit: q.limit });
})
.catch(() => {});
}, []);
const resetChat = useCallback(() => {
if (anonMode.isAnonymous) {
anonMode.resetChat();
}
}, [anonMode]);
const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]);
const navItems: NavItem[] = useMemo(
() =>
[
{
title: "Inbox",
url: "#inbox",
icon: Inbox,
isActive: false,
},
isMobile
? {
title: "Documents",
url: "#documents",
icon: SquareLibrary,
isActive: false,
}
: null,
{
title: "Announcements",
url: "#announcements",
icon: Megaphone,
isActive: false,
},
].filter((item): item is NavItem => item !== null),
[isMobile]
);
const pageUsage: PageUsage | undefined = quota
? { pagesUsed: quota.used, pagesLimit: quota.limit }
: undefined;
const handleChatSelect = useCallback((_chat: ChatItem) => gate("view chat history"), [gate]);
const handleNavItemClick = useCallback(
(item: NavItem) => {
if (item.title === "Inbox") gate("use the inbox");
else if (item.title === "Documents") setIsDocsSidebarOpen((v) => !v);
else if (item.title === "Announcements") gate("view announcements");
},
[gate]
);
const handleSearchSpaceSelect = useCallback(
(_id: number) => gate("switch search spaces"),
[gate]
);
return (
<LayoutShell
searchSpaces={[GUEST_SPACE]}
activeSearchSpaceId={0}
onSearchSpaceSelect={handleSearchSpaceSelect}
onSearchSpaceSettings={gatedAction("search space settings")}
onAddSearchSpace={gatedAction("create search spaces")}
searchSpace={GUEST_SPACE}
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={[]}
sharedChats={[]}
activeChatId={null}
onNewChat={resetChat}
onChatSelect={handleChatSelect}
onChatRename={gatedAction("rename chats")}
onChatDelete={gatedAction("delete chats")}
onChatArchive={gatedAction("archive chats")}
onViewAllSharedChats={gatedAction("view shared chats")}
onViewAllPrivateChats={gatedAction("view chat history")}
user={{
email: "Guest",
name: "Guest",
}}
onSettings={gatedAction("search space settings")}
onManageMembers={gatedAction("team management")}
onUserSettings={gatedAction("account settings")}
onLogout={() => router.push("/register")}
pageUsage={pageUsage}
isChatPage
isLoadingChats={false}
documentsPanel={{
open: isDocsSidebarOpen,
onOpenChange: setIsDocsSidebarOpen,
}}
>
<Fragment>{children}</Fragment>
</LayoutShell>
);
}

View file

@ -22,6 +22,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const activeTab = useAtomValue(activeTabAtom);
const tabs = useAtomValue(tabsAtom);
const isFreePage = pathname?.startsWith("/free") ?? false;
const isChatPage = pathname?.includes("/new-chat") ?? false;
const isDocumentTab = activeTab?.type === "document";
const hasTabBar = tabs.length > 1;
@ -30,6 +31,16 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const hasThread = isChatPage && !isDocumentTab && currentThreadState.id !== null;
// Free chat pages have their own header with model selector; only render mobile trigger
if (isFreePage) {
if (!mobileMenuTrigger) return null;
return (
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4">
{mobileMenuTrigger}
</header>
);
}
const threadForButton: ThreadRecord | null =
hasThread && currentThreadState.id !== null
? {

View file

@ -2,10 +2,23 @@
import { useQuery } from "@rocicorp/zero/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { ChevronLeft, ChevronRight, FolderClock, Trash2, Unplug } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
FileText,
FolderClock,
Lock,
Paperclip,
Trash2,
Unplug,
Upload,
X,
} from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
@ -45,8 +58,11 @@ import {
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
@ -56,6 +72,7 @@ import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index";
@ -86,7 +103,15 @@ interface DocumentsSidebarProps {
headerAction?: React.ReactNode;
}
export function DocumentsSidebar({
export function DocumentsSidebar(props: DocumentsSidebarProps) {
const isAnonymous = useIsAnonymous();
if (isAnonymous) {
return <AnonymousDocumentsSidebar {...props} />;
}
return <AuthenticatedDocumentsSidebar {...props} />;
}
function AuthenticatedDocumentsSidebar({
open,
onOpenChange,
isDocked = false,
@ -1166,3 +1191,430 @@ export function DocumentsSidebar({
</SidebarSlideOutPanel>
);
}
// ---------------------------------------------------------------------------
// Anonymous Documents Sidebar
// ---------------------------------------------------------------------------
const ANON_ALLOWED_EXTENSIONS = new Set([
".md",
".markdown",
".txt",
".text",
".json",
".jsonl",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".conf",
".xml",
".css",
".scss",
".py",
".js",
".jsx",
".ts",
".tsx",
".java",
".kt",
".go",
".rs",
".rb",
".php",
".c",
".h",
".cpp",
".hpp",
".cs",
".swift",
".sh",
".sql",
".log",
".rst",
".tex",
".vue",
".svelte",
".astro",
".tf",
".proto",
".csv",
".tsv",
".html",
".htm",
".xhtml",
]);
const ANON_ACCEPT = Array.from(ANON_ALLOWED_EXTENSIONS).join(",");
function AnonymousDocumentsSidebar({
open,
onOpenChange,
isDocked = false,
onDockedChange,
embedded = false,
headerAction,
}: DocumentsSidebarProps) {
const t = useTranslations("documents");
const tSidebar = useTranslations("sidebar");
const isMobile = !useMediaQuery("(min-width: 640px)");
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
const anonMode = useAnonymousMode();
const { gate } = useLoginGate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [search, setSearch] = useState("");
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMentioned) {
setSidebarDocs((prev) => prev.filter((d) => d.id !== doc.id));
} else {
setSidebarDocs((prev) => {
if (prev.some((d) => d.id === doc.id)) return prev;
return [
...prev,
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
];
});
}
},
[setSidebarDocs]
);
const uploadedDoc = anonMode.isAnonymous ? anonMode.uploadedDoc : null;
const hasDoc = uploadedDoc !== null;
const handleAnonUploadClick = useCallback(() => {
if (hasDoc) {
gate("upload more documents");
return;
}
fileInputRef.current?.click();
}, [hasDoc, gate]);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const ext = `.${file.name.split(".").pop()?.toLowerCase()}`;
if (!ANON_ALLOWED_EXTENSIONS.has(ext)) {
gate("upload PDFs, Word documents, images, and more");
return;
}
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/upload`, {
method: "POST",
credentials: "include",
body: formData,
});
if (res.status === 409) {
gate("upload more documents");
return;
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Upload failed: ${res.status}`);
}
const data = await res.json();
if (anonMode.isAnonymous) {
anonMode.setUploadedDoc({
filename: data.filename,
sizeBytes: data.size_bytes,
});
}
toast.success(`Uploaded "${data.filename}"`);
} catch (err) {
console.error("Upload failed:", err);
toast.error(err instanceof Error ? err.message : "Upload failed");
} finally {
setIsUploading(false);
}
},
[gate, anonMode]
);
const handleRemoveDoc = useCallback(() => {
if (anonMode.isAnonymous) {
anonMode.setUploadedDoc(null);
}
}, [anonMode]);
const treeDocuments: DocumentNodeDoc[] = useMemo(() => {
if (!anonMode.isAnonymous || !anonMode.uploadedDoc) return [];
return [
{
id: -1,
title: anonMode.uploadedDoc.filename,
document_type: "FILE",
folderId: null,
status: { state: "ready" } as { state: string; reason?: string | null },
},
];
}, [anonMode]);
const searchFilteredDocs = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return treeDocuments;
return treeDocuments.filter((d) => d.title.toLowerCase().includes(q));
}, [treeDocuments, search]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
if (isMobile) {
onOpenChange(false);
} else {
setRightPanelCollapsed(true);
}
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
const documentsContent = (
<>
<input
ref={fileInputRef}
type="file"
accept={ANON_ACCEPT}
className="hidden"
onChange={handleFileChange}
disabled={isUploading}
/>
{/* Header */}
<div className="shrink-0 flex h-14 items-center px-4">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
</div>
<div className="flex items-center gap-1">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{tSidebar("close") || "Close"}</span>
</Button>
)}
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
if (isDocked) {
onDockedChange(false);
onOpenChange(false);
} else {
onDockedChange(true);
}
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
)}
{headerAction}
</div>
</div>
</div>
{/* Connectors strip (gated) */}
<div className="shrink-0 mx-4 mt-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
<button
type="button"
onClick={() => gate("connect your data sources")}
className="flex items-center gap-2 min-w-0 flex-1 text-left px-3 py-2"
>
<Unplug className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-muted-foreground">Connect your connectors</span>
<AvatarGroup className="ml-auto shrink-0">
{(isMobile ? SHOWCASE_CONNECTORS.slice(0, 5) : SHOWCASE_CONNECTORS).map(
({ type, label }, i) => {
const avatar = (
<Avatar
key={type}
className="size-6"
style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}
>
<AvatarFallback className="bg-muted text-[10px]">
{getConnectorIcon(type, "size-3.5")}
</AvatarFallback>
</Avatar>
);
if (isMobile) return avatar;
return (
<Tooltip key={type}>
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{label}
</TooltipContent>
</Tooltip>
);
}
)}
</AvatarGroup>
</button>
</div>
{/* Filters & upload */}
<div className="flex-1 min-h-0 pt-0 flex flex-col">
<div className="px-4 pb-2">
<DocumentsFilters
typeCounts={hasDoc ? { FILE: 1 } : {}}
onSearch={setSearch}
searchValue={search}
onToggleType={() => {}}
activeTypes={[]}
onCreateFolder={() => gate("create folders")}
aiSortEnabled={false}
onUploadClick={handleAnonUploadClick}
/>
</div>
<div className="relative flex-1 min-h-0 overflow-auto">
<FolderTreeView
folders={[]}
documents={searchFilteredDocs}
expandedIds={new Set()}
onToggleExpand={() => {}}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={() => {}}
onRenameFolder={() => gate("rename folders")}
onDeleteFolder={() => gate("delete folders")}
onMoveFolder={() => gate("organize folders")}
onCreateFolder={() => gate("create folders")}
searchQuery={search.trim() || undefined}
onPreviewDocument={() => gate("preview documents")}
onEditDocument={() => gate("edit documents")}
onDeleteDocument={async () => {
handleRemoveDoc();
setSidebarDocs((prev) => prev.filter((d) => d.id !== -1));
return true;
}}
onMoveDocument={() => gate("organize documents")}
onExportDocument={() => gate("export documents")}
onVersionHistory={() => gate("view version history")}
activeTypes={[]}
onDropIntoFolder={async () => gate("organize documents")}
onReorderFolder={async () => gate("organize folders")}
watchedFolderIds={new Set()}
onRescanFolder={() => gate("watch local folders")}
onStopWatchingFolder={() => gate("watch local folders")}
onExportFolder={() => gate("export folders")}
/>
{!hasDoc && (
<div className="px-4 py-8 text-center">
<button
type="button"
onClick={handleAnonUploadClick}
disabled={isUploading}
className="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-primary/30 px-4 py-6 text-sm text-primary transition-colors hover:border-primary/60 hover:bg-primary/5 cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
>
<Upload className="size-4" />
{isUploading ? "Uploading..." : "Upload a document"}
</button>
<p className="mt-2 text-[11px] text-muted-foreground leading-relaxed">
Text, code, CSV, and HTML files only. Create an account for PDFs, images, and 30+
connectors.
</p>
</div>
)}
</div>
</div>
{/* CTA footer */}
<div className="border-t p-4 space-y-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Lock className="size-3.5 shrink-0" />
<span>Create an account to unlock:</span>
</div>
<ul className="space-y-1.5 text-xs text-muted-foreground pl-5">
<li className="flex items-center gap-1.5">
<Paperclip className="size-3 shrink-0" /> PDF, Word, images, audio uploads
</li>
<li className="flex items-center gap-1.5">
<FileText className="size-3 shrink-0" /> Unlimited documents
</li>
</ul>
<Button size="sm" className="w-full" asChild>
<Link href="/register">Create Free Account</Link>
</Button>
</div>
</>
);
if (embedded) {
return (
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
{documentsContent}
</div>
);
}
if (isDocked && open && !isMobile) {
return (
<aside
className="h-full w-[380px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
aria-label={t("title") || "Documents"}
>
{documentsContent}
</aside>
);
}
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="max-h-[75vh] flex flex-col">
<DrawerTitle className="sr-only">{t("title") || "Documents"}</DrawerTitle>
<DrawerHandle />
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">{documentsContent}</div>
</DrawerContent>
</Drawer>
);
}
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("title") || "Documents"}
width={380}
>
{documentsContent}
</SidebarSlideOutPanel>
);
}

View file

@ -1,12 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { CreditCard, Zap } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
interface PageUsageDisplayProps {
pagesUsed: number;
@ -14,50 +8,17 @@ interface PageUsageDisplayProps {
}
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
const params = useParams();
const searchSpaceId = params?.search_space_id ?? "";
const usagePercentage = (pagesUsed / pagesLimit) * 100;
const { data: stripeStatus } = useQuery({
queryKey: ["stripe-status"],
queryFn: () => stripeApiService.getStatus(),
});
const pageBuyingEnabled = stripeStatus?.page_buying_enabled ?? true;
return (
<div className="px-3 py-3 border-t">
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5" />
<Link
href={`/dashboard/${searchSpaceId}/more-pages`}
className="group flex w-[calc(100%+0.75rem)] items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<Zap className="h-3 w-3 shrink-0" />
Get Free Pages
</span>
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
FREE
</Badge>
</Link>
{pageBuyingEnabled && (
<Link
href={`/dashboard/${searchSpaceId}/buy-pages`}
className="group flex w-[calc(100%+0.75rem)] items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<CreditCard className="h-3 w-3 shrink-0" />
Buy Pages
</span>
<span className="text-[10px] font-medium text-muted-foreground">$1/1k</span>
</Link>
)}
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5" />
</div>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Progress } from "@/components/ui/progress";
import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
export function PremiumTokenUsageDisplay() {
const isAnonymous = useIsAnonymous();
const { data: tokenStatus } = useQuery({
queryKey: ["token-status"],
queryFn: () => stripeApiService.getTokenStatus(),
staleTime: 60_000,
enabled: !isAnonymous,
});
if (!tokenStatus) return null;
const usagePercentage = Math.min(
(tokenStatus.premium_tokens_used / Math.max(tokenStatus.premium_tokens_limit, 1)) * 100,
100
);
const formatTokens = (n: number) => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return n.toLocaleString();
};
return (
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{formatTokens(tokenStatus.premium_tokens_used)} /{" "}
{formatTokens(tokenStatus.premium_tokens_limit)} tokens
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5 [&>div]:bg-purple-500" />
</div>
);
}

View file

@ -1,15 +1,21 @@
"use client";
import { PenSquare } from "lucide-react";
import { CreditCard, PenSquare, Zap } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { cn } from "@/lib/utils";
import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { ChatListItem } from "./ChatListItem";
import { NavSection } from "./NavSection";
import { PageUsageDisplay } from "./PageUsageDisplay";
import { PremiumTokenUsageDisplay } from "./PremiumTokenUsageDisplay";
import { SidebarButton } from "./SidebarButton";
import { SidebarCollapseButton } from "./SidebarCollapseButton";
import { SidebarHeader } from "./SidebarHeader";
@ -267,9 +273,7 @@ export function Sidebar({
<NavSection items={navItems} onItemClick={onNavItemClick} isCollapsed={isCollapsed} />
)}
{pageUsage && !isCollapsed && (
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
)}
<SidebarUsageFooter pageUsage={pageUsage} isCollapsed={isCollapsed} />
<SidebarUserProfile
user={user}
@ -283,3 +287,86 @@ export function Sidebar({
</div>
);
}
function SidebarUsageFooter({
pageUsage,
isCollapsed,
}: {
pageUsage?: PageUsage;
isCollapsed: boolean;
}) {
const params = useParams();
const searchSpaceId = params?.search_space_id ?? "";
const isAnonymous = useIsAnonymous();
if (isCollapsed) return null;
if (isAnonymous) {
return (
<div className="px-3 py-3 border-t space-y-3">
{pageUsage && (
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{pageUsage.pagesUsed.toLocaleString()} / {pageUsage.pagesLimit.toLocaleString()}{" "}
tokens
</span>
<span className="font-medium">
{Math.min(
(pageUsage.pagesUsed / Math.max(pageUsage.pagesLimit, 1)) * 100,
100
).toFixed(0)}
%
</span>
</div>
<Progress
value={Math.min((pageUsage.pagesUsed / Math.max(pageUsage.pagesLimit, 1)) * 100, 100)}
className="h-1.5"
/>
</div>
)}
<Link
href="/register"
className="flex items-center justify-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
Create Free Account
</Link>
</div>
);
}
return (
<div className="px-3 py-3 border-t space-y-3">
<PremiumTokenUsageDisplay />
{pageUsage && (
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
)}
<div className="space-y-0.5">
<Link
href={`/dashboard/${searchSpaceId}/more-pages`}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<Zap className="h-3 w-3 shrink-0" />
Get Free Pages
</span>
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
FREE
</Badge>
</Link>
<Link
href={`/dashboard/${searchSpaceId}/buy-more`}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<CreditCard className="h-3 w-3 shrink-0" />
Buy More
</span>
<span className="text-[10px] font-medium text-muted-foreground">
$1/1k &middot; $1/1M
</span>
</Link>
</div>
</div>
);
}

View file

@ -859,6 +859,14 @@ export function ModelSelector({
Recommended
</Badge>
)}
{"is_premium" in config && (config as Record<string, unknown>).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>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground truncate">

View file

@ -53,7 +53,7 @@ export function ZeroProvider({ children }: { children: React.ReactNode }) {
const context = useMemo(
() => (hasUser ? { userId: String(userId) } : undefined),
[hasUser, userId],
[hasUser, userId]
);
const opts = useMemo(
@ -65,7 +65,7 @@ export function ZeroProvider({ children }: { children: React.ReactNode }) {
cacheURL,
auth,
}),
[userID, context, auth],
[userID, context, auth]
);
return (

View file

@ -0,0 +1,156 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Minus, Plus } from "lucide-react";
import { useParams } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { AppError } from "@/lib/error";
import { cn } from "@/lib/utils";
const TOKEN_PACK_SIZE = 1_000_000;
const PRICE_PER_PACK_USD = 1;
const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50] as const;
export function BuyTokensContent() {
const params = useParams();
const searchSpaceId = Number(params?.search_space_id);
const [quantity, setQuantity] = useState(1);
const { data: tokenStatus } = useQuery({
queryKey: ["token-status"],
queryFn: () => stripeApiService.getTokenStatus(),
});
const purchaseMutation = useMutation({
mutationFn: stripeApiService.createTokenCheckoutSession,
onSuccess: (response) => {
window.location.assign(response.checkout_url);
},
onError: (error) => {
if (error instanceof AppError && error.message) {
toast.error(error.message);
return;
}
toast.error("Failed to start checkout. Please try again.");
},
});
const totalTokens = quantity * TOKEN_PACK_SIZE;
const totalPrice = quantity * PRICE_PER_PACK_USD;
if (tokenStatus && !tokenStatus.token_buying_enabled) {
return (
<div className="w-full space-y-3 text-center">
<h2 className="text-xl font-bold tracking-tight">Buy Premium Tokens</h2>
<p className="text-sm text-muted-foreground">
Token purchases are temporarily unavailable.
</p>
</div>
);
}
const usagePercentage = tokenStatus
? Math.min(
(tokenStatus.premium_tokens_used / Math.max(tokenStatus.premium_tokens_limit, 1)) * 100,
100
)
: 0;
return (
<div className="w-full space-y-5">
<div className="text-center">
<h2 className="text-xl font-bold tracking-tight">Buy Premium Tokens</h2>
<p className="mt-1 text-sm text-muted-foreground">$1 per 1M tokens, pay as you go</p>
</div>
{tokenStatus && (
<div className="rounded-lg border bg-muted/20 p-3 space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{tokenStatus.premium_tokens_used.toLocaleString()} /{" "}
{tokenStatus.premium_tokens_limit.toLocaleString()} premium tokens
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5" />
<p className="text-[11px] text-muted-foreground">
{tokenStatus.premium_tokens_remaining.toLocaleString()} tokens remaining
</p>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
disabled={quantity <= 1 || purchaseMutation.isPending}
className="flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted disabled:opacity-40"
>
<Minus className="h-3.5 w-3.5" />
</button>
<span className="min-w-32 text-center text-lg font-semibold tabular-nums">
{(totalTokens / 1_000_000).toFixed(0)}M tokens
</span>
<button
type="button"
onClick={() => setQuantity((q) => Math.min(100, q + 1))}
disabled={quantity >= 100 || purchaseMutation.isPending}
className="flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted disabled:opacity-40"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex flex-wrap justify-center gap-1.5">
{PRESET_MULTIPLIERS.map((m) => (
<button
key={m}
type="button"
onClick={() => setQuantity(m)}
disabled={purchaseMutation.isPending}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium tabular-nums transition-colors disabled:opacity-60",
quantity === m
? "border-purple-500 bg-purple-500/10 text-purple-600 dark:text-purple-400"
: "border-border hover:border-purple-500/40 hover:bg-muted/40"
)}
>
{m}M
</button>
))}
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2">
<span className="text-sm font-medium tabular-nums">
{(totalTokens / 1_000_000).toFixed(0)}M premium tokens
</span>
<span className="text-sm font-semibold tabular-nums">${totalPrice}</span>
</div>
<Button
className="w-full bg-purple-600 text-white hover:bg-purple-700"
disabled={purchaseMutation.isPending}
onClick={() => purchaseMutation.mutate({ quantity, search_space_id: searchSpaceId })}
>
{purchaseMutation.isPending ? (
<>
<Spinner size="xs" />
Redirecting
</>
) : (
<>
Buy {(totalTokens / 1_000_000).toFixed(0)}M Tokens for ${totalPrice}
</>
)}
</Button>
<p className="text-center text-[11px] text-muted-foreground">Secure checkout via Stripe</p>
</div>
</div>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
export interface AnonymousModeContextValue {
isAnonymous: true;
modelSlug: string;
setModelSlug: (slug: string) => void;
uploadedDoc: { filename: string; sizeBytes: number } | null;
setUploadedDoc: (doc: { filename: string; sizeBytes: number } | null) => void;
resetKey: number;
resetChat: () => void;
}
interface AuthenticatedContextValue {
isAnonymous: false;
}
type ContextValue = AnonymousModeContextValue | AuthenticatedContextValue;
const DEFAULT_VALUE: AuthenticatedContextValue = { isAnonymous: false };
const AnonymousModeContext = createContext<ContextValue>(DEFAULT_VALUE);
export function AnonymousModeProvider({
initialModelSlug,
children,
}: {
initialModelSlug: string;
children: ReactNode;
}) {
const [modelSlug, setModelSlug] = useState(initialModelSlug);
const [uploadedDoc, setUploadedDoc] = useState<{ filename: string; sizeBytes: number } | null>(
null
);
const [resetKey, setResetKey] = useState(0);
const resetChat = () => setResetKey((k) => k + 1);
useEffect(() => {
anonymousChatApiService
.getDocument()
.then((doc) => {
if (doc) {
setUploadedDoc({ filename: doc.filename, sizeBytes: doc.size_bytes });
}
})
.catch(() => {});
}, []);
const value = useMemo<AnonymousModeContextValue>(
() => ({
isAnonymous: true,
modelSlug,
setModelSlug,
uploadedDoc,
setUploadedDoc,
resetKey,
resetChat,
}),
[modelSlug, uploadedDoc, resetKey]
);
return <AnonymousModeContext.Provider value={value}>{children}</AnonymousModeContext.Provider>;
}
export function useAnonymousMode(): ContextValue {
return useContext(AnonymousModeContext);
}
export function useIsAnonymous(): boolean {
return useContext(AnonymousModeContext).isAnonymous;
}

View file

@ -0,0 +1,84 @@
"use client";
import Link from "next/link";
import { createContext, type ReactNode, useCallback, useContext, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useIsAnonymous } from "./anonymous-mode";
interface LoginGateContextValue {
gate: (feature: string) => void;
}
const LoginGateContext = createContext<LoginGateContextValue>({
gate: () => {},
});
export function LoginGateProvider({ children }: { children: ReactNode }) {
const isAnonymous = useIsAnonymous();
const [feature, setFeature] = useState<string | null>(null);
const gate = useCallback(
(feat: string) => {
if (isAnonymous) {
setFeature(feat);
}
},
[isAnonymous]
);
const close = () => setFeature(null);
return (
<LoginGateContext.Provider value={{ gate }}>
{children}
<Dialog open={feature !== null} onOpenChange={(open) => !open && close()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create a free account to {feature}</DialogTitle>
<DialogDescription>
Get 5 million tokens, save chat history, upload documents, use all AI tools, and
connect 30+ integrations.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex flex-col gap-2 sm:flex-row">
<Button asChild>
<Link href="/register">Create Free Account</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/login">Log In</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</LoginGateContext.Provider>
);
}
export function useLoginGate(): LoginGateContextValue {
return useContext(LoginGateContext);
}
/**
* Returns a click handler that triggers the login gate when anonymous,
* or calls the original handler when authenticated.
*/
export function useGatedHandler(handler: (() => void) | undefined, feature: string): () => void {
const { gate } = useLoginGate();
const isAnonymous = useIsAnonymous();
return useCallback(() => {
if (isAnonymous) {
gate(feature);
} else {
handler?.();
}
}, [isAnonymous, gate, feature, handler]);
}

View file

@ -0,0 +1,49 @@
import { z } from "zod";
export const anonModel = z.object({
id: z.number(),
name: z.string(),
description: z.string().nullable().optional(),
provider: z.string(),
model_name: z.string(),
billing_tier: z.string().default("free"),
is_premium: z.boolean().default(false),
seo_slug: z.string().nullable().optional(),
seo_enabled: z.boolean().default(false),
seo_title: z.string().nullable().optional(),
seo_description: z.string().nullable().optional(),
quota_reserve_tokens: z.number().nullable().optional(),
});
export const getAnonModelsResponse = z.array(anonModel);
export const getAnonModelResponse = anonModel;
export const anonQuotaResponse = z.object({
used: z.number(),
limit: z.number(),
remaining: z.number(),
status: z.string(),
warning_threshold: z.number(),
captcha_required: z.boolean().default(false),
});
export const anonChatRequest = z.object({
model_slug: z.string().max(100),
messages: z
.array(
z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
})
)
.min(1),
disabled_tools: z.array(z.string()).optional(),
turnstile_token: z.string().optional(),
});
export type AnonModel = z.infer<typeof anonModel>;
export type GetAnonModelsResponse = z.infer<typeof getAnonModelsResponse>;
export type GetAnonModelResponse = z.infer<typeof getAnonModelResponse>;
export type AnonQuotaResponse = z.infer<typeof anonQuotaResponse>;
export type AnonChatRequest = z.infer<typeof anonChatRequest>;

View file

@ -162,6 +162,16 @@ export const globalNewLLMConfig = z.object({
is_global: z.literal(true),
is_auto_mode: z.boolean().optional().default(false), // True only for Auto mode (ID 0)
// Token quota and billing policy
billing_tier: z.string().default("free"),
is_premium: z.boolean().default(false),
anonymous_enabled: z.boolean().default(false),
seo_enabled: z.boolean().default(false),
seo_slug: z.string().nullable().optional(),
seo_title: z.string().nullable().optional(),
seo_description: z.string().nullable().optional(),
quota_reserve_tokens: z.number().nullable().optional(),
});
export const getGlobalNewLLMConfigsResponse = z.array(globalNewLLMConfig);

View file

@ -32,9 +32,48 @@ export const getPagePurchasesResponse = z.object({
purchases: z.array(pagePurchase),
});
// Premium token purchases
export const createTokenCheckoutSessionRequest = z.object({
quantity: z.number().int().min(1).max(100),
search_space_id: z.number().int().min(1),
});
export const createTokenCheckoutSessionResponse = z.object({
checkout_url: z.string(),
});
export const tokenStripeStatusResponse = z.object({
token_buying_enabled: z.boolean(),
premium_tokens_used: z.number().default(0),
premium_tokens_limit: z.number().default(0),
premium_tokens_remaining: z.number().default(0),
});
export const tokenPurchase = z.object({
id: z.uuid(),
stripe_checkout_session_id: z.string(),
stripe_payment_intent_id: z.string().nullable(),
quantity: z.number(),
tokens_granted: z.number(),
amount_total: z.number().nullable(),
currency: z.string().nullable(),
status: z.string(),
completed_at: z.string().nullable(),
created_at: z.string(),
});
export const getTokenPurchasesResponse = z.object({
purchases: z.array(tokenPurchase),
});
export type PagePurchaseStatus = z.infer<typeof pagePurchaseStatusEnum>;
export type CreateCheckoutSessionRequest = z.infer<typeof createCheckoutSessionRequest>;
export type CreateCheckoutSessionResponse = z.infer<typeof createCheckoutSessionResponse>;
export type StripeStatusResponse = z.infer<typeof stripeStatusResponse>;
export type PagePurchase = z.infer<typeof pagePurchase>;
export type GetPagePurchasesResponse = z.infer<typeof getPagePurchasesResponse>;
export type CreateTokenCheckoutSessionRequest = z.infer<typeof createTokenCheckoutSessionRequest>;
export type CreateTokenCheckoutSessionResponse = z.infer<typeof createTokenCheckoutSessionResponse>;
export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse>;
export type TokenPurchase = z.infer<typeof tokenPurchase>;
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;

View file

@ -0,0 +1,97 @@
import {
type AnonChatRequest,
type AnonModel,
type AnonQuotaResponse,
anonChatRequest,
anonQuotaResponse,
getAnonModelResponse,
getAnonModelsResponse,
} from "@/contracts/types/anonymous-chat.types";
import { BACKEND_URL } from "../env-config";
import { ValidationError } from "../error";
const BASE = "/api/v1/public/anon-chat";
class AnonymousChatApiService {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private fullUrl(path: string): string {
return `${this.baseUrl}${BASE}${path}`;
}
getModels = async (): Promise<AnonModel[]> => {
const res = await fetch(this.fullUrl("/models"), { credentials: "include" });
if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`);
const data = await res.json();
const parsed = getAnonModelsResponse.safeParse(data);
if (!parsed.success) console.error("Invalid anon models response:", parsed.error);
return data;
};
getModel = async (slug: string): Promise<AnonModel> => {
const res = await fetch(this.fullUrl(`/models/${encodeURIComponent(slug)}`), {
credentials: "include",
});
if (!res.ok) {
if (res.status === 404) throw new Error("Model not found");
throw new Error(`Failed to fetch model: ${res.status}`);
}
const data = await res.json();
const parsed = getAnonModelResponse.safeParse(data);
if (!parsed.success) console.error("Invalid anon model response:", parsed.error);
return data;
};
getQuota = async (): Promise<AnonQuotaResponse> => {
const res = await fetch(this.fullUrl("/quota"), { credentials: "include" });
if (!res.ok) throw new Error(`Failed to fetch quota: ${res.status}`);
const data = await res.json();
const parsed = anonQuotaResponse.safeParse(data);
if (!parsed.success) console.error("Invalid anon quota response:", parsed.error);
return data;
};
streamChat = async (request: AnonChatRequest): Promise<Response> => {
const validated = anonChatRequest.safeParse(request);
if (!validated.success) {
throw new ValidationError(
`Invalid request: ${validated.error.issues.map((i) => i.message).join(", ")}`
);
}
return fetch(this.fullUrl("/stream"), {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(validated.data),
});
};
uploadDocument = async (file: File): Promise<{ filename: string; size_bytes: number }> => {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(this.fullUrl("/upload"), {
method: "POST",
credentials: "include",
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Upload failed: ${res.status}`);
}
return res.json();
};
getDocument = async (): Promise<{ filename: string; size_bytes: number } | null> => {
const res = await fetch(this.fullUrl("/document"), { credentials: "include" });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to fetch document: ${res.status}`);
return res.json();
};
}
export const anonymousChatApiService = new AnonymousChatApiService(BACKEND_URL);

View file

@ -1,11 +1,18 @@
import {
type CreateCheckoutSessionRequest,
type CreateCheckoutSessionResponse,
type CreateTokenCheckoutSessionRequest,
type CreateTokenCheckoutSessionResponse,
createCheckoutSessionResponse,
createTokenCheckoutSessionResponse,
type GetPagePurchasesResponse,
type GetTokenPurchasesResponse,
getPagePurchasesResponse,
getTokenPurchasesResponse,
type StripeStatusResponse,
stripeStatusResponse,
type TokenStripeStatusResponse,
tokenStripeStatusResponse,
} from "@/contracts/types/stripe.types";
import { baseApiService } from "./base-api.service";
@ -29,6 +36,24 @@ class StripeApiService {
getStatus = async (): Promise<StripeStatusResponse> => {
return baseApiService.get("/api/v1/stripe/status", stripeStatusResponse);
};
createTokenCheckoutSession = async (
request: CreateTokenCheckoutSessionRequest
): Promise<CreateTokenCheckoutSessionResponse> => {
return baseApiService.post(
"/api/v1/stripe/create-token-checkout-session",
createTokenCheckoutSessionResponse,
{ body: request }
);
};
getTokenStatus = async (): Promise<TokenStripeStatusResponse> => {
return baseApiService.get("/api/v1/stripe/token-status", tokenStripeStatusResponse);
};
getTokenPurchases = async (): Promise<GetTokenPurchasesResponse> => {
return baseApiService.get("/api/v1/stripe/token-purchases", getTokenPurchasesResponse);
};
}
export const stripeApiService = new StripeApiService();

View file

@ -18,6 +18,7 @@ const PUBLIC_ROUTE_PREFIXES = [
"/desktop/login",
"/docs",
"/public",
"/free",
"/invite",
"/contact",
"/pricing",

View file

@ -27,6 +27,7 @@
"@assistant-ui/react-markdown": "^0.12.6",
"@babel/standalone": "^7.29.2",
"@hookform/resolvers": "^5.2.2",
"@marsidev/react-turnstile": "^1.5.0",
"@number-flow/react": "^0.5.10",
"@platejs/autoformat": "^52.0.11",
"@platejs/basic-nodes": "^52.0.11",

View file

@ -26,6 +26,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.71.2(react@19.2.4))
'@marsidev/react-turnstile':
specifier: ^1.5.0
version: 1.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@number-flow/react':
specifier: ^0.5.10
version: 0.5.14(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -1954,6 +1957,12 @@ packages:
'@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
'@marsidev/react-turnstile@1.5.0':
resolution: {integrity: sha512-Ph6mcj8u9WBDsBO7s9jKPsyRDz1sBPBJwrk+Ngx09vFInvKsQ6U6kW5amEcGq4dHOreB6DgFrOJk7/fy318YlQ==}
peerDependencies:
react: ^17.0.2 || ^18.0.0 || ^19.0
react-dom: ^17.0.2 || ^18.0.0 || ^19.0
'@mdx-js/mdx@3.1.1':
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
@ -9936,6 +9945,11 @@ snapshots:
'@juggle/resize-observer@3.4.0': {}
'@marsidev/react-turnstile@1.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@mdx-js/mdx@3.1.1':
dependencies:
'@types/estree': 1.0.8