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

@ -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>
);
}