diff --git a/surfsense_backend/app/agents/new_chat/anonymous_agent.py b/surfsense_backend/app/agents/new_chat/anonymous_agent.py new file mode 100644 index 000000000..c783d9a45 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/anonymous_agent.py @@ -0,0 +1,168 @@ +"""Minimal anonymous / free-chat agent. + +The no-login chat experience must stay dead simple: the user asks a question +and the model answers, optionally using ``web_search`` and an optionally +uploaded **read-only** document. We deliberately bypass the full SurfSense deep +agent stack (filesystem, file-intent, knowledge-base persistence, subagents, +skills, memory) because those middlewares stage or persist "documents" that an +anonymous session can never see again -- which produced phantom +"I saved it to a file" answers for free users. + +For any other SurfSense capability the model is instructed (via the system +prompt built here) to tell the user to create a free account instead of +pretending to perform the action. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from deepagents.backends import StateBackend +from langchain.agents import create_agent +from langchain.agents.middleware import ( + ModelCallLimitMiddleware, + ToolCallLimitMiddleware, +) +from langchain_core.language_models import BaseChatModel +from langgraph.types import Checkpointer + +from app.agents.new_chat.context import SurfSenseContextSchema +from app.agents.new_chat.middleware import ( + RetryAfterMiddleware, + create_surfsense_compaction_middleware, +) +from app.agents.new_chat.tools.web_search import create_web_search_tool + +# Cap how much of an uploaded document we inline into the system prompt. The +# upload endpoint allows files up to several MB, but the doc is re-sent on +# every turn and counts against the anonymous token quota, so we bound it. +_MAX_DOC_CHARS = 50_000 + + +def build_anonymous_system_prompt(anon_doc: dict[str, Any] | None = None) -> str: + """Build the system prompt for the minimal anonymous chat agent. + + The prompt keeps the assistant focused on plain Q/A + web search, inlines + any uploaded document as read-only context, and redirects every other + SurfSense feature to account registration. + """ + today = datetime.now(UTC).strftime("%A, %B %d, %Y") + + doc_section = "" + if anon_doc: + title = str(anon_doc.get("title") or "uploaded_document") + content = str(anon_doc.get("content") or "") + truncated = content[:_MAX_DOC_CHARS] + truncation_note = "" + if len(content) > _MAX_DOC_CHARS: + truncation_note = ( + "\n\n[Note: the document was truncated because it is large; " + "only the beginning is shown.]" + ) + doc_section = ( + "\n\n## Uploaded document (read-only)\n" + f'The user uploaded a document named "{title}". Its contents are ' + "provided below for reference only. You may read it and answer " + "questions about it, but you cannot modify, save, or store it.\n\n" + f'\n' + f"{truncated}{truncation_note}\n" + "" + ) + + return ( + "You are SurfSense's free AI assistant, available to everyone without " + "login.\n\n" + f"Today's date is {today}.\n\n" + "## How to help\n" + "- Answer the user's questions directly and conversationally. You are " + "a straightforward question-and-answer assistant.\n" + "- When a question needs current, real-time, or factual information " + "from the internet (news, prices, weather, recent events, live data), " + "use the `web_search` tool. Otherwise, answer directly from your own " + "knowledge.\n" + "- Be concise, accurate, and helpful. Use Markdown formatting when it " + "improves readability." + f"{doc_section}\n\n" + "## What is not available here\n" + "This is the free, no-login experience. You CANNOT save files or " + "notes, generate reports, podcasts, resumes, presentations, or images, " + "search or build a knowledge base, connect to apps (Gmail, Google " + "Drive, Notion, Slack, Calendar, Discord, and similar), set up " + "automations, or remember anything across sessions.\n\n" + "If the user asks for any of these, do NOT pretend to do them and " + "never claim you saved, created, or stored anything. Instead, briefly " + "let them know the feature requires a free SurfSense account and " + "invite them to create one at https://www.surfsense.com. Then offer to " + "help with what you can do here (answering questions and searching the " + "web)." + ) + + +async def create_anonymous_chat_agent( + *, + llm: BaseChatModel, + checkpointer: Checkpointer, + anon_session_id: str | None = None, + anon_doc: dict[str, Any] | None = None, + enable_web_search: bool = True, +): + """Create a minimal Q/A agent for anonymous / free chat. + + Unlike :func:`create_surfsense_deep_agent`, this agent has no filesystem, + file-intent, knowledge-base persistence, subagent, skills, or memory + middleware. Its only tool is ``web_search`` (when ``enable_web_search`` is + True), and any uploaded document is injected into the system prompt as + read-only context. + + Args: + llm: The chat model to use (already built by the caller). + checkpointer: LangGraph checkpointer for the ephemeral anon thread. + anon_session_id: Anonymous session id (used only for telemetry/metadata). + anon_doc: Optional ``{"title", "content"}`` for an uploaded document. + enable_web_search: When False, the agent runs as a pure LLM with no + tools (used when the user toggles web search off). + """ + tools = ( + [create_web_search_tool(search_space_id=None, available_connectors=None)] + if enable_web_search + else [] + ) + + # Reliability-only middleware. Nothing here touches the database or + # filesystem: call limits guard against loops, compaction summarises long + # histories into in-graph state, and retry handles provider rate limits. + middleware: list[Any] = [ + ModelCallLimitMiddleware(thread_limit=120, run_limit=80, exit_behavior="end"), + ] + if tools: + middleware.append( + ToolCallLimitMiddleware( + thread_limit=300, run_limit=80, exit_behavior="continue" + ) + ) + middleware.append(create_surfsense_compaction_middleware(llm, StateBackend)) + middleware.append(RetryAfterMiddleware(max_retries=3)) + + system_prompt = build_anonymous_system_prompt(anon_doc) + + agent = create_agent( + llm, + system_prompt=system_prompt, + tools=tools, + middleware=middleware, + context_schema=SurfSenseContextSchema, + checkpointer=checkpointer, + ) + return agent.with_config( + { + "recursion_limit": 40, + "metadata": { + "ls_integration": "surfsense_anonymous_chat", + "anon_session_id": anon_session_id, + }, + } + ) + + +__all__ = ["build_anonymous_system_prompt", "create_anonymous_chat_agent"] diff --git a/surfsense_backend/app/automations/triggers/builtin/event/filter.py b/surfsense_backend/app/automations/triggers/builtin/event/filter.py index 9f13cd51e..742281fc6 100644 --- a/surfsense_backend/app/automations/triggers/builtin/event/filter.py +++ b/surfsense_backend/app/automations/triggers/builtin/event/filter.py @@ -65,8 +65,7 @@ def _match_condition(condition: Any, actual: Any) -> bool: return False if isinstance(condition, dict): return all( - _apply_operator(op, operand, actual) - for op, operand in condition.items() + _apply_operator(op, operand, actual) for op, operand in condition.items() ) return actual == condition diff --git a/surfsense_backend/app/automations/triggers/builtin/event/selector.py b/surfsense_backend/app/automations/triggers/builtin/event/selector.py index 9c000e716..ee00a6094 100644 --- a/surfsense_backend/app/automations/triggers/builtin/event/selector.py +++ b/surfsense_backend/app/automations/triggers/builtin/event/selector.py @@ -41,9 +41,7 @@ async def _select_and_start(event_dict: dict[str, Any]) -> None: await _start_one(session, trigger=trigger, event=event) -async def _eligible( - session: AsyncSession, *, event: Event -) -> list[AutomationTrigger]: +async def _eligible(session: AsyncSession, *, event: Event) -> list[AutomationTrigger]: """Enabled ``event`` triggers for this event type whose filter matches.""" stmt = select(AutomationTrigger).where( AutomationTrigger.type == TriggerType.EVENT, diff --git a/surfsense_backend/app/routes/anonymous_chat_routes.py b/surfsense_backend/app/routes/anonymous_chat_routes.py index f9d694e5a..eb952e684 100644 --- a/surfsense_backend/app/routes/anonymous_chat_routes.py +++ b/surfsense_backend/app/routes/anonymous_chat_routes.py @@ -351,10 +351,9 @@ async def stream_anonymous_chat( async def _generate(): from langchain_core.messages import AIMessage, HumanMessage - from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent + from app.agents.new_chat.anonymous_agent import create_anonymous_chat_agent from app.agents.new_chat.checkpointer import get_checkpointer from app.db import shielded_async_session - from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService from app.services.token_tracking_service import start_turn from app.tasks.chat.stream_new_chat import StreamResult, _stream_agent_events @@ -363,24 +362,23 @@ async def stream_anonymous_chat( streaming_service = VercelStreamingService() try: - async with shielded_async_session() as session: - connector_service = ConnectorService(session, search_space_id=None) + async with shielded_async_session(): checkpointer = await get_checkpointer() anon_thread_id = f"anon-{session_id}-{request_id}" - agent = await create_surfsense_deep_agent( + # Load the optional uploaded document as read-only context. + anon_doc = await _load_anon_document(session_id) + + # Minimal Q/A agent: web_search only (when enabled), no + # filesystem / persistence / subagents. The uploaded document + # is injected into the system prompt as read-only context. + agent = await create_anonymous_chat_agent( llm=llm, - search_space_id=0, - db_session=session, - connector_service=connector_service, checkpointer=checkpointer, - user_id=None, - thread_id=None, - agent_config=agent_config, - enabled_tools=list(enabled_for_agent), - disabled_tools=None, anon_session_id=session_id, + anon_doc=anon_doc, + enable_web_search="web_search" in enabled_for_agent, ) langchain_messages = [] @@ -396,7 +394,6 @@ async def stream_anonymous_chat( input_state = { "messages": langchain_messages, - "search_space_id": 0, } langgraph_config = { @@ -500,6 +497,38 @@ ANON_ALLOWED_EXTENSIONS = PLAINTEXT_EXTENSIONS | DIRECT_CONVERT_EXTENSIONS ANON_DOC_REDIS_PREFIX = "anon:doc:" +async def _load_anon_document(session_id: str) -> dict[str, Any] | None: + """Read the anonymous session's uploaded document from Redis. + + Returns ``{"title", "content"}`` for read-only injection into the agent's + system prompt, or ``None`` when nothing was uploaded for this session. + """ + import json as _json + + import redis.asyncio as aioredis + + redis_client = aioredis.from_url(config.REDIS_APP_URL, decode_responses=True) + redis_key = f"{ANON_DOC_REDIS_PREFIX}{session_id}" + try: + data = await redis_client.get(redis_key) + if not data: + return None + payload = _json.loads(data) + except Exception as exc: # pragma: no cover - defensive + logger.warning("Failed to load anonymous document from Redis: %s", exc) + return None + finally: + await redis_client.aclose() + + content = str(payload.get("content") or "") + if not content: + return None + return { + "title": str(payload.get("filename") or "uploaded_document"), + "content": content, + } + + class AnonDocResponse(BaseModel): filename: str size_bytes: int diff --git a/surfsense_backend/app/session_events.py b/surfsense_backend/app/session_events.py index b2ec57fcc..048df2b46 100644 --- a/surfsense_backend/app/session_events.py +++ b/surfsense_backend/app/session_events.py @@ -79,9 +79,11 @@ def _after_commit(session: Session) -> None: ] for task in tasks: task.add_done_callback( - lambda t: logger.error("event publish failed: %s", t.exception()) - if not t.cancelled() and t.exception() - else None + lambda t: ( + logger.error("event publish failed: %s", t.exception()) + if not t.cancelled() and t.exception() + else None + ) ) diff --git a/surfsense_backend/tests/unit/event_bus/test_catalog.py b/surfsense_backend/tests/unit/event_bus/test_catalog.py index cbd377b02..b09482bea 100644 --- a/surfsense_backend/tests/unit/event_bus/test_catalog.py +++ b/surfsense_backend/tests/unit/event_bus/test_catalog.py @@ -24,6 +24,7 @@ def _event_type(type_: str = "test.thing") -> EventType: def test_register_then_get_returns_the_event_type(isolated_event_catalog: None) -> None: from app.event_bus.catalog import catalog + catalog.register(_event_type()) assert catalog.get("test.thing") is not None @@ -32,12 +33,14 @@ def test_register_then_get_returns_the_event_type(isolated_event_catalog: None) def test_get_unknown_type_returns_none(isolated_event_catalog: None) -> None: from app.event_bus.catalog import catalog + assert catalog.get("does.not.exist") is None def test_register_duplicate_type_raises(isolated_event_catalog: None) -> None: """A type is a contract; registering it twice is a bug, not an override.""" from app.event_bus.catalog import catalog + catalog.register(_event_type()) with pytest.raises(ValueError, match="already registered"): @@ -47,6 +50,7 @@ def test_register_duplicate_type_raises(isolated_event_catalog: None) -> None: def test_all_is_a_defensive_snapshot(isolated_event_catalog: None) -> None: """Mutating the returned dict must not corrupt the registry.""" from app.event_bus.catalog import catalog + catalog.register(_event_type()) snapshot = catalog.all() diff --git a/surfsense_web/components/assistant-ui/inline-citation.tsx b/surfsense_web/components/assistant-ui/inline-citation.tsx index cbf3c82d6..6a8f2e035 100644 --- a/surfsense_web/components/assistant-ui/inline-citation.tsx +++ b/surfsense_web/components/assistant-ui/inline-citation.tsx @@ -51,7 +51,9 @@ export const InlineCitation: FC = ({ chunkId, isDocsChunk = doc - {isDocsChunk ? "Documentation reference" : "Uploaded document"} + + {isDocsChunk ? "Documentation reference" : "Uploaded document"} + ); } diff --git a/surfsense_web/components/free-chat/free-chat-page.tsx b/surfsense_web/components/free-chat/free-chat-page.tsx index 927eaef87..2ee026cc3 100644 --- a/surfsense_web/components/free-chat/free-chat-page.tsx +++ b/surfsense_web/components/free-chat/free-chat-page.tsx @@ -15,6 +15,7 @@ import { type TokenUsageData, TokenUsageProvider, } from "@/components/assistant-ui/token-usage-context"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { useAnonymousMode } from "@/contexts/anonymous-mode"; import { TimelineDataUI } from "@/features/chat-messages/timeline"; import { @@ -101,11 +102,16 @@ export function FreeChatPage() { const anonMode = useAnonymousMode(); const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : ""; const resetKey = anonMode.isAnonymous ? anonMode.resetKey : 0; + const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true; const [messages, setMessages] = useState([]); const [isRunning, setIsRunning] = useState(false); const [tokenUsageStore] = useState(() => createTokenUsageStore()); const abortControllerRef = useRef(null); + // Mirror the latest messages into a ref so onNew stays a stable callback + // (it reads history on demand instead of depending on the array). + const messagesRef = useRef([]); + messagesRef.current = messages; // Turnstile CAPTCHA state const [captchaRequired, setCaptchaRequired] = useState(false); @@ -152,6 +158,7 @@ export function FreeChatPage() { model_slug: modelSlug, messages: messageHistory, }; + if (!webSearchEnabled) reqBody.disabled_tools = ["web_search"]; if (turnstileToken) reqBody.turnstile_token = turnstileToken; const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, { @@ -301,7 +308,7 @@ export function FreeChatPage() { throw err; } }, - [modelSlug, tokenUsageStore] + [modelSlug, tokenUsageStore, webSearchEnabled] ); const onNew = useCallback( @@ -345,7 +352,7 @@ export function FreeChatPage() { }, ]); - const messageHistory = messages + const messageHistory = messagesRef.current .filter((m) => m.role === "user" || m.role === "assistant") .map((m) => { let text = ""; @@ -395,7 +402,7 @@ export function FreeChatPage() { abortControllerRef.current = null; } }, - [messages, doStream] + [modelSlug, anonMode, doStream] ); /** Called when Turnstile resolves successfully. Stores the token and auto-retries. */ @@ -481,19 +488,21 @@ export function FreeChatPage() { {captchaRequired && TURNSTILE_SITE_KEY && ( -
-
- - Quick verification to continue chatting -
- turnstileRef.current?.reset()} - onExpire={() => turnstileRef.current?.reset()} - options={{ theme: "auto", size: "normal" }} - /> +
+ + + Quick verification to continue chatting + + turnstileRef.current?.reset()} + onExpire={() => turnstileRef.current?.reset()} + options={{ theme: "auto", size: "normal" }} + /> + +
)} diff --git a/surfsense_web/components/free-chat/free-composer.tsx b/surfsense_web/components/free-chat/free-composer.tsx index 943ace9aa..46d9e0259 100644 --- a/surfsense_web/components/free-chat/free-composer.tsx +++ b/surfsense_web/components/free-chat/free-composer.tsx @@ -6,6 +6,7 @@ import { type FC, useCallback, useRef, useState } from "react"; import { toast } from "sonner"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useAnonymousMode } from "@/contexts/anonymous-mode"; @@ -71,10 +72,11 @@ export const FreeComposer: FC = () => { const { gate } = useLoginGate(); const anonMode = useAnonymousMode(); const [text, setText] = useState(""); - const [webSearchEnabled, setWebSearchEnabled] = useState(true); const fileInputRef = useRef(null); const hasUploadedDoc = anonMode.isAnonymous && anonMode.uploadedDoc !== null; + const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true; + const setWebSearchEnabled = anonMode.isAnonymous ? anonMode.setWebSearchEnabled : () => {}; const handleTextChange = useCallback( (e: React.ChangeEvent) => { @@ -189,14 +191,11 @@ export const FreeComposer: FC = () => { @@ -207,13 +206,13 @@ export const FreeComposer: FC = () => { -
+ diff --git a/surfsense_web/components/free-chat/free-model-selector.tsx b/surfsense_web/components/free-chat/free-model-selector.tsx index b7ae60bd3..9bf4ecee5 100644 --- a/surfsense_web/components/free-chat/free-model-selector.tsx +++ b/surfsense_web/components/free-chat/free-model-selector.tsx @@ -1,10 +1,18 @@ "use client"; -import { Bot, Check, ChevronDown, Search } from "lucide-react"; +import { Bot, Check, ChevronDown } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useAnonymousMode } from "@/contexts/anonymous-mode"; import type { AnonModel } from "@/contracts/types/anonymous-chat.types"; @@ -19,21 +27,18 @@ export function FreeModelSelector({ className }: { className?: string }) { const [open, setOpen] = useState(false); const [models, setModels] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const [focusedIndex, setFocusedIndex] = useState(-1); - const searchInputRef = useRef(null); useEffect(() => { - anonymousChatApiService.getModels().then(setModels).catch(console.error); - }, []); - - const handleOpenChange = useCallback((next: boolean) => { - if (next) { - setSearchQuery(""); - setFocusedIndex(-1); - requestAnimationFrame(() => searchInputRef.current?.focus()); - } - setOpen(next); + const controller = new AbortController(); + anonymousChatApiService + .getModels() + .then((data) => { + if (!controller.signal.aborted) setModels(data); + }) + .catch((err) => { + if (!controller.signal.aborted) console.error(err); + }); + return () => controller.abort(); }, []); const currentModel = useMemo( @@ -41,22 +46,12 @@ export function FreeModelSelector({ className }: { className?: string }) { [models, currentSlug] ); + // Free models first, premium last; immutable sort to avoid mutating state. const sortedModels = useMemo( - () => [...models].sort((a, b) => Number(a.is_premium) - Number(b.is_premium)), + () => models.toSorted((a, b) => Number(a.is_premium) - Number(b.is_premium)), [models] ); - const filteredModels = useMemo(() => { - if (!searchQuery.trim()) return sortedModels; - const q = searchQuery.toLowerCase(); - return sortedModels.filter( - (m) => - m.name.toLowerCase().includes(q) || - m.model_name.toLowerCase().includes(q) || - m.provider.toLowerCase().includes(q) - ); - }, [sortedModels, searchQuery]); - const handleSelect = useCallback( (model: AnonModel) => { setOpen(false); @@ -70,42 +65,15 @@ export function FreeModelSelector({ className }: { className?: string }) { [currentSlug, anonMode, router] ); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - 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 ( - + - e.preventDefault()} - > -
- - 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" - /> -
-
- {filteredModels.length === 0 ? ( -
- -

No models found

-
- ) : ( - filteredModels.map((model, index) => { - const isSelected = model.seo_slug === currentSlug; - const isFocused = focusedIndex === index; - return ( -
handleSelect(model)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleSelect(model); - } - }} - onMouseEnter={() => setFocusedIndex(index)} - className={cn( - "group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer", - "transition-colors duration-150 mx-2", - "hover:bg-accent hover:text-accent-foreground", - isFocused && "bg-accent text-accent-foreground", - isSelected && "bg-accent text-accent-foreground" - )} - > -
- {getProviderIcon(model.provider, { className: "size-5" })} -
-
-
- {model.name} - {model.is_premium ? ( - - Premium - - ) : ( - - Free - - )} + + (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)} + > + + + No models found. + + {sortedModels.map((model) => { + const isSelected = model.seo_slug === currentSlug; + return ( + handleSelect(model)} + className="gap-2.5" + > +
+ {getProviderIcon(model.provider, { className: "size-5" })}
- - {model.model_name} - -
- {isSelected && } -
- ); - }) - )} -
+
+
+ {model.name} + + {model.is_premium ? "Premium" : "Free"} + +
+ + {model.model_name} + +
+ {isSelected && } + + ); + })} + + + ); diff --git a/surfsense_web/components/free-chat/free-right-panel.tsx b/surfsense_web/components/free-chat/free-right-panel.tsx index f2b07815f..f37af6142 100644 --- a/surfsense_web/components/free-chat/free-right-panel.tsx +++ b/surfsense_web/components/free-chat/free-right-panel.tsx @@ -4,6 +4,14 @@ import { Lock } from "lucide-react"; import Link from "next/link"; import type { FC } from "react"; import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; interface GatedTabProps { title: string; @@ -11,16 +19,20 @@ interface GatedTabProps { } const GatedTab: FC = ({ title, description }) => ( -
-
- -
-

{title}

-

{description}

- -
+ + + + + + {title} + {description} + + + + + ); export const ReportsGatedPlaceholder: FC = () => ( diff --git a/surfsense_web/components/free-chat/quota-bar.tsx b/surfsense_web/components/free-chat/quota-bar.tsx index 0693ef539..6b2368218 100644 --- a/surfsense_web/components/free-chat/quota-bar.tsx +++ b/surfsense_web/components/free-chat/quota-bar.tsx @@ -2,6 +2,7 @@ import { OctagonAlert, Orbit } from "lucide-react"; import Link from "next/link"; +import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { cn } from "@/lib/utils"; @@ -19,38 +20,30 @@ export function QuotaBar({ used, limit, warningThreshold, className }: QuotaBarP const isExceeded = used >= limit; return ( -
-
+
+
{used.toLocaleString()} / {limit.toLocaleString()} tokens {isExceeded ? ( - Limit reached + Limit reached ) : isWarning ? ( - - + + {remaining.toLocaleString()} remaining ) : ( {percentage.toFixed(0)}% )}
- div]:bg-red-500", - isWarning && !isExceeded && "[&>div]:bg-amber-500" - )} - /> + {isExceeded && ( - - - Create free account for 5M more tokens - + )}
); diff --git a/surfsense_web/components/free-chat/quota-warning-banner.tsx b/surfsense_web/components/free-chat/quota-warning-banner.tsx index 828e8006e..e6aa89d42 100644 --- a/surfsense_web/components/free-chat/quota-warning-banner.tsx +++ b/surfsense_web/components/free-chat/quota-warning-banner.tsx @@ -3,6 +3,7 @@ import { OctagonAlert, Orbit, X } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -27,61 +28,46 @@ export function QuotaWarningBanner({ if (isExceeded) { return ( -
-
- -
-

- Free token limit reached -

-

- You've used all {limit.toLocaleString()} free tokens. Create a free account to - get $5 of premium credit and access to all models. -

- - + + + Free token limit reached + +

+ You've used all {limit.toLocaleString()} free tokens. Create a free account to get + $5 of premium credit and access to all models. +

+
-
-
+ + + ); } return ( -
-
- -

- You've used {used.toLocaleString()} of {limit.toLocaleString()} free tokens.{" "} - - Create an account - {" "} - for $5 of premium credit. -

- -
-
+ + + Running low on free tokens + + You've used {used.toLocaleString()} of {limit.toLocaleString()} free tokens.{" "} + + Create an account + {" "} + for $5 of premium credit. + + + ); } diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 881fbe2b0..a90d6b32e 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -89,10 +89,7 @@ const DesktopLocalTabContent = dynamic( { ssr: false } ); -const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = [ - "USER_MEMORY", - "TEAM_MEMORY", -]; +const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["USER_MEMORY", "TEAM_MEMORY"]; const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [ { id: -1001, diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index 769327e1e..43a5cad74 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -220,13 +220,7 @@ export const DocumentMentionPicker = forwardRef< DocumentMentionPickerRef, DocumentMentionPickerProps >(function DocumentMentionPicker( - { - searchSpaceId, - onSelectionChange, - onDone, - initialSelectedDocuments = [], - externalSearch = "", - }, + { searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" }, ref ) { const search = externalSearch; diff --git a/surfsense_web/components/ui/empty.tsx b/surfsense_web/components/ui/empty.tsx new file mode 100644 index 000000000..79145502f --- /dev/null +++ b/surfsense_web/components/ui/empty.tsx @@ -0,0 +1,94 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia }; diff --git a/surfsense_web/contexts/anonymous-mode.tsx b/surfsense_web/contexts/anonymous-mode.tsx index eaf0996c3..c24a5cb1b 100644 --- a/surfsense_web/contexts/anonymous-mode.tsx +++ b/surfsense_web/contexts/anonymous-mode.tsx @@ -9,6 +9,8 @@ export interface AnonymousModeContextValue { setModelSlug: (slug: string) => void; uploadedDoc: { filename: string; sizeBytes: number } | null; setUploadedDoc: (doc: { filename: string; sizeBytes: number } | null) => void; + webSearchEnabled: boolean; + setWebSearchEnabled: (enabled: boolean) => void; resetKey: number; resetChat: () => void; } @@ -34,6 +36,7 @@ export function AnonymousModeProvider({ const [uploadedDoc, setUploadedDoc] = useState<{ filename: string; sizeBytes: number } | null>( null ); + const [webSearchEnabled, setWebSearchEnabled] = useState(true); const [resetKey, setResetKey] = useState(0); const resetChat = () => setResetKey((k) => k + 1); @@ -56,10 +59,12 @@ export function AnonymousModeProvider({ setModelSlug, uploadedDoc, setUploadedDoc, + webSearchEnabled, + setWebSearchEnabled, resetKey, resetChat, }), - [modelSlug, uploadedDoc, resetKey] + [modelSlug, uploadedDoc, webSearchEnabled, resetKey] ); return {children};