mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-23 19:05:16 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/ui-revamp
This commit is contained in:
commit
4e8c552440
142 changed files with 14603 additions and 6056 deletions
|
|
@ -5,7 +5,7 @@ import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
|||
export const metadata: Metadata = {
|
||||
title: "Pricing | SurfSense - Free AI Search Plans",
|
||||
description:
|
||||
"Explore SurfSense plans and pricing. Start free with 500 pages & $5 of premium credit. Use ChatGPT, Claude AI, and premium AI models. Pay as you go at provider cost — $1 buys $1 of credit.",
|
||||
"Explore SurfSense plans and pricing. Start free with 500 pages & $5 in premium credits. Use ChatGPT, Claude AI, and premium AI models. Pay as you go at provider cost.",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/pricing",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useParams } from "next/navigation";
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { disabledToolsAtom } from "@/atoms/agent-tools/agent-tools.atoms";
|
||||
import {
|
||||
clearTargetCommentIdAtom,
|
||||
|
|
@ -393,6 +394,8 @@ export default function NewChatPage() {
|
|||
|
||||
// Get current user for author info in shared chats
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
const { data: agentFlags } = useAtomValue(agentFlagsAtom);
|
||||
const localFilesystemEnabled = agentFlags?.enable_desktop_local_filesystem === true;
|
||||
|
||||
// Live collaboration: sync session state and messages via Zero
|
||||
useChatSessionStateSync(threadId);
|
||||
|
|
@ -989,7 +992,9 @@ export default function NewChatPage() {
|
|||
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
const selection = await getAgentFilesystemSelection(searchSpaceId);
|
||||
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
||||
localFilesystemEnabled,
|
||||
});
|
||||
if (
|
||||
selection.filesystem_mode === "desktop_local_folder" &&
|
||||
(!selection.local_filesystem_mounts || selection.local_filesystem_mounts.length === 0)
|
||||
|
|
@ -1311,6 +1316,7 @@ export default function NewChatPage() {
|
|||
setAgentCreatedDocuments,
|
||||
queryClient,
|
||||
currentUser,
|
||||
localFilesystemEnabled,
|
||||
disabledTools,
|
||||
updateChatTabTitle,
|
||||
tokenUsageStore,
|
||||
|
|
@ -1413,7 +1419,9 @@ export default function NewChatPage() {
|
|||
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
const selection = await getAgentFilesystemSelection(searchSpaceId);
|
||||
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
||||
localFilesystemEnabled,
|
||||
});
|
||||
const response = await fetchWithTurnCancellingRetry(() =>
|
||||
fetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, {
|
||||
method: "POST",
|
||||
|
|
@ -1561,6 +1569,7 @@ export default function NewChatPage() {
|
|||
pendingInterrupt,
|
||||
messages,
|
||||
searchSpaceId,
|
||||
localFilesystemEnabled,
|
||||
queryClient,
|
||||
tokenUsageStore,
|
||||
fetchWithTurnCancellingRetry,
|
||||
|
|
@ -1746,7 +1755,9 @@ export default function NewChatPage() {
|
|||
? messageDocumentsMap[sourceUserMessageId]
|
||||
: [];
|
||||
try {
|
||||
const selection = await getAgentFilesystemSelection(searchSpaceId);
|
||||
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
||||
localFilesystemEnabled,
|
||||
});
|
||||
const requestBody: Record<string, unknown> = {
|
||||
search_space_id: searchSpaceId,
|
||||
user_query: newUserQuery,
|
||||
|
|
@ -2016,6 +2027,7 @@ export default function NewChatPage() {
|
|||
searchSpaceId,
|
||||
messages,
|
||||
disabledTools,
|
||||
localFilesystemEnabled,
|
||||
messageDocumentsMap,
|
||||
setMessageDocumentsMap,
|
||||
queryClient,
|
||||
|
|
|
|||
|
|
@ -178,6 +178,19 @@ const FLAG_GROUPS: FlagGroup[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "desktop",
|
||||
title: "Desktop",
|
||||
subtitle: "Desktop-only capabilities exposed by the backend deployment.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_desktop_local_filesystem",
|
||||
label: "Local filesystem",
|
||||
description: "Allow Desktop chat sessions to operate directly on selected local folders.",
|
||||
envVar: "ENABLE_DESKTOP_LOCAL_FILESYSTEM",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
|
||||
|
|
|
|||
|
|
@ -477,9 +477,7 @@ const MessageInfoDropdown: FC = () => {
|
|||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{counts.total_tokens.toLocaleString()} tokens
|
||||
{costMicros && costMicros > 0
|
||||
? ` · ${formatTurnCost(costMicros)}`
|
||||
: ""}
|
||||
{costMicros && costMicros > 0 ? ` · ${formatTurnCost(costMicros)}` : ""}
|
||||
</span>
|
||||
</ActionBarMorePrimitive.Item>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@
|
|||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Some requests may be blocked if not using Firecrawl."
|
||||
},
|
||||
"JIRA_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "maintenance",
|
||||
"statusMessage": "Rework in progress."
|
||||
},
|
||||
"CONFLUENCE_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "maintenance",
|
||||
"statusMessage": "Rework in progress."
|
||||
}
|
||||
},
|
||||
"globalSettings": {
|
||||
|
|
|
|||
|
|
@ -105,14 +105,14 @@ export const OAUTH_CONNECTORS = [
|
|||
{
|
||||
id: "jira-connector",
|
||||
title: "Jira",
|
||||
description: "Search, read, and manage issues",
|
||||
description: "Rework in progress.",
|
||||
connectorType: EnumConnectorName.JIRA_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/mcp/jira/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "confluence-connector",
|
||||
title: "Confluence",
|
||||
description: "Search documentation",
|
||||
description: "Rework in progress.",
|
||||
connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/confluence/connector/add/",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { useTranslations } from "next-intl";
|
|||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
|
|
@ -197,6 +198,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const { data: agentFlags } = useAtomValue(agentFlagsAtom);
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const connectorCount = connectors?.length ?? 0;
|
||||
|
||||
|
|
@ -209,6 +211,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
||||
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
|
||||
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
|
||||
const localFilesystemEnabled = agentFlags?.enable_desktop_local_filesystem === true;
|
||||
const isElectron =
|
||||
desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI;
|
||||
|
||||
|
|
@ -1036,9 +1039,12 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
|
||||
|
||||
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
|
||||
const showFilesystemTabs =
|
||||
!isMobile && !!electronAPI && !!filesystemSettings && localFilesystemEnabled;
|
||||
const currentFilesystemTab =
|
||||
filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
|
||||
localFilesystemEnabled && filesystemSettings?.mode === "desktop_local_folder"
|
||||
? "local"
|
||||
: "cloud";
|
||||
const showCloudSkeleton =
|
||||
currentFilesystemTab === "cloud" &&
|
||||
(zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete");
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import type React from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
imageGenConfigsAtom,
|
||||
|
|
@ -461,6 +462,18 @@ export function ModelSelector({
|
|||
const { data: visionUserConfigs, isLoading: visionUserLoading } =
|
||||
useAtomValue(visionLLMConfigsAtom);
|
||||
|
||||
// Pending image attachments on the composer. Used to surface an
|
||||
// amber "No image" hint on chat models the catalog reports as
|
||||
// non-vision (`supports_image_input=false`) when the next message
|
||||
// will carry an image. The hint is purely advisory: selection,
|
||||
// focus, and click handling are unaffected. The backend's safety
|
||||
// net (`is_known_text_only_chat_model`) is the actual block, and
|
||||
// it only fires when LiteLLM *explicitly* marks a model as
|
||||
// text-only — so a model that's secretly capable but hasn't been
|
||||
// annotated will still flow through to the provider.
|
||||
const pendingUserImageUrls = useAtomValue(pendingUserImageDataUrlsAtom);
|
||||
const hasPendingImages = pendingUserImageUrls.length > 0;
|
||||
|
||||
const isLoading =
|
||||
llmUserLoading ||
|
||||
llmGlobalLoading ||
|
||||
|
|
@ -984,6 +997,21 @@ export function ModelSelector({
|
|||
const isSelected = getSelectedId() === config.id;
|
||||
const isFocused = focusedIndex === index;
|
||||
const hasCitations = "citations_enabled" in config && !!config.citations_enabled;
|
||||
// Chat-tab only: surface an amber "No image" hint when the
|
||||
// composer carries images and the catalog reports the model as
|
||||
// non-vision. This is purely advisory — selection is *not*
|
||||
// blocked. The backend's narrow safety net
|
||||
// (`is_known_text_only_chat_model`) is the source of truth for
|
||||
// rejecting image turns, and it only fires when LiteLLM
|
||||
// explicitly marks the model as text-only. A model surfaced as
|
||||
// `supports_image_input=false` here may still be capable in
|
||||
// practice (unknown / unmapped LiteLLM entry), so we let the
|
||||
// user pick it and the provider response decide.
|
||||
const isImageIncompatibleChatModel =
|
||||
activeTab === "llm" &&
|
||||
hasPendingImages &&
|
||||
"supports_image_input" in config &&
|
||||
(config as Record<string, unknown>).supports_image_input === false;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -992,6 +1020,11 @@ export function ModelSelector({
|
|||
role="option"
|
||||
tabIndex={isMobile ? -1 : 0}
|
||||
aria-selected={isSelected}
|
||||
title={
|
||||
isImageIncompatibleChatModel
|
||||
? "This model is reported as text-only. You can still pick it; the provider may reject image turns."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
onKeyDown={
|
||||
isMobile
|
||||
|
|
@ -1005,9 +1038,8 @@ export function ModelSelector({
|
|||
}
|
||||
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",
|
||||
"group flex items-center gap-2.5 px-3 py-2 rounded-xl",
|
||||
"transition-all duration-150 mx-2 cursor-pointer hover:bg-accent/40",
|
||||
isSelected && "bg-primary/6 dark:bg-primary/8",
|
||||
isFocused && "bg-accent/50"
|
||||
)}
|
||||
|
|
@ -1053,6 +1085,14 @@ export function ModelSelector({
|
|||
Free
|
||||
</Badge>
|
||||
) : null}
|
||||
{isImageIncompatibleChatModel && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300 border-0"
|
||||
>
|
||||
No image
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ const demoPlans = [
|
|||
price: "0",
|
||||
yearlyPrice: "0",
|
||||
period: "",
|
||||
billingText: "500 pages + $5 of premium credit included",
|
||||
billingText: "500 pages + $5 in premium credits included",
|
||||
features: [
|
||||
"Self Hostable",
|
||||
"500 pages included to start",
|
||||
"$5 of premium credit to start, billed at provider cost",
|
||||
"$5 in premium credits for paid AI models and premium AI features",
|
||||
"Includes access to OpenAI text, audio and image models",
|
||||
"Realtime Collaborative Group Chats with teammates",
|
||||
"Community support on Discord",
|
||||
|
|
@ -34,8 +34,7 @@ const demoPlans = [
|
|||
billingText: "No subscription, buy only when you need more",
|
||||
features: [
|
||||
"Everything in Free",
|
||||
"Buy 1,000-page packs at $1 each",
|
||||
"Top up premium credit at $1 per $1 of credit, billed at provider cost",
|
||||
"Buy 1,000-page packs or $1 in premium credits at $1 each",
|
||||
"Use premium AI models like GPT-5.4, Claude Sonnet 4.6, Gemini 2.5 Pro & 100+ more via OpenRouter",
|
||||
"Priority support on Discord",
|
||||
],
|
||||
|
|
@ -89,7 +88,7 @@ const faqData: FAQSection[] = [
|
|||
{
|
||||
question: "What are Basic and Premium processing modes?",
|
||||
answer:
|
||||
"When uploading documents, you can choose between two processing modes. Basic mode uses standard extraction and costs 1 page credit per page, great for most documents. Premium mode uses advanced extraction optimized for complex financial, medical, and legal documents with intricate tables, layouts, and formatting. Premium costs 10 page credits per page but delivers significantly higher fidelity output for these specialized document types.",
|
||||
"When uploading documents, you can choose between two processing modes. Basic mode uses standard extraction and costs 1 page credit per page, great for most documents. Premium processing mode uses advanced extraction optimized for complex financial, medical, and legal documents with intricate tables, layouts, and formatting. It costs 10 page credits per page and does not use your premium AI credits.",
|
||||
},
|
||||
{
|
||||
question: "How does the Pay As You Go plan work?",
|
||||
|
|
@ -129,27 +128,32 @@ const faqData: FAQSection[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
title: "Premium Credit",
|
||||
title: "Premium Credits",
|
||||
items: [
|
||||
{
|
||||
question: 'What is "premium credit"?',
|
||||
question: 'What are "premium credits"?',
|
||||
answer:
|
||||
"Premium credit is your USD balance for using premium AI models like GPT-5.4, Claude Sonnet 4.6, and Gemini 2.5 Pro in SurfSense. Each AI request debits the actual USD cost the provider charges, so cheap and expensive models bill proportionally. Non-premium models (such as the free-tier models available without login) don't touch your premium credit.",
|
||||
"Premium credits are your USD balance for paid AI usage in SurfSense, including premium AI models like GPT-5.4, Claude Sonnet 4.6, and Gemini 2.5 Pro, plus premium AI features such as image generation, podcasts, and video presentations when they use paid models. Each request debits the actual USD provider cost, so cheaper and more expensive models bill proportionally.",
|
||||
},
|
||||
{
|
||||
question: "How much premium credit do I get for free?",
|
||||
question: "How many premium credits do I get for free?",
|
||||
answer:
|
||||
"Every registered SurfSense account starts with $5 of premium credit at no cost. Anonymous users (no login) get 500,000 free tokens across all free models. Once your free credit runs out, you can top up at any time.",
|
||||
"Every registered SurfSense account starts with $5 in premium credits at no cost. Anonymous users (no login) get 500,000 free tokens across free models before creating an account. Once your included premium credits run out, you can top up at any time.",
|
||||
},
|
||||
{
|
||||
question: "How does buying premium credit work?",
|
||||
question: "How does buying premium credits work?",
|
||||
answer:
|
||||
"Just like pages, there's no subscription. Top-ups buy $1 of credit for $1 — every cent you pay is spent at provider cost, no markup. Purchased credit is added to your account immediately. You can buy up to $100 at a time.",
|
||||
"Premium credit top-ups are pay as you go, with no subscription. $1 buys $1 of credit, and your balance is spent at provider cost. Purchased credit is added to your account immediately. You can buy up to $100 at a time.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I run out of premium credit?",
|
||||
question: "Are premium credits the same as page credits?",
|
||||
answer:
|
||||
"When your premium credit balance runs low (below 20%), you'll see a warning. Once you run out, premium model requests are paused until you top up. You can always switch to non-premium models, which don't touch your premium credit.",
|
||||
"No. Page credits pay for document indexing and file-based connector processing. Premium credits pay for paid AI usage, such as premium model chats and premium AI generation features. Premium document processing mode sounds similar, but it consumes page credits, not premium credits.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I run out of premium credits?",
|
||||
answer:
|
||||
"When your premium credit balance runs low, you'll see a warning. Once you run out, paid model requests and premium AI features are paused until you top up. You can still use non-premium models and features that do not consume premium credits.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -159,7 +163,7 @@ const faqData: FAQSection[] = [
|
|||
{
|
||||
question: "Can I self-host SurfSense with unlimited pages and credit?",
|
||||
answer:
|
||||
"Yes! When self-hosting, you have full control over your page and premium-credit limits. The default self-hosted setup gives you effectively unlimited pages and premium credit, so you can index as much data and use as many AI queries as your infrastructure supports.",
|
||||
"Yes! When self-hosting, you have full control over your page and premium credit limits. The default self-hosted setup gives you effectively unlimited pages and premium credits, so you can index as much data and use as many AI queries as your infrastructure supports.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -250,7 +254,7 @@ function PricingFAQ() {
|
|||
Frequently Asked Questions
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
||||
Everything you need to know about SurfSense pages, premium credit, and billing.
|
||||
Everything you need to know about SurfSense pages, premium credits, and billing.
|
||||
Can't find what you need? Reach out at{" "}
|
||||
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
|
||||
rohan@surfsense.com
|
||||
|
|
@ -335,7 +339,7 @@ function PricingBasic() {
|
|||
<Pricing
|
||||
plans={demoPlans}
|
||||
title="SurfSense Pricing"
|
||||
description="Start free with 500 pages & $5 of premium credit. Pay as you go, billed at provider cost."
|
||||
description="Start free with 500 pages & $5 in premium credits. Pay as you go."
|
||||
/>
|
||||
<PricingFAQ />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -190,8 +191,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
? "model"
|
||||
: "models"}
|
||||
</span>{" "}
|
||||
available from your administrator.{" "}
|
||||
{(() => {
|
||||
available from your administrator. {(() => {
|
||||
const nonAuto = globalConfigs.filter(
|
||||
(g) => !("is_auto_mode" in g && g.is_auto_mode)
|
||||
);
|
||||
|
|
@ -214,6 +214,75 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Global Image Models — read-only cards with per-model Free/Premium
|
||||
badges. Mirrors the badge palette used by the chat role selector
|
||||
(`llm-role-manager.tsx`) so the meaning is consistent across
|
||||
every model-configuration surface (chat / image / vision). */}
|
||||
{!isLoading &&
|
||||
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs md:text-sm font-semibold text-muted-foreground">
|
||||
Global Image Models
|
||||
</h3>
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{globalConfigs
|
||||
.filter((g) => !("is_auto_mode" in g && g.is_auto_mode))
|
||||
.map((cfg) => {
|
||||
const billingTier =
|
||||
("billing_tier" in cfg &&
|
||||
typeof (cfg as { billing_tier?: string }).billing_tier === "string" &&
|
||||
(cfg as { billing_tier?: string }).billing_tier) ||
|
||||
"free";
|
||||
const isPremium = billingTier === "premium";
|
||||
return (
|
||||
<Card
|
||||
key={cfg.id}
|
||||
className="border-border/60 bg-muted/20 overflow-hidden h-full"
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(cfg.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-1.5">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{cfg.name}
|
||||
</h4>
|
||||
{isPremium ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
|
||||
>
|
||||
Premium
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cfg.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
|
||||
{cfg.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{cfg.model_name}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
|
|
|
|||
|
|
@ -70,9 +70,7 @@ export function MorePagesContent() {
|
|||
<div className="w-full space-y-5">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Get Free Pages</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Earn bonus pages by completing tasks
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Earn bonus pages by completing tasks</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -191,8 +192,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
? "model"
|
||||
: "models"}
|
||||
</span>{" "}
|
||||
available from your administrator.{" "}
|
||||
{(() => {
|
||||
available from your administrator. {(() => {
|
||||
const nonAuto = globalConfigs.filter(
|
||||
(g) => !("is_auto_mode" in g && g.is_auto_mode)
|
||||
);
|
||||
|
|
@ -215,6 +215,75 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Global Vision Models — read-only cards with per-model Free/Premium
|
||||
badges. Mirrors the badge palette used by the chat role selector
|
||||
(`llm-role-manager.tsx`) so the meaning is consistent across
|
||||
every model-configuration surface (chat / image / vision). */}
|
||||
{!isLoading &&
|
||||
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs md:text-sm font-semibold text-muted-foreground">
|
||||
Global Vision Models
|
||||
</h3>
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{globalConfigs
|
||||
.filter((g) => !("is_auto_mode" in g && g.is_auto_mode))
|
||||
.map((cfg) => {
|
||||
const billingTier =
|
||||
("billing_tier" in cfg &&
|
||||
typeof (cfg as { billing_tier?: string }).billing_tier === "string" &&
|
||||
(cfg as { billing_tier?: string }).billing_tier) ||
|
||||
"free";
|
||||
const isPremium = billingTier === "premium";
|
||||
return (
|
||||
<Card
|
||||
key={cfg.id}
|
||||
className="border-border/60 bg-muted/20 overflow-hidden h-full"
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(cfg.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-1.5">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{cfg.name}
|
||||
</h4>
|
||||
{isPremium ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
|
||||
>
|
||||
Premium
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cfg.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
|
||||
{cfg.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{cfg.model_name}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -416,9 +416,19 @@ export const GeneratePodcastToolUI = ({
|
|||
return <PodcastErrorState title={title} error={result.error || "Generation failed"} />;
|
||||
}
|
||||
|
||||
// Already generating - show simple warning, don't create another poller
|
||||
// The FIRST tool call will display the podcast when ready
|
||||
// (new: "generating", legacy: "already_generating")
|
||||
// Pending/generating rows have a stable podcast_id, so the card can poll
|
||||
// independently while the chat stream finishes.
|
||||
if (
|
||||
(result.status === "pending" ||
|
||||
result.status === "generating" ||
|
||||
result.status === "processing") &&
|
||||
result.podcast_id
|
||||
) {
|
||||
return <PodcastStatusPoller podcastId={result.podcast_id} title={result.title || title} />;
|
||||
}
|
||||
|
||||
// Legacy duplicate/no-ID result - show a simple warning, don't create
|
||||
// another poller. The first tool call will display the podcast when ready.
|
||||
if (result.status === "generating" || result.status === "already_generating") {
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||
|
|
@ -432,11 +442,6 @@ export const GeneratePodcastToolUI = ({
|
|||
);
|
||||
}
|
||||
|
||||
// Pending - poll for completion (new: "pending" with podcast_id)
|
||||
if (result.status === "pending" && result.podcast_id) {
|
||||
return <PodcastStatusPoller podcastId={result.podcast_id} title={result.title || title} />;
|
||||
}
|
||||
|
||||
// Ready with podcast_id (new: "ready", legacy: "success")
|
||||
if ((result.status === "ready" || result.status === "success") && result.podcast_id) {
|
||||
return <PodcastPlayer podcastId={result.podcast_id} title={result.title || title} />;
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ export function LoginGateProvider({ children }: { children: ReactNode }) {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Create a free account to {feature}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Get $5 of premium credit, save chat history, upload documents, use all AI tools,
|
||||
and connect 30+ integrations.
|
||||
Get $5 of premium credit, save chat history, upload documents, use all AI tools, and
|
||||
connect 30+ integrations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row">
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ export const newLLMConfig = z.object({
|
|||
created_at: z.string(),
|
||||
search_space_id: z.number(),
|
||||
user_id: z.string(),
|
||||
|
||||
// Capability flag — derived server-side at the route boundary from
|
||||
// LiteLLM's authoritative model map. There is no DB column. Default
|
||||
// `true` is the conservative-allow stance for unknown / unmapped
|
||||
// BYOK rows; the streaming-task safety net is the only place a
|
||||
// `false` actually blocks a request.
|
||||
supports_image_input: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -74,11 +81,16 @@ export const newLLMConfigPublic = newLLMConfig.omit({ api_key: true });
|
|||
|
||||
/**
|
||||
* Create NewLLMConfig
|
||||
*
|
||||
* `supports_image_input` is omitted because it is derived server-side
|
||||
* from LiteLLM's model map at read time — there is no DB column to
|
||||
* persist a client-supplied value into.
|
||||
*/
|
||||
export const createNewLLMConfigRequest = newLLMConfig.omit({
|
||||
id: true,
|
||||
created_at: true,
|
||||
user_id: true,
|
||||
supports_image_input: true,
|
||||
});
|
||||
|
||||
export const createNewLLMConfigResponse = newLLMConfig;
|
||||
|
|
@ -114,6 +126,8 @@ export const updateNewLLMConfigRequest = z.object({
|
|||
created_at: true,
|
||||
search_space_id: true,
|
||||
user_id: true,
|
||||
// Derived server-side; not part of the writable surface.
|
||||
supports_image_input: true,
|
||||
})
|
||||
.partial(),
|
||||
});
|
||||
|
|
@ -172,6 +186,16 @@ export const globalNewLLMConfig = z.object({
|
|||
seo_title: z.string().nullable().optional(),
|
||||
seo_description: z.string().nullable().optional(),
|
||||
quota_reserve_tokens: z.number().nullable().optional(),
|
||||
// Capability flag — true when the model can accept image inputs.
|
||||
// Resolved server-side (OpenRouter dynamic configs use the OR
|
||||
// `architecture.input_modalities` field; YAML / BYOK use LiteLLM's
|
||||
// authoritative `supports_vision` map). The chat selector renders
|
||||
// an amber "No image" hint when this is false and there are
|
||||
// pending image attachments, but does not block selection — the
|
||||
// backend safety net only rejects when LiteLLM *explicitly* marks
|
||||
// the model as text-only, so unknown / new models still flow
|
||||
// through. Default `true` matches that conservative-allow stance.
|
||||
supports_image_input: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const getGlobalNewLLMConfigsResponse = z.array(globalNewLLMConfig);
|
||||
|
|
@ -259,6 +283,9 @@ export const globalImageGenConfig = z.object({
|
|||
is_global: z.literal(true),
|
||||
is_auto_mode: z.boolean().optional().default(false),
|
||||
billing_tier: z.string().default("free"),
|
||||
// Mirrors `globalNewLLMConfig.is_premium` so the new-chat selector's
|
||||
// Free/Premium badge logic lights up automatically for image-gen too.
|
||||
is_premium: z.boolean().default(false),
|
||||
quota_reserve_micros: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -341,6 +368,9 @@ export const globalVisionLLMConfig = z.object({
|
|||
is_global: z.literal(true),
|
||||
is_auto_mode: z.boolean().optional().default(false),
|
||||
billing_tier: z.string().default("free"),
|
||||
// Mirrors `globalNewLLMConfig.is_premium` so the new-chat selector's
|
||||
// Free/Premium badge logic lights up automatically for vision too.
|
||||
is_premium: z.boolean().default(false),
|
||||
quota_reserve_tokens: z.number().nullable().optional(),
|
||||
input_cost_per_token: z.number().nullable().optional(),
|
||||
output_cost_per_token: z.number().nullable().optional(),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ export interface AgentFilesystemSelection {
|
|||
local_filesystem_mounts?: AgentFilesystemMountSelection[];
|
||||
}
|
||||
|
||||
export interface AgentFilesystemSelectionOptions {
|
||||
localFilesystemEnabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTION: AgentFilesystemSelection = {
|
||||
filesystem_mode: "cloud",
|
||||
client_platform: "web",
|
||||
|
|
@ -23,10 +27,15 @@ export function getClientPlatform(): ClientPlatform {
|
|||
}
|
||||
|
||||
export async function getAgentFilesystemSelection(
|
||||
searchSpaceId?: number | null
|
||||
searchSpaceId?: number | null,
|
||||
options?: AgentFilesystemSelectionOptions
|
||||
): Promise<AgentFilesystemSelection> {
|
||||
const platform = getClientPlatform();
|
||||
if (platform !== "desktop" || !window.electronAPI?.getAgentFilesystemSettings) {
|
||||
if (
|
||||
platform !== "desktop" ||
|
||||
!options?.localFilesystemEnabled ||
|
||||
!window.electronAPI?.getAgentFilesystemSettings
|
||||
) {
|
||||
return { ...DEFAULT_SELECTION, client_platform: platform };
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ const AgentFeatureFlagsSchema = z.object({
|
|||
enable_plugin_loader: z.boolean(),
|
||||
|
||||
enable_otel: z.boolean(),
|
||||
|
||||
enable_desktop_local_filesystem: z.boolean(),
|
||||
});
|
||||
|
||||
export type AgentFeatureFlags = z.infer<typeof AgentFeatureFlagsSchema>;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ const nextConfig: NextConfig = {
|
|||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
port: "8000",
|
||||
pathname: "/api/v1/image-generations/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "surfsense_web",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"description": "SurfSense Frontend",
|
||||
"scripts": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue