mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-02 19:55:18 +02:00
refactor: anonymous/free chat experience
- Enhanced lambda function formatting in `_after_commit` for better clarity. - Simplified generator expression in `_match_condition` for improved readability. - Streamlined function signature in `_eligible` for consistency. - Updated imports and refactored anonymous chat routes to use a new agent creation method. - Added a new function `_load_anon_document` to handle document loading from Redis. - Improved UI components by replacing legacy structures with modern alternatives, including alerts and separators. - Refactored quota-related components to utilize new alert structures for better user feedback. - Cleaned up unused variables and optimized component states for performance.
This commit is contained in:
parent
0cce9b7e64
commit
0f2e3c7655
17 changed files with 493 additions and 278 deletions
168
surfsense_backend/app/agents/new_chat/anonymous_agent.py
Normal file
168
surfsense_backend/app/agents/new_chat/anonymous_agent.py
Normal file
|
|
@ -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'<uploaded_document title="{title}">\n'
|
||||||
|
f"{truncated}{truncation_note}\n"
|
||||||
|
"</uploaded_document>"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
@ -65,8 +65,7 @@ def _match_condition(condition: Any, actual: Any) -> bool:
|
||||||
return False
|
return False
|
||||||
if isinstance(condition, dict):
|
if isinstance(condition, dict):
|
||||||
return all(
|
return all(
|
||||||
_apply_operator(op, operand, actual)
|
_apply_operator(op, operand, actual) for op, operand in condition.items()
|
||||||
for op, operand in condition.items()
|
|
||||||
)
|
)
|
||||||
return actual == condition
|
return actual == condition
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,7 @@ async def _select_and_start(event_dict: dict[str, Any]) -> None:
|
||||||
await _start_one(session, trigger=trigger, event=event)
|
await _start_one(session, trigger=trigger, event=event)
|
||||||
|
|
||||||
|
|
||||||
async def _eligible(
|
async def _eligible(session: AsyncSession, *, event: Event) -> list[AutomationTrigger]:
|
||||||
session: AsyncSession, *, event: Event
|
|
||||||
) -> list[AutomationTrigger]:
|
|
||||||
"""Enabled ``event`` triggers for this event type whose filter matches."""
|
"""Enabled ``event`` triggers for this event type whose filter matches."""
|
||||||
stmt = select(AutomationTrigger).where(
|
stmt = select(AutomationTrigger).where(
|
||||||
AutomationTrigger.type == TriggerType.EVENT,
|
AutomationTrigger.type == TriggerType.EVENT,
|
||||||
|
|
|
||||||
|
|
@ -351,10 +351,9 @@ async def stream_anonymous_chat(
|
||||||
async def _generate():
|
async def _generate():
|
||||||
from langchain_core.messages import AIMessage, HumanMessage
|
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.agents.new_chat.checkpointer import get_checkpointer
|
||||||
from app.db import shielded_async_session
|
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.new_streaming_service import VercelStreamingService
|
||||||
from app.services.token_tracking_service import start_turn
|
from app.services.token_tracking_service import start_turn
|
||||||
from app.tasks.chat.stream_new_chat import StreamResult, _stream_agent_events
|
from app.tasks.chat.stream_new_chat import StreamResult, _stream_agent_events
|
||||||
|
|
@ -363,24 +362,23 @@ async def stream_anonymous_chat(
|
||||||
streaming_service = VercelStreamingService()
|
streaming_service = VercelStreamingService()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with shielded_async_session() as session:
|
async with shielded_async_session():
|
||||||
connector_service = ConnectorService(session, search_space_id=None)
|
|
||||||
checkpointer = await get_checkpointer()
|
checkpointer = await get_checkpointer()
|
||||||
|
|
||||||
anon_thread_id = f"anon-{session_id}-{request_id}"
|
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,
|
llm=llm,
|
||||||
search_space_id=0,
|
|
||||||
db_session=session,
|
|
||||||
connector_service=connector_service,
|
|
||||||
checkpointer=checkpointer,
|
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_session_id=session_id,
|
||||||
|
anon_doc=anon_doc,
|
||||||
|
enable_web_search="web_search" in enabled_for_agent,
|
||||||
)
|
)
|
||||||
|
|
||||||
langchain_messages = []
|
langchain_messages = []
|
||||||
|
|
@ -396,7 +394,6 @@ async def stream_anonymous_chat(
|
||||||
|
|
||||||
input_state = {
|
input_state = {
|
||||||
"messages": langchain_messages,
|
"messages": langchain_messages,
|
||||||
"search_space_id": 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
langgraph_config = {
|
langgraph_config = {
|
||||||
|
|
@ -500,6 +497,38 @@ ANON_ALLOWED_EXTENSIONS = PLAINTEXT_EXTENSIONS | DIRECT_CONVERT_EXTENSIONS
|
||||||
ANON_DOC_REDIS_PREFIX = "anon:doc:"
|
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):
|
class AnonDocResponse(BaseModel):
|
||||||
filename: str
|
filename: str
|
||||||
size_bytes: int
|
size_bytes: int
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,11 @@ def _after_commit(session: Session) -> None:
|
||||||
]
|
]
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
task.add_done_callback(
|
task.add_done_callback(
|
||||||
lambda t: logger.error("event publish failed: %s", t.exception())
|
lambda t: (
|
||||||
if not t.cancelled() and t.exception()
|
logger.error("event publish failed: %s", t.exception())
|
||||||
else None
|
if not t.cancelled() and t.exception()
|
||||||
|
else None
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def test_register_then_get_returns_the_event_type(isolated_event_catalog: None) -> None:
|
||||||
from app.event_bus.catalog import catalog
|
from app.event_bus.catalog import catalog
|
||||||
|
|
||||||
catalog.register(_event_type())
|
catalog.register(_event_type())
|
||||||
|
|
||||||
assert catalog.get("test.thing") is not None
|
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:
|
def test_get_unknown_type_returns_none(isolated_event_catalog: None) -> None:
|
||||||
from app.event_bus.catalog import catalog
|
from app.event_bus.catalog import catalog
|
||||||
|
|
||||||
assert catalog.get("does.not.exist") is None
|
assert catalog.get("does.not.exist") is None
|
||||||
|
|
||||||
|
|
||||||
def test_register_duplicate_type_raises(isolated_event_catalog: None) -> 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."""
|
"""A type is a contract; registering it twice is a bug, not an override."""
|
||||||
from app.event_bus.catalog import catalog
|
from app.event_bus.catalog import catalog
|
||||||
|
|
||||||
catalog.register(_event_type())
|
catalog.register(_event_type())
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="already registered"):
|
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:
|
def test_all_is_a_defensive_snapshot(isolated_event_catalog: None) -> None:
|
||||||
"""Mutating the returned dict must not corrupt the registry."""
|
"""Mutating the returned dict must not corrupt the registry."""
|
||||||
from app.event_bus.catalog import catalog
|
from app.event_bus.catalog import catalog
|
||||||
|
|
||||||
catalog.register(_event_type())
|
catalog.register(_event_type())
|
||||||
|
|
||||||
snapshot = catalog.all()
|
snapshot = catalog.all()
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,9 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
|
||||||
doc
|
doc
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{isDocsChunk ? "Documentation reference" : "Uploaded document"}</TooltipContent>
|
<TooltipContent>
|
||||||
|
{isDocsChunk ? "Documentation reference" : "Uploaded document"}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
type TokenUsageData,
|
type TokenUsageData,
|
||||||
TokenUsageProvider,
|
TokenUsageProvider,
|
||||||
} from "@/components/assistant-ui/token-usage-context";
|
} from "@/components/assistant-ui/token-usage-context";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||||
import { TimelineDataUI } from "@/features/chat-messages/timeline";
|
import { TimelineDataUI } from "@/features/chat-messages/timeline";
|
||||||
import {
|
import {
|
||||||
|
|
@ -101,11 +102,16 @@ export function FreeChatPage() {
|
||||||
const anonMode = useAnonymousMode();
|
const anonMode = useAnonymousMode();
|
||||||
const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
|
const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
|
||||||
const resetKey = anonMode.isAnonymous ? anonMode.resetKey : 0;
|
const resetKey = anonMode.isAnonymous ? anonMode.resetKey : 0;
|
||||||
|
const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true;
|
||||||
|
|
||||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [tokenUsageStore] = useState(() => createTokenUsageStore());
|
const [tokenUsageStore] = useState(() => createTokenUsageStore());
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
// Mirror the latest messages into a ref so onNew stays a stable callback
|
||||||
|
// (it reads history on demand instead of depending on the array).
|
||||||
|
const messagesRef = useRef<ThreadMessageLike[]>([]);
|
||||||
|
messagesRef.current = messages;
|
||||||
|
|
||||||
// Turnstile CAPTCHA state
|
// Turnstile CAPTCHA state
|
||||||
const [captchaRequired, setCaptchaRequired] = useState(false);
|
const [captchaRequired, setCaptchaRequired] = useState(false);
|
||||||
|
|
@ -152,6 +158,7 @@ export function FreeChatPage() {
|
||||||
model_slug: modelSlug,
|
model_slug: modelSlug,
|
||||||
messages: messageHistory,
|
messages: messageHistory,
|
||||||
};
|
};
|
||||||
|
if (!webSearchEnabled) reqBody.disabled_tools = ["web_search"];
|
||||||
if (turnstileToken) reqBody.turnstile_token = turnstileToken;
|
if (turnstileToken) reqBody.turnstile_token = turnstileToken;
|
||||||
|
|
||||||
const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, {
|
const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, {
|
||||||
|
|
@ -301,7 +308,7 @@ export function FreeChatPage() {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[modelSlug, tokenUsageStore]
|
[modelSlug, tokenUsageStore, webSearchEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onNew = useCallback(
|
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")
|
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
let text = "";
|
let text = "";
|
||||||
|
|
@ -395,7 +402,7 @@ export function FreeChatPage() {
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[messages, doStream]
|
[modelSlug, anonMode, doStream]
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Called when Turnstile resolves successfully. Stores the token and auto-retries. */
|
/** Called when Turnstile resolves successfully. Stores the token and auto-retries. */
|
||||||
|
|
@ -481,19 +488,21 @@ export function FreeChatPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{captchaRequired && TURNSTILE_SITE_KEY && (
|
{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 justify-center border-b bg-muted/30 px-4 py-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<Alert className="w-auto max-w-md">
|
||||||
<ShieldCheck className="h-4 w-4" />
|
<ShieldCheck />
|
||||||
<span>Quick verification to continue chatting</span>
|
<AlertTitle>Quick verification to continue chatting</AlertTitle>
|
||||||
</div>
|
<AlertDescription>
|
||||||
<Turnstile
|
<Turnstile
|
||||||
ref={turnstileRef}
|
ref={turnstileRef}
|
||||||
siteKey={TURNSTILE_SITE_KEY}
|
siteKey={TURNSTILE_SITE_KEY}
|
||||||
onSuccess={handleTurnstileSuccess}
|
onSuccess={handleTurnstileSuccess}
|
||||||
onError={() => turnstileRef.current?.reset()}
|
onError={() => turnstileRef.current?.reset()}
|
||||||
onExpire={() => turnstileRef.current?.reset()}
|
onExpire={() => turnstileRef.current?.reset()}
|
||||||
options={{ theme: "auto", size: "normal" }}
|
options={{ theme: "auto", size: "normal" }}
|
||||||
/>
|
/>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { type FC, useCallback, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||||
|
|
@ -71,10 +72,11 @@ export const FreeComposer: FC = () => {
|
||||||
const { gate } = useLoginGate();
|
const { gate } = useLoginGate();
|
||||||
const anonMode = useAnonymousMode();
|
const anonMode = useAnonymousMode();
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [webSearchEnabled, setWebSearchEnabled] = useState(true);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const hasUploadedDoc = anonMode.isAnonymous && anonMode.uploadedDoc !== null;
|
const hasUploadedDoc = anonMode.isAnonymous && anonMode.uploadedDoc !== null;
|
||||||
|
const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true;
|
||||||
|
const setWebSearchEnabled = anonMode.isAnonymous ? anonMode.setWebSearchEnabled : () => {};
|
||||||
|
|
||||||
const handleTextChange = useCallback(
|
const handleTextChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
|
@ -189,14 +191,11 @@ export const FreeComposer: FC = () => {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={handleUploadClick}
|
onClick={handleUploadClick}
|
||||||
className={cn(
|
className={cn(hasUploadedDoc && "text-primary")}
|
||||||
"h-auto gap-1.5 rounded-md px-2 py-1 text-xs transition-colors",
|
|
||||||
"text-muted-foreground hover:text-accent-foreground hover:bg-accent",
|
|
||||||
hasUploadedDoc && "text-primary"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Paperclip className="size-3.5" />
|
<Paperclip data-icon="inline-start" />
|
||||||
{hasUploadedDoc ? "1/1" : "Upload"}
|
{hasUploadedDoc ? "1/1" : "Upload"}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -207,13 +206,13 @@ export const FreeComposer: FC = () => {
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className="h-4 w-px bg-border/60" />
|
<Separator orientation="vertical" className="h-4" />
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<label
|
<label
|
||||||
htmlFor="free-web-search-toggle"
|
htmlFor="free-web-search-toggle"
|
||||||
className="flex items-center gap-1.5 cursor-pointer select-none rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-accent-foreground hover:bg-accent transition-colors"
|
className="flex cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
<Globe className="size-3.5" />
|
<Globe className="size-3.5" />
|
||||||
<span className="hidden sm:inline">Web</span>
|
<span className="hidden sm:inline">Web</span>
|
||||||
|
|
@ -221,7 +220,6 @@ export const FreeComposer: FC = () => {
|
||||||
id="free-web-search-toggle"
|
id="free-web-search-toggle"
|
||||||
checked={webSearchEnabled}
|
checked={webSearchEnabled}
|
||||||
onCheckedChange={setWebSearchEnabled}
|
onCheckedChange={setWebSearchEnabled}
|
||||||
className="scale-75"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Bot, Check, ChevronDown, Search } from "lucide-react";
|
import { Bot, Check, ChevronDown } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||||
import type { AnonModel } from "@/contracts/types/anonymous-chat.types";
|
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 [open, setOpen] = useState(false);
|
||||||
const [models, setModels] = useState<AnonModel[]>([]);
|
const [models, setModels] = useState<AnonModel[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
anonymousChatApiService.getModels().then(setModels).catch(console.error);
|
const controller = new AbortController();
|
||||||
}, []);
|
anonymousChatApiService
|
||||||
|
.getModels()
|
||||||
const handleOpenChange = useCallback((next: boolean) => {
|
.then((data) => {
|
||||||
if (next) {
|
if (!controller.signal.aborted) setModels(data);
|
||||||
setSearchQuery("");
|
})
|
||||||
setFocusedIndex(-1);
|
.catch((err) => {
|
||||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
if (!controller.signal.aborted) console.error(err);
|
||||||
}
|
});
|
||||||
setOpen(next);
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentModel = useMemo(
|
const currentModel = useMemo(
|
||||||
|
|
@ -41,22 +46,12 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
||||||
[models, currentSlug]
|
[models, currentSlug]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Free models first, premium last; immutable sort to avoid mutating state.
|
||||||
const sortedModels = useMemo(
|
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]
|
[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(
|
const handleSelect = useCallback(
|
||||||
(model: AnonModel) => {
|
(model: AnonModel) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
@ -70,42 +65,15 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
||||||
[currentSlug, anonMode, router]
|
[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 (
|
return (
|
||||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn(
|
className={cn("gap-2 bg-muted hover:bg-muted/80", className)}
|
||||||
"h-8 gap-2 px-3 text-sm bg-muted hover:bg-muted/80 border-0 select-none",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{currentModel ? (
|
{currentModel ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -118,90 +86,47 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
||||||
<span className="text-muted-foreground">Select Model</span>
|
<span className="text-muted-foreground">Select Model</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0" />
|
<ChevronDown className="ml-1 size-3.5 shrink-0 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="w-[320px] p-0" align="start" sideOffset={8}>
|
||||||
className="w-[320px] p-0 rounded-lg shadow-lg overflow-hidden select-none"
|
<Command
|
||||||
align="start"
|
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
|
||||||
sideOffset={8}
|
>
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
<CommandInput placeholder="Search models" />
|
||||||
>
|
<CommandList>
|
||||||
<div className="relative">
|
<CommandEmpty>No models found.</CommandEmpty>
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
<CommandGroup>
|
||||||
<input
|
{sortedModels.map((model) => {
|
||||||
ref={searchInputRef}
|
const isSelected = model.seo_slug === currentSlug;
|
||||||
placeholder="Search models"
|
return (
|
||||||
value={searchQuery}
|
<CommandItem
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
key={model.id}
|
||||||
onKeyDown={handleKeyDown}
|
value={`${model.name} ${model.model_name} ${model.provider}`}
|
||||||
className="w-full pl-8 pr-3 py-2.5 text-sm bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
onSelect={() => handleSelect(model)}
|
||||||
/>
|
className="gap-2.5"
|
||||||
</div>
|
>
|
||||||
<div className="overflow-y-auto max-h-[320px] py-1 space-y-0.5">
|
<div className="shrink-0">
|
||||||
{filteredModels.length === 0 ? (
|
{getProviderIcon(model.provider, { className: "size-5" })}
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-8 px-4">
|
|
||||||
<Search className="size-6 text-muted-foreground" />
|
|
||||||
<p className="text-sm text-muted-foreground">No models found</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredModels.map((model, index) => {
|
|
||||||
const isSelected = model.seo_slug === currentSlug;
|
|
||||||
const isFocused = focusedIndex === index;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={model.id}
|
|
||||||
role="option"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-selected={isSelected}
|
|
||||||
onClick={() => handleSelect(model)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(model);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setFocusedIndex(index)}
|
|
||||||
className={cn(
|
|
||||||
"group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer",
|
|
||||||
"transition-colors duration-150 mx-2",
|
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
|
||||||
isFocused && "bg-accent text-accent-foreground",
|
|
||||||
isSelected && "bg-accent text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="shrink-0">
|
|
||||||
{getProviderIcon(model.provider, { className: "size-5" })}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="font-medium text-sm truncate">{model.name}</span>
|
|
||||||
{model.is_premium ? (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[9px] px-1 py-0 h-3.5 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
|
|
||||||
>
|
|
||||||
Premium
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[9px] px-1 py-0 h-3.5 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
|
|
||||||
>
|
|
||||||
Free
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground truncate block">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
{model.model_name}
|
<div className="flex items-center gap-1.5">
|
||||||
</span>
|
<span className="truncate text-sm font-medium">{model.name}</span>
|
||||||
</div>
|
<Badge variant={model.is_premium ? "default" : "secondary"}>
|
||||||
{isSelected && <Check className="size-4 text-primary shrink-0" />}
|
{model.is_premium ? "Premium" : "Free"}
|
||||||
</div>
|
</Badge>
|
||||||
);
|
</div>
|
||||||
})
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
)}
|
{model.model_name}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && <Check className="size-4 shrink-0 text-primary" />}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,14 @@ import { Lock } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@/components/ui/empty";
|
||||||
|
|
||||||
interface GatedTabProps {
|
interface GatedTabProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -11,16 +19,20 @@ interface GatedTabProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GatedTab: FC<GatedTabProps> = ({ title, description }) => (
|
const GatedTab: FC<GatedTabProps> = ({ title, description }) => (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center">
|
<Empty>
|
||||||
<div className="rounded-full bg-muted p-3">
|
<EmptyHeader>
|
||||||
<Lock className="size-5 text-muted-foreground" />
|
<EmptyMedia variant="icon">
|
||||||
</div>
|
<Lock />
|
||||||
<h3 className="text-sm font-medium">{title}</h3>
|
</EmptyMedia>
|
||||||
<p className="text-xs text-muted-foreground max-w-[200px]">{description}</p>
|
<EmptyTitle>{title}</EmptyTitle>
|
||||||
<Button size="sm" asChild>
|
<EmptyDescription>{description}</EmptyDescription>
|
||||||
<Link href="/register">Create Free Account</Link>
|
</EmptyHeader>
|
||||||
</Button>
|
<EmptyContent>
|
||||||
</div>
|
<Button size="sm" asChild>
|
||||||
|
<Link href="/register">Create Free Account</Link>
|
||||||
|
</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ReportsGatedPlaceholder: FC = () => (
|
export const ReportsGatedPlaceholder: FC = () => (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { OctagonAlert, Orbit } from "lucide-react";
|
import { OctagonAlert, Orbit } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -19,38 +20,30 @@ export function QuotaBar({ used, limit, warningThreshold, className }: QuotaBarP
|
||||||
const isExceeded = used >= limit;
|
const isExceeded = used >= limit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-1.5", className)}>
|
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||||
<div className="flex justify-between items-center text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{used.toLocaleString()} / {limit.toLocaleString()} tokens
|
{used.toLocaleString()} / {limit.toLocaleString()} tokens
|
||||||
</span>
|
</span>
|
||||||
{isExceeded ? (
|
{isExceeded ? (
|
||||||
<span className="font-medium text-red-500">Limit reached</span>
|
<span className="font-medium text-destructive">Limit reached</span>
|
||||||
) : isWarning ? (
|
) : isWarning ? (
|
||||||
<span className="font-medium text-amber-500 flex items-center gap-1">
|
<span className="flex items-center gap-1 font-medium text-highlight">
|
||||||
<OctagonAlert className="h-3 w-3" />
|
<OctagonAlert className="size-3" />
|
||||||
{remaining.toLocaleString()} remaining
|
{remaining.toLocaleString()} remaining
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-medium">{percentage.toFixed(0)}%</span>
|
<span className="font-medium">{percentage.toFixed(0)}%</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress value={percentage} className="h-1.5" />
|
||||||
value={percentage}
|
|
||||||
className={cn(
|
|
||||||
"h-1.5",
|
|
||||||
isExceeded && "[&>div]:bg-red-500",
|
|
||||||
isWarning && !isExceeded && "[&>div]:bg-amber-500"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{isExceeded && (
|
{isExceeded && (
|
||||||
<Link
|
<Button asChild size="sm" className="mt-0.5 w-full">
|
||||||
href="/register"
|
<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 data-icon="inline-start" />
|
||||||
>
|
Create free account for 5M more tokens
|
||||||
<Orbit className="h-3 w-3" />
|
</Link>
|
||||||
Create free account for 5M more tokens
|
</Button>
|
||||||
</Link>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { OctagonAlert, Orbit, X } from "lucide-react";
|
import { OctagonAlert, Orbit, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -27,61 +28,46 @@ export function QuotaWarningBanner({
|
||||||
|
|
||||||
if (isExceeded) {
|
if (isExceeded) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Alert variant="destructive" className={className}>
|
||||||
className={cn(
|
<OctagonAlert />
|
||||||
"rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/50 p-4",
|
<AlertTitle>Free token limit reached</AlertTitle>
|
||||||
className
|
<AlertDescription>
|
||||||
)}
|
<p>
|
||||||
>
|
You've used all {limit.toLocaleString()} free tokens. Create a free account to get
|
||||||
<div className="flex items-start gap-3">
|
$5 of premium credit and access to all models.
|
||||||
<OctagonAlert className="h-5 w-5 text-red-500 shrink-0 mt-0.5" />
|
</p>
|
||||||
<div className="flex-1 space-y-2">
|
<Button asChild size="sm" className="mt-1">
|
||||||
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
<Link href="/register">
|
||||||
Free token limit reached
|
<Orbit data-icon="inline-start" />
|
||||||
</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 of premium credit and access to all models.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/register"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-linear-to-r from-purple-600 to-blue-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
|
||||||
>
|
|
||||||
<Orbit className="h-4 w-4" />
|
|
||||||
Create Free Account
|
Create Free Account
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Alert variant="warning" className={cn("pr-10", className)}>
|
||||||
className={cn(
|
<OctagonAlert />
|
||||||
"rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/50 p-3",
|
<AlertTitle>Running low on free tokens</AlertTitle>
|
||||||
className
|
<AlertDescription>
|
||||||
)}
|
You've used {used.toLocaleString()} of {limit.toLocaleString()} free tokens.{" "}
|
||||||
>
|
<Link href="/register" className="font-medium underline hover:no-underline">
|
||||||
<div className="flex items-center gap-3">
|
Create an account
|
||||||
<OctagonAlert className="h-4 w-4 text-amber-500 shrink-0" />
|
</Link>{" "}
|
||||||
<p className="flex-1 text-xs text-amber-700 dark:text-amber-300">
|
for $5 of premium credit.
|
||||||
You've used {used.toLocaleString()} of {limit.toLocaleString()} free tokens.{" "}
|
</AlertDescription>
|
||||||
<Link href="/register" className="font-medium underline hover:no-underline">
|
<Button
|
||||||
Create an account
|
type="button"
|
||||||
</Link>{" "}
|
variant="ghost"
|
||||||
for $5 of premium credit.
|
size="icon"
|
||||||
</p>
|
onClick={() => setDismissed(true)}
|
||||||
<Button
|
aria-label="Dismiss"
|
||||||
type="button"
|
className="absolute top-2 right-2 size-6"
|
||||||
variant="ghost"
|
>
|
||||||
size="icon"
|
<X />
|
||||||
onClick={() => setDismissed(true)}
|
</Button>
|
||||||
className="size-6 text-amber-400 hover:bg-transparent hover:text-amber-600 dark:hover:text-amber-200"
|
</Alert>
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,7 @@ const DesktopLocalTabContent = dynamic(
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = [
|
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["USER_MEMORY", "TEAM_MEMORY"];
|
||||||
"USER_MEMORY",
|
|
||||||
"TEAM_MEMORY",
|
|
||||||
];
|
|
||||||
const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [
|
const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [
|
||||||
{
|
{
|
||||||
id: -1001,
|
id: -1001,
|
||||||
|
|
|
||||||
|
|
@ -220,13 +220,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
DocumentMentionPickerRef,
|
DocumentMentionPickerRef,
|
||||||
DocumentMentionPickerProps
|
DocumentMentionPickerProps
|
||||||
>(function DocumentMentionPicker(
|
>(function DocumentMentionPicker(
|
||||||
{
|
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
|
||||||
searchSpaceId,
|
|
||||||
onSelectionChange,
|
|
||||||
onDone,
|
|
||||||
initialSelectedDocuments = [],
|
|
||||||
externalSearch = "",
|
|
||||||
},
|
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const search = externalSearch;
|
const search = externalSearch;
|
||||||
|
|
|
||||||
94
surfsense_web/components/ui/empty.tsx
Normal file
94
surfsense_web/components/ui/empty.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty"
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-header"
|
||||||
|
className={cn("flex max-w-sm flex-col items-center gap-2 text-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMediaVariants = cva(
|
||||||
|
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function EmptyMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-icon"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(emptyMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-title"
|
||||||
|
className={cn("text-lg font-medium tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-content"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };
|
||||||
|
|
@ -9,6 +9,8 @@ export interface AnonymousModeContextValue {
|
||||||
setModelSlug: (slug: string) => void;
|
setModelSlug: (slug: string) => void;
|
||||||
uploadedDoc: { filename: string; sizeBytes: number } | null;
|
uploadedDoc: { filename: string; sizeBytes: number } | null;
|
||||||
setUploadedDoc: (doc: { filename: string; sizeBytes: number } | null) => void;
|
setUploadedDoc: (doc: { filename: string; sizeBytes: number } | null) => void;
|
||||||
|
webSearchEnabled: boolean;
|
||||||
|
setWebSearchEnabled: (enabled: boolean) => void;
|
||||||
resetKey: number;
|
resetKey: number;
|
||||||
resetChat: () => void;
|
resetChat: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -34,6 +36,7 @@ export function AnonymousModeProvider({
|
||||||
const [uploadedDoc, setUploadedDoc] = useState<{ filename: string; sizeBytes: number } | null>(
|
const [uploadedDoc, setUploadedDoc] = useState<{ filename: string; sizeBytes: number } | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [webSearchEnabled, setWebSearchEnabled] = useState(true);
|
||||||
const [resetKey, setResetKey] = useState(0);
|
const [resetKey, setResetKey] = useState(0);
|
||||||
|
|
||||||
const resetChat = () => setResetKey((k) => k + 1);
|
const resetChat = () => setResetKey((k) => k + 1);
|
||||||
|
|
@ -56,10 +59,12 @@ export function AnonymousModeProvider({
|
||||||
setModelSlug,
|
setModelSlug,
|
||||||
uploadedDoc,
|
uploadedDoc,
|
||||||
setUploadedDoc,
|
setUploadedDoc,
|
||||||
|
webSearchEnabled,
|
||||||
|
setWebSearchEnabled,
|
||||||
resetKey,
|
resetKey,
|
||||||
resetChat,
|
resetChat,
|
||||||
}),
|
}),
|
||||||
[modelSlug, uploadedDoc, resetKey]
|
[modelSlug, uploadedDoc, webSearchEnabled, resetKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AnonymousModeContext.Provider value={value}>{children}</AnonymousModeContext.Provider>;
|
return <AnonymousModeContext.Provider value={value}>{children}</AnonymousModeContext.Provider>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue