mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 17:26:23 +02:00
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
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:
parent
87452bb315
commit
ff4e0f9b62
68 changed files with 5914 additions and 121 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
271
surfsense_web/components/free-chat/anonymous-chat.tsx
Normal file
271
surfsense_web/components/free-chat/anonymous-chat.tsx
Normal 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 · No login required · 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 · Responses may be inaccurate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
395
surfsense_web/components/free-chat/free-chat-page.tsx
Normal file
395
surfsense_web/components/free-chat/free-chat-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
surfsense_web/components/free-chat/free-composer.tsx
Normal file
262
surfsense_web/components/free-chat/free-composer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
195
surfsense_web/components/free-chat/free-model-selector.tsx
Normal file
195
surfsense_web/components/free-chat/free-model-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
surfsense_web/components/free-chat/free-right-panel.tsx
Normal file
45
surfsense_web/components/free-chat/free-right-panel.tsx
Normal 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."
|
||||
/>
|
||||
);
|
||||
82
surfsense_web/components/free-chat/free-thread.tsx
Normal file
82
surfsense_web/components/free-chat/free-thread.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
57
surfsense_web/components/free-chat/quota-bar.tsx
Normal file
57
surfsense_web/components/free-chat/quota-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
surfsense_web/components/free-chat/quota-warning-banner.tsx
Normal file
84
surfsense_web/components/free-chat/quota-warning-banner.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 · $1/1M
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
156
surfsense_web/components/settings/buy-tokens-content.tsx
Normal file
156
surfsense_web/components/settings/buy-tokens-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue