No automations yet
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx
index 74c604173..8314a5179 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx
@@ -1,5 +1,5 @@
"use client";
-import { AlarmClock, CalendarDays, Info } from "lucide-react";
+import { CalendarDays, Info, Workflow } from "lucide-react";
import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { AutomationSummary } from "@/contracts/types/automation.types";
import { AutomationRow } from "./automation-row";
@@ -31,7 +31,7 @@ export function AutomationsTable({
-
+
Name
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
index a68e53a1c..59967080f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
@@ -130,7 +130,7 @@ export function AutomationBuilderForm({
// data into state, so there's no flicker/loop and the user's pick is sticky.
const resolvedModels = useMemo(
() => ({
- chatModelId: form.models.chatModelId || eligibleModels.llm.defaultId || 0,
+ agentLlmId: form.models.agentLlmId || eligibleModels.llm.defaultId || 0,
imageConfigId: form.models.imageConfigId || eligibleModels.image.defaultId || 0,
visionConfigId: form.models.visionConfigId || eligibleModels.vision.defaultId || 0,
}),
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx
index 6dd42366b..2c4a0bf60 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx
@@ -25,7 +25,7 @@ import { getProviderIcon } from "@/lib/provider-icons";
import { Field } from "./form-field";
export interface AutomationModelSelection {
- chatModelId: number;
+ agentLlmId: number;
imageConfigId: number;
visionConfigId: number;
}
@@ -39,7 +39,7 @@ interface AutomationModelFieldsProps {
}
/**
- * Three eligible-only model pickers (Chat / Image / Vision) for the
+ * Three eligible-only model pickers (Agent LLM / Image / Vision) for the
* automation builder + chat approval card. Options come from
* {@link useAutomationEligibleModels} (premium globals + BYOK only); selection
* is validated + snapshotted onto `definition.models` at create time.
@@ -51,18 +51,18 @@ export function AutomationModelFields({
errors,
}: AutomationModelFieldsProps) {
const { llm, image, vision, isLoading } = useAutomationEligibleModels();
- const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/models`;
+ const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/roles`;
return (
onChange({ chatModelId: id })}
+ error={errors?.agentLlmId}
+ onChange={(id) => onChange({ agentLlmId: id })}
/>
{
+ return isLlmOnboardingComplete(preferences.agent_llm_id, globalConfigs.length > 0);
+ }, [preferences.agent_llm_id, globalConfigs.length]);
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
+ const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
+ const hasAttemptedAutoConfig = useRef(false);
const isOnboardingPage = pathname?.includes("/onboard");
const isOwner = access?.is_owner ?? false;
- const isSearchSpaceReady = activeSearchSpaceId === searchSpaceId;
-
- useEffect(() => {
- if (isSearchSpaceReady) return;
- setHasCheckedOnboarding(false);
- }, [isSearchSpaceReady]);
useEffect(() => {
if (isOnboardingPage) {
@@ -66,27 +66,13 @@ export function DashboardClientLayout({
}
if (
- isSearchSpaceReady &&
!loading &&
!accessLoading &&
!globalConfigsLoading &&
- !globalConfigStatusLoading &&
- !modelConnectionsLoading &&
- !hasCheckedOnboarding
+ !hasCheckedOnboarding &&
+ !isAutoConfiguring
) {
- // Onboarding is only relevant when no operator-provided
- // global_llm_config.yaml exists. When it does, search spaces inherit
- // the global config and should never be forced into onboarding.
- if (globalConfigStatus?.exists) {
- setHasCheckedOnboarding(true);
- return;
- }
-
- const onboardingComplete = isLlmOnboardingComplete(
- modelRoles.chat_model_id,
- globalConnections,
- modelConnections
- );
+ const onboardingComplete = isOnboardingComplete();
if (onboardingComplete) {
setHasCheckedOnboarding(true);
@@ -98,25 +84,56 @@ export function DashboardClientLayout({
return;
}
+ if (globalConfigs.length > 0 && !hasAttemptedAutoConfig.current) {
+ hasAttemptedAutoConfig.current = true;
+ setIsAutoConfiguring(true);
+
+ const autoConfigureWithGlobal = async () => {
+ try {
+ const firstGlobalConfig = globalConfigs[0];
+ await updatePreferences({
+ search_space_id: Number(searchSpaceId),
+ data: {
+ agent_llm_id: firstGlobalConfig.id,
+ },
+ });
+
+ await refetchPreferences();
+
+ toast.success("AI configured automatically!", {
+ description: `Using ${firstGlobalConfig.name}. Customize in Settings.`,
+ });
+
+ setHasCheckedOnboarding(true);
+ } catch (error) {
+ console.error("Auto-configuration failed:", error);
+ router.push(`/dashboard/${searchSpaceId}/onboard`);
+ } finally {
+ setIsAutoConfiguring(false);
+ }
+ };
+
+ autoConfigureWithGlobal();
+ return;
+ }
+
router.push(`/dashboard/${searchSpaceId}/onboard`);
setHasCheckedOnboarding(true);
}
}, [
- isSearchSpaceReady,
loading,
accessLoading,
globalConfigsLoading,
- globalConfigStatusLoading,
- globalConfigStatus,
- modelConnectionsLoading,
- modelRoles.chat_model_id,
- globalConnections,
- modelConnections,
+ isOnboardingComplete,
isOnboardingPage,
isOwner,
+ isAutoConfiguring,
+ globalConfigs,
router,
searchSpaceId,
hasCheckedOnboarding,
+ updatePreferences,
+ refetchPreferences,
]);
const electronAPI = useElectronAPI();
@@ -168,14 +185,10 @@ export function DashboardClientLayout({
// Determine if we should show loading
const shouldShowLoading =
- !hasCheckedOnboarding &&
- (!isSearchSpaceReady ||
- loading ||
- accessLoading ||
- globalConfigsLoading ||
- globalConfigStatusLoading ||
- modelConnectionsLoading) &&
- !isOnboardingPage;
+ (!hasCheckedOnboarding &&
+ (loading || accessLoading || globalConfigsLoading) &&
+ !isOnboardingPage) ||
+ isAutoConfiguring;
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(shouldShowLoading);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/callback/route.ts b/surfsense_web/app/dashboard/[search_space_id]/connectors/callback/route.ts
index 96f7dc349..304f33a33 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/connectors/callback/route.ts
+++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/callback/route.ts
@@ -16,12 +16,9 @@ export async function GET(
};
const result = JSON.stringify(payload);
- const response = new NextResponse(null, {
- status: 302,
- headers: {
- Location: `/dashboard/${search_space_id}/new-chat`,
- },
- });
+ const redirectUrl = new URL(`/dashboard/${search_space_id}/new-chat`, request.url);
+
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.cookies.set(OAUTH_RESULT_COOKIE, result, {
path: "/",
maxAge: 60,
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index 3594e15eb..f048376cc 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -106,7 +106,7 @@ import {
extractUserTurnForNewChatApi,
type NewChatUserImagePayload,
} from "@/lib/chat/user-turn-api-parts";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
import { NotFoundError } from "@/lib/error";
import {
trackChatBlocked,
@@ -613,18 +613,6 @@ export default function NewChatPage() {
return;
}
- if (normalized.channel === "inline") {
- if (normalized.assistantMessage) {
- await persistAssistantErrorMessage({
- threadId,
- assistantMsgId,
- text: normalized.assistantMessage,
- });
- }
- toast.error(normalized.userMessage);
- return;
- }
-
toast.error(normalized.userMessage);
},
[currentUser?.id, persistAssistantErrorMessage, searchSpaceId, setPremiumAlertForThread]
@@ -919,9 +907,10 @@ export default function NewChatPage() {
if (threadId) {
const token = getBearerToken();
if (token) {
+ const backendUrl = BACKEND_URL;
try {
const response = await fetch(
- buildBackendUrl(`/api/v1/threads/${threadId}/cancel-active-turn`),
+ `${backendUrl}/api/v1/threads/${threadId}/cancel-active-turn`,
{
method: "POST",
headers: {
@@ -1109,6 +1098,7 @@ export default function NewChatPage() {
let streamBatcher: FrameBatchedUpdater | null = null;
try {
+ const backendUrl = BACKEND_URL;
const selection = await getAgentFilesystemSelection(searchSpaceId, {
localFilesystemEnabled,
});
@@ -1145,7 +1135,7 @@ export default function NewChatPage() {
}
const response = await fetchWithTurnCancellingRetry(() =>
- fetch(buildBackendUrl("/api/v1/new_chat"), {
+ fetch(`${backendUrl}/api/v1/new_chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -1640,11 +1630,12 @@ export default function NewChatPage() {
}
try {
+ const backendUrl = BACKEND_URL;
const selection = await getAgentFilesystemSelection(searchSpaceId, {
localFilesystemEnabled,
});
const response = await fetchWithTurnCancellingRetry(() =>
- fetch(buildBackendUrl(`/api/v1/threads/${resumeThreadId}/resume`), {
+ fetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, {
method: "POST",
headers: {
"Content-Type": "application/json",
diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
index 8efe81cce..de5c961e8 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
@@ -2,89 +2,193 @@
import { useAtomValue } from "jotai";
import { useParams, useRouter } from "next/navigation";
-import { useEffect, useMemo } from "react";
+import { useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
import {
- globalLlmConfigStatusAtom,
- globalModelConnectionsAtom,
- modelConnectionsAtom,
- modelRolesAtom,
-} from "@/atoms/model-connections/model-connections-query.atoms";
+ createNewLLMConfigMutationAtom,
+ updateLLMPreferencesMutationAtom,
+} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
+import {
+ globalNewLLMConfigsAtom,
+ llmPreferencesAtom,
+} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { Logo } from "@/components/Logo";
-import { ModelProviderConnectionsPanel } from "@/components/settings/model-connections/model-provider-connections-panel";
+import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
-import { hasEnabledChatModel, isLlmOnboardingComplete } from "@/lib/onboarding";
+import { isLlmOnboardingComplete } from "@/lib/onboarding";
export default function OnboardPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
- const { data: globalConnections = [], isLoading: globalLoading } = useAtomValue(
- globalModelConnectionsAtom
- );
- const { data: connections = [] } = useAtomValue(modelConnectionsAtom);
- const { data: roles = {}, isLoading: rolesLoading } = useAtomValue(modelRolesAtom);
- const { data: globalConfigStatus, isLoading: globalConfigStatusLoading } =
- useAtomValue(globalLlmConfigStatusAtom);
+ // Queries
+ const {
+ data: globalConfigs = [],
+ isFetching: globalConfigsLoading,
+ isSuccess: globalConfigsLoaded,
+ } = useAtomValue(globalNewLLMConfigsAtom);
+ const { data: preferences = {}, isFetching: preferencesLoading } =
+ useAtomValue(llmPreferencesAtom);
+ // Mutations
+ const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue(
+ createNewLLMConfigMutationAtom
+ );
+ const { mutateAsync: updatePreferences, isPending: isUpdatingPreferences } = useAtomValue(
+ updateLLMPreferencesMutationAtom
+ );
+
+ // State
+ const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
+ const hasAttemptedAutoConfig = useRef(false);
+
+ // Check authentication
useEffect(() => {
- if (!getBearerToken()) redirectToLogin();
+ const token = getBearerToken();
+ if (!token) {
+ redirectToLogin();
+ }
}, []);
- const hasUsableChatModel = useMemo(
- () => hasEnabledChatModel([...globalConnections, ...connections]),
- [globalConnections, connections]
+ const isOnboardingComplete = isLlmOnboardingComplete(
+ preferences.agent_llm_id,
+ globalConfigs.length > 0
);
- const onboardingComplete = isLlmOnboardingComplete(
- roles.chat_model_id,
- globalConnections,
- connections
- );
-
- const isLoading = globalLoading || rolesLoading || globalConfigStatusLoading;
-
- // Onboarding only applies when no global_llm_config.yaml exists. If a global
- // config is present (or onboarding is already complete), leave this page.
- const shouldLeaveOnboarding =
- !isLoading && (Boolean(globalConfigStatus?.exists) || onboardingComplete);
-
useEffect(() => {
- if (shouldLeaveOnboarding) {
- router.replace(`/dashboard/${searchSpaceId}/new-chat`);
+ if (!preferencesLoading && globalConfigsLoaded && isOnboardingComplete) {
+ router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
- }, [shouldLeaveOnboarding, router, searchSpaceId]);
+ }, [preferencesLoading, globalConfigsLoaded, isOnboardingComplete, router, searchSpaceId]);
- useGlobalLoadingEffect(isLoading || shouldLeaveOnboarding);
+ useEffect(() => {
+ const autoConfigureWithGlobal = async () => {
+ if (hasAttemptedAutoConfig.current) return;
+ if (globalConfigsLoading || preferencesLoading) return;
+ if (!globalConfigsLoaded) return;
+ if (isOnboardingComplete) return;
- if (isLoading || shouldLeaveOnboarding) return null;
+ if (globalConfigs.length > 0) {
+ hasAttemptedAutoConfig.current = true;
+ setIsAutoConfiguring(true);
+
+ try {
+ const firstGlobalConfig = globalConfigs[0];
+
+ await updatePreferences({
+ search_space_id: searchSpaceId,
+ data: {
+ agent_llm_id: firstGlobalConfig.id,
+ },
+ });
+
+ toast.success("AI configured automatically!", {
+ description: `Using ${firstGlobalConfig.name}. You can customize this later in Settings.`,
+ });
+
+ router.push(`/dashboard/${searchSpaceId}/new-chat`);
+ } catch (error) {
+ console.error("Auto-configuration failed:", error);
+ toast.error("Auto-configuration failed. Please add a configuration manually.");
+ setIsAutoConfiguring(false);
+ }
+ }
+ };
+
+ autoConfigureWithGlobal();
+ }, [
+ globalConfigs,
+ globalConfigsLoading,
+ globalConfigsLoaded,
+ preferencesLoading,
+ isOnboardingComplete,
+ updatePreferences,
+ searchSpaceId,
+ router,
+ ]);
+
+ const handleSubmit = async (formData: LLMConfigFormData) => {
+ try {
+ const newConfig = await createConfig(formData);
+
+ await updatePreferences({
+ search_space_id: searchSpaceId,
+ data: {
+ agent_llm_id: newConfig.id,
+ },
+ });
+
+ toast.success("Configuration created!", {
+ description: "Redirecting to chat...",
+ });
+
+ router.push(`/dashboard/${searchSpaceId}/new-chat`);
+ } catch (error) {
+ console.error("Failed to create config:", error);
+ if (error instanceof Error) {
+ toast.error(error.message || "Failed to create configuration");
+ }
+ }
+ };
+
+ const isSubmitting = isCreating || isUpdatingPreferences;
+
+ const isLoading = globalConfigsLoading || preferencesLoading || isAutoConfiguring;
+ useGlobalLoadingEffect(isLoading);
+
+ if (isLoading) {
+ return null;
+ }
+
+ if (globalConfigs.length > 0 && !isAutoConfiguring) {
+ return null;
+ }
return (
-
-
-
-
-
Choose a model
-
- Connect any supported provider, then enable the models you want SurfSense to use.
-
+
+
+ {/* Header */}
+
+
+
+
Configure Your AI
+
+ Add your LLM provider to get started with SurfSense
+
+
+
+
+ {/* Form card */}
+
+
+
+
+ {/* Footer */}
+
+
+
You can add more configurations later
-
router.push(`/dashboard/${searchSpaceId}/new-chat`)}
- >
- Start
-
- }
- showAddProviderHeader={false}
- />
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/image-models/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/image-models/page.tsx
new file mode 100644
index 000000000..b300f8078
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/image-models/page.tsx
@@ -0,0 +1,6 @@
+import { ImageModelManager } from "@/components/settings/image-model-manager";
+
+export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
+ const { search_space_id } = await params;
+ return
;
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx
index bb928f8f7..22f68edab 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx
@@ -1,6 +1,15 @@
"use client";
-import { BookText, Cpu, Earth, Settings, UserKey } from "lucide-react";
+import {
+ BookText,
+ Bot,
+ CircleUser,
+ Earth,
+ ImageIcon,
+ ListChecks,
+ ScanEye,
+ UserKey,
+} from "lucide-react";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -11,7 +20,10 @@ import { cn } from "@/lib/utils";
export type SearchSpaceSettingsTab =
| "general"
+ | "roles"
| "models"
+ | "image-models"
+ | "vision-models"
| "team-roles"
| "prompts"
| "public-links";
@@ -43,12 +55,27 @@ export function SearchSpaceSettingsLayoutShell({
{
value: "general" as const,
label: t("nav_general"),
- icon:
,
+ icon:
,
+ },
+ {
+ value: "roles" as const,
+ label: t("nav_role_assignments"),
+ icon:
,
},
{
value: "models" as const,
- label: t("nav_models"),
- icon:
,
+ label: t("nav_agent_models"),
+ icon:
,
+ },
+ {
+ value: "image-models" as const,
+ label: t("nav_image_models"),
+ icon:
,
+ },
+ {
+ value: "vision-models" as const,
+ label: t("nav_vision_models"),
+ icon:
,
},
{
value: "team-roles" as const,
diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx
index c97ef7630..d68194782 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx
@@ -1,6 +1,6 @@
-import { ModelConnectionsSettings } from "@/components/settings/model-connections-settings";
+import { AgentModelManager } from "@/components/settings/agent-model-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
- return
;
+ return
;
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/roles/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/roles/page.tsx
new file mode 100644
index 000000000..5bad50cd3
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/roles/page.tsx
@@ -0,0 +1,6 @@
+import { LLMRoleManager } from "@/components/settings/llm-role-manager";
+
+export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
+ const { search_space_id } = await params;
+ return
;
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/vision-models/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/vision-models/page.tsx
new file mode 100644
index 000000000..06aea003a
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/vision-models/page.tsx
@@ -0,0 +1,6 @@
+import { VisionModelManager } from "@/components/settings/vision-model-manager";
+
+export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
+ const { search_space_id } = await params;
+ return
;
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx
index 4a3c5e9e7..b0cb6699c 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx
@@ -1,11 +1,10 @@
"use client";
-import { AlertTriangle, RefreshCw, ShieldAlert } from "lucide-react";
+import { RefreshCw, ShieldAlert } from "lucide-react";
import { useParams } from "next/navigation";
import { QRCodeSVG } from "qrcode.react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -20,7 +19,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import type { SearchSpace } from "@/contracts/types/search-space.types";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
import { cn } from "@/lib/utils";
type GatewayConnection = {
@@ -40,7 +39,6 @@ type GatewayConnection = {
};
type GatewayConfig = {
- enabled: boolean;
telegram_enabled: boolean;
whatsapp_intake_mode: "disabled" | "cloud" | "baileys";
slack_enabled: boolean;
@@ -49,14 +47,6 @@ type GatewayConfig = {
type GatewayConfigState = GatewayConfig | null;
-const DISABLED_GATEWAY_CONFIG: GatewayConfig = {
- enabled: false,
- telegram_enabled: false,
- whatsapp_intake_mode: "disabled",
- slack_enabled: false,
- discord_enabled: false,
-};
-
type Pairing = {
binding_id: number;
code: string;
@@ -90,26 +80,16 @@ export function MessagingChannelsContent() {
const whatsappMode = gatewayConfig?.whatsapp_intake_mode ?? "disabled";
const slackGatewayEnabled = gatewayConfig?.slack_enabled ?? false;
const discordGatewayEnabled = gatewayConfig?.discord_enabled ?? false;
- const gatewayDisabled = gatewayConfig?.enabled === false;
const fetchConnections = useCallback(async (platform?: GatewayPlatform) => {
- const res = await authenticatedFetch(
- buildBackendUrl("/api/v1/gateway/connections", platform ? { platform } : undefined)
- );
- if (!res.ok) return [];
- const data = await res.json();
- return Array.isArray(data) ? (data as GatewayConnection[]) : [];
+ const query = platform ? `?platform=${encodeURIComponent(platform)}` : "";
+ const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/connections${query}`);
+ return (await res.json()) as GatewayConnection[];
}, []);
- const fetchGatewayConfig = useCallback(async (): Promise
=> {
- const res = await authenticatedFetch(buildBackendUrl("/api/v1/gateway/config"));
- if (!res.ok) return DISABLED_GATEWAY_CONFIG;
- const data = (await res.json()) as Partial;
- return {
- ...DISABLED_GATEWAY_CONFIG,
- ...data,
- enabled: data.enabled ?? true,
- };
+ const fetchGatewayConfig = useCallback(async () => {
+ const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/config`);
+ return (await res.json()) as GatewayConfig;
}, []);
const refresh = useCallback(async () => {
@@ -145,9 +125,7 @@ export function MessagingChannelsContent() {
const refreshBaileysHealth = useCallback(async () => {
if (whatsappMode !== "baileys") return;
- const res = await authenticatedFetch(
- buildBackendUrl("/api/v1/gateway/whatsapp/baileys/health")
- );
+ const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/whatsapp/baileys/health`);
if (!res.ok) return;
const data = (await res.json()) as BaileysHealth;
setBaileysHealth(data);
@@ -158,7 +136,7 @@ export function MessagingChannelsContent() {
}, [refreshBaileysHealth]);
async function startPairing(platform: PairingPlatform) {
- const res = await authenticatedFetch(buildBackendUrl("/api/v1/gateway/bindings/start"), {
+ const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform, search_space_id: searchSpaceId }),
@@ -170,7 +148,7 @@ export function MessagingChannelsContent() {
async function installSlackGateway() {
const res = await authenticatedFetch(
- buildBackendUrl("/api/v1/gateway/slack/install", { search_space_id: searchSpaceId })
+ `${BACKEND_URL}/api/v1/gateway/slack/install?search_space_id=${searchSpaceId}`
);
if (!res.ok) return;
const data = (await res.json()) as { auth_url?: string };
@@ -181,7 +159,7 @@ export function MessagingChannelsContent() {
async function installDiscordGateway() {
const res = await authenticatedFetch(
- buildBackendUrl("/api/v1/gateway/discord/install", { search_space_id: searchSpaceId })
+ `${BACKEND_URL}/api/v1/gateway/discord/install?search_space_id=${searchSpaceId}`
);
if (!res.ok) return;
const data = (await res.json()) as { auth_url?: string };
@@ -203,8 +181,8 @@ export function MessagingChannelsContent() {
async function revoke(connection: GatewayConnection) {
const url =
connection.route_type === "account" && connection.account_id
- ? buildBackendUrl(`/api/v1/gateway/accounts/${connection.account_id}`)
- : buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}`);
+ ? `${BACKEND_URL}/api/v1/gateway/accounts/${connection.account_id}`
+ : `${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}`;
await authenticatedFetch(url, {
method: "DELETE",
});
@@ -227,8 +205,8 @@ export function MessagingChannelsContent() {
);
const url =
connection.route_type === "account" && connection.account_id
- ? buildBackendUrl(`/api/v1/gateway/accounts/${connection.account_id}/search-space`)
- : buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}/search-space`);
+ ? `${BACKEND_URL}/api/v1/gateway/accounts/${connection.account_id}/search-space`
+ : `${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}/search-space`;
const res = await authenticatedFetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -244,7 +222,7 @@ export function MessagingChannelsContent() {
}
async function resume(connection: GatewayConnection) {
- await authenticatedFetch(buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}/resume`), {
+ await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}/resume`, {
method: "POST",
});
await refreshPlatform(connection.platform as GatewayPlatform);
@@ -403,21 +381,7 @@ export function MessagingChannelsContent() {
{isGatewayConfigLoading ? renderGatewaySkeletons() : null}
- {!isGatewayConfigLoading && gatewayDisabled ? (
-
-
- Messaging Channels coming soon
-
-
- Soon you'll be able to connect WhatsApp, Telegram, Slack, and Discord to your
- SurfSense agent so you can ask questions, route messages to search spaces, and get
- answers from your knowledge base without leaving your chat app.
-
-
-
- ) : null}
-
- {!isGatewayConfigLoading && !gatewayDisabled && !hasEnabledGateway ? (
+ {!isGatewayConfigLoading && !hasEnabledGateway ? (
No messaging gateways enabled
@@ -425,7 +389,7 @@ export function MessagingChannelsContent() {
) : null}
- {!gatewayDisabled && telegramGatewayEnabled ? (
+ {telegramGatewayEnabled ? (
@@ -461,7 +425,7 @@ export function MessagingChannelsContent() {
) : null}
- {!gatewayDisabled && slackGatewayEnabled ? (
+ {slackGatewayEnabled ? (
@@ -493,7 +457,7 @@ export function MessagingChannelsContent() {
) : null}
- {!gatewayDisabled && discordGatewayEnabled ? (
+ {discordGatewayEnabled ? (
@@ -525,7 +489,7 @@ export function MessagingChannelsContent() {
) : null}
- {!gatewayDisabled && whatsappMode !== "disabled" ? (
+ {whatsappMode !== "disabled" ? (
diff --git a/surfsense_web/app/dashboard/dashboard-shell.tsx b/surfsense_web/app/dashboard/dashboard-shell.tsx
deleted file mode 100644
index f84cd56eb..000000000
--- a/surfsense_web/app/dashboard/dashboard-shell.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
-import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
-import { ensureTokensFromElectron, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
-import { queryClient } from "@/lib/query-client/client";
-
-export function DashboardShell({ children }: { children: React.ReactNode }) {
- const [isCheckingAuth, setIsCheckingAuth] = useState(true);
-
- // Use the global loading screen - spinner animation won't reset
- useGlobalLoadingEffect(isCheckingAuth);
-
- useEffect(() => {
- async function checkAuth() {
- let token = getBearerToken();
- if (!token) {
- const synced = await ensureTokensFromElectron();
- if (synced) token = getBearerToken();
- }
- if (!token) {
- redirectToLogin();
- return;
- }
- queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
- setIsCheckingAuth(false);
- }
- checkAuth();
- }, []);
-
- // Return null while loading - the global provider handles the loading UI
- if (isCheckingAuth) {
- return null;
- }
-
- return (
-
- );
-}
diff --git a/surfsense_web/app/dashboard/layout.tsx b/surfsense_web/app/dashboard/layout.tsx
index 6212c92e7..1f5481b15 100644
--- a/surfsense_web/app/dashboard/layout.tsx
+++ b/surfsense_web/app/dashboard/layout.tsx
@@ -1,14 +1,46 @@
-import { RuntimeConfig } from "@/components/providers/runtime-config.server";
-import { DashboardShell } from "./dashboard-shell";
+"use client";
+
+import { useEffect, useState } from "react";
+import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
+import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
+import { ensureTokensFromElectron, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
+import { queryClient } from "@/lib/query-client/client";
interface DashboardLayoutProps {
children: React.ReactNode;
}
export default function DashboardLayout({ children }: DashboardLayoutProps) {
+ const [isCheckingAuth, setIsCheckingAuth] = useState(true);
+
+ // Use the global loading screen - spinner animation won't reset
+ useGlobalLoadingEffect(isCheckingAuth);
+
+ useEffect(() => {
+ async function checkAuth() {
+ let token = getBearerToken();
+ if (!token) {
+ const synced = await ensureTokensFromElectron();
+ if (synced) token = getBearerToken();
+ }
+ if (!token) {
+ redirectToLogin();
+ return;
+ }
+ queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
+ setIsCheckingAuth(false);
+ }
+ checkAuth();
+ }, []);
+
+ // Return null while loading - the global provider handles the loading UI
+ if (isCheckingAuth) {
+ return null;
+ }
+
return (
-
- {children}
-
+
);
}
diff --git a/surfsense_web/app/desktop/login/layout.tsx b/surfsense_web/app/desktop/login/layout.tsx
deleted file mode 100644
index 83556d314..000000000
--- a/surfsense_web/app/desktop/login/layout.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { RuntimeConfig } from "@/components/providers/runtime-config.server";
-
-export default function DesktopLoginLayout({ children }: { children: React.ReactNode }) {
- return
{children};
-}
diff --git a/surfsense_web/app/desktop/login/page.tsx b/surfsense_web/app/desktop/login/page.tsx
index 0d91588e1..41c956f3e 100644
--- a/surfsense_web/app/desktop/login/page.tsx
+++ b/surfsense_web/app/desktop/login/page.tsx
@@ -8,7 +8,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
-import { useIsGoogleAuth } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -18,8 +17,9 @@ import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { setBearerToken } from "@/lib/auth-utils";
-import { buildBackendUrl } from "@/lib/env-config";
+import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
+const isGoogleAuth = AUTH_TYPE === "GOOGLE";
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
@@ -189,7 +189,6 @@ function HotkeyRow({
export default function DesktopLoginPage() {
const router = useRouter();
const api = useElectronAPI();
- const isGoogleAuth = useIsGoogleAuth();
const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom);
const [email, setEmail] = useState("");
@@ -240,7 +239,7 @@ export default function DesktopLoginPage() {
const handleGoogleLogin = () => {
if (isGoogleRedirecting) return;
setIsGoogleRedirecting(true);
- window.location.href = buildBackendUrl("/auth/google/authorize-redirect");
+ window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
};
const autoSetSearchSpace = async () => {
diff --git a/surfsense_web/app/docs/layout.tsx b/surfsense_web/app/docs/layout.tsx
index cc5f2118a..9311a45b4 100644
--- a/surfsense_web/app/docs/layout.tsx
+++ b/surfsense_web/app/docs/layout.tsx
@@ -8,15 +8,12 @@ const gridTemplate = `"sidebar header toc"
"sidebar toc-popover toc"
"sidebar main toc" 1fr / var(--fd-sidebar-col) minmax(0, 1fr) min-content`;
-const docsSurfaceClass =
- "bg-main-panel [--color-fd-background:var(--main-panel)] [--color-fd-card:var(--main-panel)] [--color-fd-popover:var(--main-panel)] [--color-fd-muted:var(--main-panel)] [--color-fd-secondary:var(--main-panel)]";
-
export default function Layout({ children }: { children: ReactNode }) {
return (
{
try {
- const res = await fetch(`${SERVER_BACKEND_URL}/api/v1/public/anon-chat/models`, {
+ const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/models`, {
next: { revalidate: 3600 },
});
if (!res.ok) return [];
diff --git a/surfsense_web/app/verify-token/route.ts b/surfsense_web/app/verify-token/route.ts
index 9df460779..b7ed762de 100644
--- a/surfsense_web/app/verify-token/route.ts
+++ b/surfsense_web/app/verify-token/route.ts
@@ -1,16 +1,12 @@
import { type NextRequest, NextResponse } from "next/server";
-function getBackendBaseUrl() {
- const base =
- process.env.SURFSENSE_BACKEND_INTERNAL_URL ||
- // TODO: Remove FASTAPI_BACKEND_INTERNAL_URL after the post-Caddy env migration window.
- process.env.FASTAPI_BACKEND_INTERNAL_URL ||
- "http://backend:8000";
- return base.replace(/\/+$/, "");
-}
+const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace(
+ /\/+$/,
+ ""
+);
export async function GET(request: NextRequest) {
- const response = await fetch(`${getBackendBaseUrl()}/verify-token`, {
+ const response = await fetch(`${backendBaseUrl}/verify-token`, {
method: "GET",
headers: {
Authorization: request.headers.get("authorization") || "",
diff --git a/surfsense_web/atoms/automations/automations-mutation.atoms.ts b/surfsense_web/atoms/automations/automations-mutation.atoms.ts
index 288d97c63..a81cd1578 100644
--- a/surfsense_web/atoms/automations/automations-mutation.atoms.ts
+++ b/surfsense_web/atoms/automations/automations-mutation.atoms.ts
@@ -57,9 +57,9 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({
task_count: variables.definition.plan.length,
trigger_type: variables.triggers?.[0]?.type ?? "none",
has_schedule: (variables.triggers?.length ?? 0) > 0,
- chat_model_id: variables.definition.models?.chat_model_id,
- image_gen_model_id: variables.definition.models?.image_gen_model_id,
- vision_model_id: variables.definition.models?.vision_model_id,
+ agent_llm_id: variables.definition.models?.agent_llm_id,
+ image_generation_config_id: variables.definition.models?.image_generation_config_id,
+ vision_llm_config_id: variables.definition.models?.vision_llm_config_id,
tags_count: variables.definition.metadata?.tags?.length,
});
},
diff --git a/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts b/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts
new file mode 100644
index 000000000..922c398c9
--- /dev/null
+++ b/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts
@@ -0,0 +1,96 @@
+import { atomWithMutation } from "jotai-tanstack-query";
+import { toast } from "sonner";
+import type {
+ CreateImageGenConfigRequest,
+ CreateImageGenConfigResponse,
+ DeleteImageGenConfigResponse,
+ GetImageGenConfigsResponse,
+ UpdateImageGenConfigRequest,
+ UpdateImageGenConfigResponse,
+} from "@/contracts/types/new-llm-config.types";
+import { imageGenConfigApiService } from "@/lib/apis/image-gen-config-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+import { queryClient } from "@/lib/query-client/client";
+import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
+
+/**
+ * Mutation atom for creating a new ImageGenerationConfig
+ */
+export const createImageGenConfigMutationAtom = atomWithMutation((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ mutationKey: ["image-gen-configs", "create"],
+ meta: { suppressGlobalErrorToast: true },
+ enabled: !!searchSpaceId,
+ mutationFn: async (request: CreateImageGenConfigRequest) => {
+ return imageGenConfigApiService.createConfig(request);
+ },
+ onSuccess: (_: CreateImageGenConfigResponse, request: CreateImageGenConfigRequest) => {
+ toast.success(`${request.name} created`);
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
+ });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to create image model");
+ },
+ };
+});
+
+/**
+ * Mutation atom for updating an existing ImageGenerationConfig
+ */
+export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ mutationKey: ["image-gen-configs", "update"],
+ meta: { suppressGlobalErrorToast: true },
+ enabled: !!searchSpaceId,
+ mutationFn: async (request: UpdateImageGenConfigRequest) => {
+ return imageGenConfigApiService.updateConfig(request);
+ },
+ onSuccess: (_: UpdateImageGenConfigResponse, request: UpdateImageGenConfigRequest) => {
+ toast.success(`${request.data.name ?? "Configuration"} updated`);
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
+ });
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.imageGenConfigs.byId(request.id),
+ });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update image model");
+ },
+ };
+});
+
+/**
+ * Mutation atom for deleting an ImageGenerationConfig
+ */
+export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ mutationKey: ["image-gen-configs", "delete"],
+ meta: { suppressGlobalErrorToast: true },
+ enabled: !!searchSpaceId,
+ mutationFn: async (request: { id: number; name: string }) => {
+ return imageGenConfigApiService.deleteConfig(request.id);
+ },
+ onSuccess: (_: DeleteImageGenConfigResponse, request: { id: number; name: string }) => {
+ toast.success(`${request.name} deleted`);
+ queryClient.setQueryData(
+ cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
+ (oldData: GetImageGenConfigsResponse | undefined) => {
+ if (!oldData) return oldData;
+ return oldData.filter((config) => config.id !== request.id);
+ }
+ );
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to delete image model");
+ },
+ };
+});
diff --git a/surfsense_web/atoms/image-gen-config/image-gen-config-query.atoms.ts b/surfsense_web/atoms/image-gen-config/image-gen-config-query.atoms.ts
new file mode 100644
index 000000000..a45e69a03
--- /dev/null
+++ b/surfsense_web/atoms/image-gen-config/image-gen-config-query.atoms.ts
@@ -0,0 +1,33 @@
+import { atomWithQuery } from "jotai-tanstack-query";
+import { imageGenConfigApiService } from "@/lib/apis/image-gen-config-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
+
+/**
+ * Query atom for fetching user-created image gen configs for the active search space
+ */
+export const imageGenConfigsAtom = atomWithQuery((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
+ enabled: !!searchSpaceId,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ queryFn: async () => {
+ return imageGenConfigApiService.getConfigs(Number(searchSpaceId));
+ },
+ };
+});
+
+/**
+ * Query atom for fetching global image gen configs (from YAML, negative IDs)
+ */
+export const globalImageGenConfigsAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.imageGenConfigs.global(),
+ staleTime: 10 * 60 * 1000, // 10 minutes - global configs rarely change
+ queryFn: async () => {
+ return imageGenConfigApiService.getGlobalConfigs();
+ },
+ };
+});
diff --git a/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts b/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts
deleted file mode 100644
index f00bf76f9..000000000
--- a/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-import { atomWithMutation } from "jotai-tanstack-query";
-import { toast } from "sonner";
-import type {
- ConnectionCreateRequest,
- ConnectionRead,
- ConnectionUpdateRequest,
- ModelCreateRequest,
- ModelPreviewRead,
- ModelRead,
- ModelRoles,
- ModelsBulkUpdateRequest,
- ModelTestPreviewRequest,
- ModelUpdateRequest,
- VerifyConnectionResponse,
-} from "@/contracts/types/model-connections.types";
-import { modelConnectionsApiService } from "@/lib/apis/model-connections-api.service";
-import { cacheKeys } from "@/lib/query-client/cache-keys";
-import { queryClient } from "@/lib/query-client/client";
-import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
-
-function invalidateModelConnections(searchSpaceId: number) {
- queryClient.invalidateQueries({
- queryKey: cacheKeys.modelConnections.all(searchSpaceId),
- });
- queryClient.invalidateQueries({
- queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
- });
-}
-
-function upsertModelConnection(searchSpaceId: number, connection: ConnectionRead) {
- queryClient.setQueryData(
- cacheKeys.modelConnections.all(searchSpaceId),
- (current = []) => {
- if (current.some((item) => item.id === connection.id)) {
- return current.map((item) => (item.id === connection.id ? connection : item));
- }
- return [...current, connection];
- }
- );
-}
-
-export const createModelConnectionMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["model-connections", "create"],
- mutationFn: (request: ConnectionCreateRequest) =>
- modelConnectionsApiService.createConnection(request),
- onSuccess: (connection: ConnectionRead, request: ConnectionCreateRequest) => {
- const resolvedSearchSpaceId = Number(
- request.search_space_id ?? connection.search_space_id ?? searchSpaceId
- );
- toast.success("Connection created");
- if (resolvedSearchSpaceId > 0) {
- upsertModelConnection(resolvedSearchSpaceId, connection);
- invalidateModelConnections(resolvedSearchSpaceId);
- }
- },
- onError: (error: Error) => toast.error(error.message || "Failed to create connection"),
- };
-});
-
-export const updateModelConnectionMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["model-connections", "update"],
- mutationFn: ({ id, data }: { id: number; data: ConnectionUpdateRequest }) =>
- modelConnectionsApiService.updateConnection(id, data),
- onSuccess: () => {
- toast.success("Connection updated");
- invalidateModelConnections(searchSpaceId);
- },
- onError: (error: Error) => toast.error(error.message || "Failed to update connection"),
- };
-});
-
-export const deleteModelConnectionMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["model-connections", "delete"],
- mutationFn: (id: number) => modelConnectionsApiService.deleteConnection(id),
- onSuccess: () => {
- toast.success("Connection deleted");
- invalidateModelConnections(searchSpaceId);
- },
- onError: (error: Error) => toast.error(error.message || "Failed to delete connection"),
- };
-});
-
-export const verifyModelConnectionMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["model-connections", "verify"],
- mutationFn: (id: number) => modelConnectionsApiService.verifyConnection(id),
- onSuccess: (result: VerifyConnectionResponse) => {
- if (result.ok) {
- toast.success("Connection verified");
- } else {
- // Non-fatal: many providers lack a /models endpoint yet still serve
- // chat. Guide the user to add model IDs manually instead of alarming.
- toast.warning(
- result.message
- ? `${result.message} Chat may still work — add model IDs manually.`
- : "Couldn't list models. Chat may still work — add model IDs manually."
- );
- }
- invalidateModelConnections(searchSpaceId);
- },
- onError: (error: Error) => toast.error(error.message || "Failed to verify connection"),
- };
-});
-
-export const discoverConnectionModelsMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["model-connections", "discover"],
- mutationFn: (id: number) => modelConnectionsApiService.discoverModels(id),
- onSuccess: (models: ModelRead[]) => {
- toast.success(
- models.length ? `${models.length} models discovered` : "No models found for this connection"
- );
- invalidateModelConnections(searchSpaceId);
- },
- onError: (error: Error) => toast.error(error.message || "Failed to discover models"),
- };
-});
-
-export const previewConnectionModelsMutationAtom = atomWithMutation(() => {
- return {
- mutationKey: ["model-connections", "discover-preview"],
- mutationFn: (request: ConnectionCreateRequest) =>
- modelConnectionsApiService.previewModels(request),
- onSuccess: (_models: ModelPreviewRead[]) => {},
- onError: (error: Error) => toast.error(error.message || "Failed to discover models"),
- };
-});
-
-export const testPreviewModelMutationAtom = atomWithMutation(() => {
- return {
- mutationKey: ["model-connections", "test-preview"],
- mutationFn: (request: ModelTestPreviewRequest) =>
- modelConnectionsApiService.testPreviewModel(request),
- onSuccess: (result: VerifyConnectionResponse) => {
- if (!result.ok) {
- toast.error(result.message || "Model test failed");
- }
- },
- onError: (error: Error) => toast.error(error.message || "Failed to test model"),
- };
-});
-
-export const addManualModelMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["models", "add-manual"],
- mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelCreateRequest }) =>
- modelConnectionsApiService.addManualModel(connectionId, data),
- onSuccess: () => {
- toast.success("Model added");
- invalidateModelConnections(searchSpaceId);
- },
- onError: (error: Error) => toast.error(error.message || "Failed to add model"),
- };
-});
-
-export const updateModelMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["models", "update"],
- mutationFn: ({ id, data }: { id: number; data: ModelUpdateRequest }) =>
- modelConnectionsApiService.updateModel(id, data),
- onSuccess: () => invalidateModelConnections(searchSpaceId),
- onError: (error: Error) => toast.error(error.message || "Failed to update model"),
- };
-});
-
-export const bulkUpdateModelsMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["models", "bulk-update"],
- mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelsBulkUpdateRequest }) =>
- modelConnectionsApiService.bulkUpdateModels(connectionId, data),
- onSuccess: () => invalidateModelConnections(searchSpaceId),
- onError: (error: Error) => toast.error(error.message || "Failed to update models"),
- };
-});
-
-export const testModelMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["models", "test"],
- mutationFn: (id: number) => modelConnectionsApiService.testModel(id),
- onSuccess: (result: VerifyConnectionResponse) => {
- if (result.ok) toast.success("Model test succeeded");
- else toast.error(result.message || "Model test failed");
- invalidateModelConnections(searchSpaceId);
- },
- onError: (error: Error) => toast.error(error.message || "Failed to test model"),
- };
-});
-
-export const updateModelRolesMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["model-roles", "update"],
- mutationFn: (roles: ModelRoles) =>
- modelConnectionsApiService.updateModelRoles(searchSpaceId, roles),
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
- });
- },
- onError: (error: Error) => toast.error(error.message || "Failed to update model roles"),
- };
-});
diff --git a/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts b/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts
deleted file mode 100644
index 04dad9b21..000000000
--- a/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { atomWithQuery } from "jotai-tanstack-query";
-import { modelConnectionsApiService } from "@/lib/apis/model-connections-api.service";
-import { getBearerToken } from "@/lib/auth-utils";
-import { cacheKeys } from "@/lib/query-client/cache-keys";
-import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
-
-export const globalModelConnectionsAtom = atomWithQuery(() => ({
- queryKey: cacheKeys.modelConnections.global(),
- enabled: !!getBearerToken(),
- staleTime: 10 * 60 * 1000,
- queryFn: () => modelConnectionsApiService.getGlobalConnections(),
-}));
-
-export const globalLlmConfigStatusAtom = atomWithQuery(() => ({
- queryKey: cacheKeys.modelConnections.globalConfigStatus(),
- enabled: !!getBearerToken(),
- staleTime: 60 * 60 * 1000,
- queryFn: () => modelConnectionsApiService.getGlobalLlmConfigStatus(),
-}));
-
-export const modelProvidersAtom = atomWithQuery(() => ({
- queryKey: cacheKeys.modelConnections.providers(),
- enabled: !!getBearerToken(),
- staleTime: 60 * 60 * 1000,
- queryFn: () => modelConnectionsApiService.getModelProviders(),
-}));
-
-export const modelConnectionsAtom = atomWithQuery((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- queryKey: cacheKeys.modelConnections.all(searchSpaceId),
- enabled: !!searchSpaceId,
- staleTime: 5 * 60 * 1000,
- queryFn: () => modelConnectionsApiService.getConnections(searchSpaceId),
- };
-});
-
-export const modelRolesAtom = atomWithQuery((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
- enabled: !!searchSpaceId,
- staleTime: 5 * 60 * 1000,
- queryFn: () => modelConnectionsApiService.getModelRoles(searchSpaceId),
- };
-});
diff --git a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts
new file mode 100644
index 000000000..476d89d4c
--- /dev/null
+++ b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts
@@ -0,0 +1,132 @@
+import { atomWithMutation } from "jotai-tanstack-query";
+import { toast } from "sonner";
+import type {
+ CreateNewLLMConfigRequest,
+ CreateNewLLMConfigResponse,
+ DeleteNewLLMConfigRequest,
+ DeleteNewLLMConfigResponse,
+ GetNewLLMConfigsResponse,
+ UpdateLLMPreferencesRequest,
+ UpdateNewLLMConfigRequest,
+ UpdateNewLLMConfigResponse,
+} from "@/contracts/types/new-llm-config.types";
+import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+import { queryClient } from "@/lib/query-client/client";
+import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
+
+/**
+ * Mutation atom for creating a new NewLLMConfig
+ */
+export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ mutationKey: ["new-llm-configs", "create"],
+ meta: { suppressGlobalErrorToast: true },
+ enabled: !!searchSpaceId,
+ mutationFn: async (request: CreateNewLLMConfigRequest) => {
+ return newLLMConfigApiService.createConfig(request);
+ },
+ onSuccess: (_: CreateNewLLMConfigResponse, request: CreateNewLLMConfigRequest) => {
+ toast.success(`${request.name} created`);
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
+ });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to create model");
+ },
+ };
+});
+
+/**
+ * Mutation atom for updating an existing NewLLMConfig
+ */
+export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ mutationKey: ["new-llm-configs", "update"],
+ meta: { suppressGlobalErrorToast: true },
+ enabled: !!searchSpaceId,
+ mutationFn: async (request: UpdateNewLLMConfigRequest) => {
+ return newLLMConfigApiService.updateConfig(request);
+ },
+ onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => {
+ toast.success(`${request.data.name ?? "Configuration"} updated`);
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
+ });
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.newLLMConfigs.byId(request.id),
+ });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update");
+ },
+ };
+});
+
+/**
+ * Mutation atom for deleting a NewLLMConfig
+ */
+export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ mutationKey: ["new-llm-configs", "delete"],
+ meta: { suppressGlobalErrorToast: true },
+ enabled: !!searchSpaceId,
+ mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
+ return newLLMConfigApiService.deleteConfig({ id: request.id });
+ },
+ onSuccess: (
+ _: DeleteNewLLMConfigResponse,
+ request: DeleteNewLLMConfigRequest & { name: string }
+ ) => {
+ toast.success(`${request.name} deleted`);
+ queryClient.setQueryData(
+ cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
+ (oldData: GetNewLLMConfigsResponse | undefined) => {
+ if (!oldData) return oldData;
+ return oldData.filter((config) => config.id !== request.id);
+ }
+ );
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to delete");
+ },
+ };
+});
+
+/**
+ * Mutation atom for updating LLM preferences (role assignments)
+ */
+export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ mutationKey: ["llm-preferences", "update"],
+ meta: { suppressGlobalErrorToast: true },
+ enabled: !!searchSpaceId,
+ mutationFn: async (request: UpdateLLMPreferencesRequest) => {
+ return newLLMConfigApiService.updateLLMPreferences(request);
+ },
+ onSuccess: (_data, request: UpdateLLMPreferencesRequest) => {
+ queryClient.setQueryData(
+ cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
+ (old: Record | undefined) => ({ ...old, ...request.data })
+ );
+ // Automation eligibility is derived from these model preferences
+ // (agent/image/vision). Invalidate it so the automations gate alert
+ // reflects the new selection without a manual refresh.
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.automations.modelEligibility(Number(searchSpaceId)),
+ });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update LLM preferences");
+ },
+ };
+});
diff --git a/surfsense_web/atoms/new-llm-config/new-llm-config-query.atoms.ts b/surfsense_web/atoms/new-llm-config/new-llm-config-query.atoms.ts
new file mode 100644
index 000000000..410d061e5
--- /dev/null
+++ b/surfsense_web/atoms/new-llm-config/new-llm-config-query.atoms.ts
@@ -0,0 +1,98 @@
+import { atomWithQuery } from "jotai-tanstack-query";
+import type { LLMModel } from "@/contracts/enums/llm-models";
+import { LLM_MODELS } from "@/contracts/enums/llm-models";
+import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
+import { getBearerToken } from "@/lib/auth-utils";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
+
+/**
+ * Query atom for fetching all NewLLMConfigs for the active search space
+ */
+export const newLLMConfigsAtom = atomWithQuery((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
+ enabled: !!searchSpaceId,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ queryFn: async () => {
+ return newLLMConfigApiService.getConfigs({
+ search_space_id: Number(searchSpaceId),
+ });
+ },
+ };
+});
+
+/**
+ * Query atom for fetching global NewLLMConfigs (from YAML, negative IDs)
+ */
+export const globalNewLLMConfigsAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.newLLMConfigs.global(),
+ staleTime: 10 * 60 * 1000, // 10 minutes - global configs rarely change
+ enabled: !!getBearerToken(),
+ queryFn: async () => {
+ return newLLMConfigApiService.getGlobalConfigs();
+ },
+ };
+});
+
+/**
+ * Query atom for fetching LLM preferences (role assignments) for the active search space
+ */
+export const llmPreferencesAtom = atomWithQuery((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ queryKey: cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
+ enabled: !!searchSpaceId,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ queryFn: async () => {
+ return newLLMConfigApiService.getLLMPreferences(Number(searchSpaceId));
+ },
+ };
+});
+
+/**
+ * Query atom for fetching default system instructions template
+ */
+export const defaultSystemInstructionsAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.newLLMConfigs.defaultInstructions(),
+ staleTime: 60 * 60 * 1000, // 1 hour - this rarely changes
+ queryFn: async () => {
+ return newLLMConfigApiService.getDefaultSystemInstructions();
+ },
+ };
+});
+
+/**
+ * Query atom for the dynamic model catalogue.
+ * Fetched from the backend (which proxies OpenRouter's public API).
+ * Falls back to the static hardcoded list on error.
+ */
+export const modelListAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.newLLMConfigs.modelList(),
+ staleTime: 60 * 60 * 1000, // 1 hour - models don't change often
+ placeholderData: LLM_MODELS,
+ queryFn: async (): Promise => {
+ const data = await newLLMConfigApiService.getModels();
+ const dynamicModels = data.map((m) => ({
+ value: m.value,
+ label: m.label,
+ provider: m.provider,
+ contextWindow: m.context_window ?? undefined,
+ }));
+
+ // Providers covered by the dynamic API (from OpenRouter mapping).
+ // For uncovered providers (Ollama, Groq, Bedrock, etc.) keep the
+ // hand-curated static suggestions so users still see model options.
+ const coveredProviders = new Set(dynamicModels.map((m) => m.provider));
+ const staticFallbacks = LLM_MODELS.filter((m) => !coveredProviders.has(m.provider));
+
+ return [...dynamicModels, ...staticFallbacks];
+ },
+ };
+});
diff --git a/surfsense_web/atoms/vision-llm-config/vision-llm-config-mutation.atoms.ts b/surfsense_web/atoms/vision-llm-config/vision-llm-config-mutation.atoms.ts
new file mode 100644
index 000000000..f46b977d5
--- /dev/null
+++ b/surfsense_web/atoms/vision-llm-config/vision-llm-config-mutation.atoms.ts
@@ -0,0 +1,87 @@
+import { atomWithMutation } from "jotai-tanstack-query";
+import { toast } from "sonner";
+import type {
+ CreateVisionLLMConfigRequest,
+ CreateVisionLLMConfigResponse,
+ DeleteVisionLLMConfigResponse,
+ GetVisionLLMConfigsResponse,
+ UpdateVisionLLMConfigRequest,
+ UpdateVisionLLMConfigResponse,
+} from "@/contracts/types/new-llm-config.types";
+import { visionLLMConfigApiService } from "@/lib/apis/vision-llm-config-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+import { queryClient } from "@/lib/query-client/client";
+import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
+
+export const createVisionLLMConfigMutationAtom = atomWithMutation((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ mutationKey: ["vision-llm-configs", "create"],
+ meta: { suppressGlobalErrorToast: true },
+ enabled: !!searchSpaceId,
+ mutationFn: async (request: CreateVisionLLMConfigRequest) => {
+ return visionLLMConfigApiService.createConfig(request);
+ },
+ onSuccess: (_: CreateVisionLLMConfigResponse, request: CreateVisionLLMConfigRequest) => {
+ toast.success(`${request.name} created`);
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
+ });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to create vision model");
+ },
+ };
+});
+
+export const updateVisionLLMConfigMutationAtom = atomWithMutation((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ mutationKey: ["vision-llm-configs", "update"],
+ meta: { suppressGlobalErrorToast: true },
+ enabled: !!searchSpaceId,
+ mutationFn: async (request: UpdateVisionLLMConfigRequest) => {
+ return visionLLMConfigApiService.updateConfig(request);
+ },
+ onSuccess: (_: UpdateVisionLLMConfigResponse, request: UpdateVisionLLMConfigRequest) => {
+ toast.success(`${request.data.name ?? "Configuration"} updated`);
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
+ });
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.visionLLMConfigs.byId(request.id),
+ });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update vision model");
+ },
+ };
+});
+
+export const deleteVisionLLMConfigMutationAtom = atomWithMutation((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ mutationKey: ["vision-llm-configs", "delete"],
+ meta: { suppressGlobalErrorToast: true },
+ enabled: !!searchSpaceId,
+ mutationFn: async (request: { id: number; name: string }) => {
+ return visionLLMConfigApiService.deleteConfig(request.id);
+ },
+ onSuccess: (_: DeleteVisionLLMConfigResponse, request: { id: number; name: string }) => {
+ toast.success(`${request.name} deleted`);
+ queryClient.setQueryData(
+ cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
+ (oldData: GetVisionLLMConfigsResponse | undefined) => {
+ if (!oldData) return oldData;
+ return oldData.filter((config) => config.id !== request.id);
+ }
+ );
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to delete vision model");
+ },
+ };
+});
diff --git a/surfsense_web/atoms/vision-llm-config/vision-llm-config-query.atoms.ts b/surfsense_web/atoms/vision-llm-config/vision-llm-config-query.atoms.ts
new file mode 100644
index 000000000..906ce638f
--- /dev/null
+++ b/surfsense_web/atoms/vision-llm-config/vision-llm-config-query.atoms.ts
@@ -0,0 +1,51 @@
+import { atomWithQuery } from "jotai-tanstack-query";
+import type { LLMModel } from "@/contracts/enums/llm-models";
+import { VISION_MODELS } from "@/contracts/enums/vision-providers";
+import { visionLLMConfigApiService } from "@/lib/apis/vision-llm-config-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
+
+export const visionLLMConfigsAtom = atomWithQuery((get) => {
+ const searchSpaceId = get(activeSearchSpaceIdAtom);
+
+ return {
+ queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
+ enabled: !!searchSpaceId,
+ staleTime: 5 * 60 * 1000,
+ queryFn: async () => {
+ return visionLLMConfigApiService.getConfigs(Number(searchSpaceId));
+ },
+ };
+});
+
+export const globalVisionLLMConfigsAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.visionLLMConfigs.global(),
+ staleTime: 10 * 60 * 1000,
+ queryFn: async () => {
+ return visionLLMConfigApiService.getGlobalConfigs();
+ },
+ };
+});
+
+export const visionModelListAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.visionLLMConfigs.modelList(),
+ staleTime: 60 * 60 * 1000,
+ placeholderData: VISION_MODELS,
+ queryFn: async (): Promise => {
+ const data = await visionLLMConfigApiService.getModels();
+ const dynamicModels = data.map((m) => ({
+ value: m.value,
+ label: m.label,
+ provider: m.provider,
+ contextWindow: m.context_window ?? undefined,
+ }));
+
+ const coveredProviders = new Set(dynamicModels.map((m) => m.provider));
+ const staticFallbacks = VISION_MODELS.filter((m) => !coveredProviders.has(m.provider));
+
+ return [...dynamicModels, ...staticFallbacks];
+ },
+ };
+});
diff --git a/surfsense_web/components/agent-action-log/action-log-dialog.tsx b/surfsense_web/components/agent-action-log/action-log-dialog.tsx
index 5f3b83db1..1d0eefc17 100644
--- a/surfsense_web/components/agent-action-log/action-log-dialog.tsx
+++ b/surfsense_web/components/agent-action-log/action-log-dialog.tsx
@@ -2,7 +2,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue } from "jotai";
-import { RefreshCw, Workflow } from "lucide-react";
+import { RefreshCcw, Workflow } from "lucide-react";
import { useCallback } from "react";
import { actionLogDialogAtom } from "@/atoms/agent/action-log-dialog.atom";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
@@ -112,7 +112,7 @@ export function ActionLogDialog() {
className="absolute right-14 top-4 size-8 rounded-full p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Refresh action log"
>
-
+
diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx
index 59006b26e..d084ac0fd 100644
--- a/surfsense_web/components/assistant-ui/assistant-message.tsx
+++ b/surfsense_web/components/assistant-ui/assistant-message.tsx
@@ -26,9 +26,9 @@ import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import {
- globalModelConnectionsAtom,
- modelConnectionsAtom,
-} from "@/atoms/model-connections/model-connections-query.atoms";
+ globalNewLLMConfigsAtom,
+ newLLMConfigsAtom,
+} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import {
CitationMetadataProvider,
@@ -37,10 +37,7 @@ import {
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button";
-import {
- type TokenUsageModelBreakdown,
- useTokenUsage,
-} from "@/components/assistant-ui/token-usage-context";
+import { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
@@ -271,81 +268,29 @@ function formatTurnCost(micros: number): string {
return "$0";
}
-function normalizeUsageModelKey(modelKey: string): string {
- return modelKey.trim().replace(/^~/, "");
-}
-
-function bareModelKey(modelKey: string): string {
- const normalized = normalizeUsageModelKey(modelKey);
- const parts = normalized.split("/");
- return parts[parts.length - 1] || normalized;
-}
-
-function inferProviderFromModelKey(modelKey: string) {
- const normalized = normalizeUsageModelKey(modelKey);
- const [provider] = normalized.split("/");
- return provider && provider !== normalized ? provider : null;
-}
-
-function titleCaseModelPart(part: string) {
- if (!part) return "";
- const upper = part.toUpperCase();
- if (/^\d+(\.\d+)?[BKM]$/.test(upper)) return upper;
- if (["gpt", "oai", "api", "llm", "vlm"].includes(part.toLowerCase())) return upper;
- return part.charAt(0).toUpperCase() + part.slice(1);
-}
-
-function humanizeModelId(modelKey: string): string {
- const bare = bareModelKey(modelKey)
- .replace(/:latest$/i, "")
- .replace(/[-_]+/g, " ")
- .trim();
- if (!bare) return modelKey;
- return bare.split(/\s+/).map(titleCaseModelPart).join(" ");
-}
-
const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ chatTurnId }) => {
const messageId = useAuiState(({ message }) => message?.id);
const createdAt = useAuiState(({ message }) => message?.createdAt);
const usage = useTokenUsage(messageId);
- const { data: globalConnections = [] } = useAtomValue(globalModelConnectionsAtom);
- const { data: localConnections = [] } = useAtomValue(modelConnectionsAtom);
+ const { data: localConfigs } = useAtomValue(newLLMConfigsAtom);
+ const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
- const modelConnectionByKey = useMemo(() => {
- const map = new Map
();
- for (const connection of [...globalConnections, ...localConnections]) {
- for (const model of connection.models) {
- const normalizedModelId = normalizeUsageModelKey(model.model_id);
- const entry = {
- name: model.display_name || model.model_id,
- provider: connection.provider,
- modelId: model.model_id,
- };
- map.set(model.model_id, entry);
- map.set(normalizedModelId, entry);
- map.set(bareModelKey(model.model_id), entry);
- }
+ const configByModel = useMemo(() => {
+ const map = new Map();
+ for (const c of [...(globalConfigs ?? []), ...(localConfigs ?? [])]) {
+ map.set(c.model_name, { name: c.name, provider: c.provider });
}
return map;
- }, [globalConnections, localConnections]);
+ }, [localConfigs, globalConfigs]);
- const resolveModel = (modelKey: string, counts: TokenUsageModelBreakdown) => {
- const normalizedKey = normalizeUsageModelKey(counts.model_id || counts.model || modelKey);
- const connectionModel =
- modelConnectionByKey.get(modelKey) ??
- modelConnectionByKey.get(normalizeUsageModelKey(modelKey)) ??
- modelConnectionByKey.get(normalizedKey) ??
- modelConnectionByKey.get(bareModelKey(normalizedKey));
- const provider =
- counts.provider || connectionModel?.provider || inferProviderFromModelKey(normalizedKey);
- const modelId = counts.model_id || connectionModel?.modelId || modelKey;
- const name = counts.display_name || connectionModel?.name || humanizeModelId(modelId);
- return {
- name,
- modelId,
- icon: provider ? getProviderIcon(provider, { className: "size-3.5 shrink-0" }) : null,
- };
+ const resolveModel = (modelKey: string) => {
+ const parts = modelKey.split("/");
+ const bare = parts[parts.length - 1] ?? modelKey;
+ const config = configByModel.get(modelKey) ?? configByModel.get(bare);
+ return config
+ ? { name: config.name, icon: getProviderIcon(config.provider, { className: "size-3.5" }) }
+ : { name: modelKey, icon: null };
};
const modelBreakdown = usage ? (usage.usage ?? usage.model_breakdown) : undefined;
@@ -374,12 +319,12 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch
{models.length > 0 ? (
models.map(([model, counts]) => {
- const { name, icon } = resolveModel(model, counts);
+ const { name, icon } = resolveModel(model);
const costMicros = counts.cost_micros;
return (
e.preventDefault()}
>
diff --git a/surfsense_web/components/assistant-ui/chat-viewport.tsx b/surfsense_web/components/assistant-ui/chat-viewport.tsx
index 83308b642..dedada7a5 100644
--- a/surfsense_web/components/assistant-ui/chat-viewport.tsx
+++ b/surfsense_web/components/assistant-ui/chat-viewport.tsx
@@ -27,8 +27,8 @@ export interface ChatViewportProps {
export const ChatViewport: FC = ({ children, footer }) => (
void;
@@ -42,10 +42,17 @@ export const CirclebackConfig: FC = ({ connector, onNameC
const doFetch = async () => {
if (!connector.search_space_id) return;
+ const baseUrl = BACKEND_URL;
+ if (!baseUrl) {
+ console.error("NEXT_PUBLIC_FASTAPI_BACKEND_URL is not configured");
+ setIsLoading(false);
+ return;
+ }
+
setIsLoading(true);
try {
const response = await authenticatedFetch(
- buildBackendUrl(`/api/v1/webhooks/circleback/${connector.search_space_id}/info`),
+ `${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`,
{ signal: controller.signal }
);
if (controller.signal.aborted) return;
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
index 1fc555471..011eeec96 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
@@ -13,7 +13,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import { getReauthEndpoint } from "@/lib/connector-telemetry";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
@@ -95,13 +95,12 @@ export const ConnectorEditView: FC = ({
if (!spaceId || !reauthEndpoint) return;
setReauthing(true);
try {
- const response = await authenticatedFetch(
- buildBackendUrl(reauthEndpoint, {
- connector_id: connector.id,
- space_id: spaceId,
- return_url: window.location.pathname,
- })
- );
+ const backendUrl = BACKEND_URL;
+ const url = new URL(`${backendUrl}${reauthEndpoint}`);
+ url.searchParams.set("connector_id", String(connector.id));
+ url.searchParams.set("space_id", String(spaceId));
+ url.searchParams.set("return_url", window.location.pathname);
+ const response = await authenticatedFetch(url.toString());
if (!response.ok) {
const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication.");
diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
index 2f10152b8..45c174d74 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
@@ -16,7 +16,7 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { searchSourceConnector } from "@/contracts/types/connector.types";
import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types/oauth.types";
import { authenticatedFetch } from "@/lib/auth-utils";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
import {
trackConnectorConnected,
trackConnectorDeleted,
@@ -351,7 +351,9 @@ export const useConnectorDialog = () => {
trackConnectorSetupStarted(Number(searchSpaceId), connector.connectorType, "oauth_click");
try {
- const url = buildBackendUrl(connector.authEndpoint, { space_id: searchSpaceId });
+ // Check if authEndpoint already has query parameters
+ const separator = connector.authEndpoint.includes("?") ? "&" : "?";
+ const url = `${BACKEND_URL}${connector.authEndpoint}${separator}space_id=${searchSpaceId}`;
const response = await authenticatedFetch(url, { method: "GET" });
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
index f7b27441b..4977219f7 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
@@ -2,10 +2,10 @@
import { Search } from "lucide-react";
import type { FC } from "react";
-import { useIsSelfHosted } from "@/components/providers/runtime-config";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { usePlatform } from "@/hooks/use-platform";
+import { isSelfHosted } from "@/lib/env-config";
import { ConnectorCard } from "../components/connector-card";
import {
COMPOSIO_CONNECTORS,
@@ -22,11 +22,6 @@ type OAuthConnector = (typeof OAUTH_CONNECTORS)[number];
type ComposioConnector = (typeof COMPOSIO_CONNECTORS)[number];
type OtherConnector = (typeof OTHER_CONNECTORS)[number];
type CrawlerConnector = (typeof CRAWLERS)[number];
-type DeploymentFilterableConnector = {
- readonly id: string;
- readonly selfHostedOnly?: boolean;
- readonly desktopOnly?: boolean;
-};
/**
* Extract the display name from a full connector name.
@@ -71,14 +66,14 @@ export const AllConnectorsTab: FC = ({
onManage,
onViewAccountsList,
}) => {
- const selfHosted = useIsSelfHosted();
+ const selfHosted = isSelfHosted();
const { isDesktop } = usePlatform();
const matchesSearch = (title: string, description: string) =>
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
description.toLowerCase().includes(searchQuery.toLowerCase());
- const passesDeploymentFilter = (c: DeploymentFilterableConnector) =>
+ const passesDeploymentFilter = (c: { selfHostedOnly?: boolean; desktopOnly?: boolean }) =>
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
// Filter connectors based on search and deployment mode
diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
index f53537cdc..05b684397 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
@@ -12,7 +12,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import { getReauthEndpoint } from "@/lib/connector-telemetry";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
@@ -61,13 +61,12 @@ export const ConnectorAccountsListView: FC = ({
if (!searchSpaceId || !endpoint) return;
setReauthingId(connector.id);
try {
- const response = await authenticatedFetch(
- buildBackendUrl(endpoint, {
- connector_id: connector.id,
- space_id: searchSpaceId,
- return_url: window.location.pathname,
- })
- );
+ const backendUrl = BACKEND_URL;
+ const url = new URL(`${backendUrl}${endpoint}`);
+ url.searchParams.set("connector_id", String(connector.id));
+ url.searchParams.set("space_id", String(searchSpaceId));
+ url.searchParams.set("return_url", window.location.pathname);
+ const response = await authenticatedFetch(url.toString());
if (!response.ok) {
const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication.");
diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx
index cd32ca920..ee36e8499 100644
--- a/surfsense_web/components/assistant-ui/markdown-text.tsx
+++ b/surfsense_web/components/assistant-ui/markdown-text.tsx
@@ -110,7 +110,7 @@ const MarkdownTextImpl = () => {
return (
{
editorRef.current?.focus();
}, [isDesktop, showDocumentPopover, showPromptPicker, threadId]);
- const handleChatModelSelected = useCallback(() => {
- if (!isDesktop) return;
- editorRef.current?.focus();
- }, [isDesktop]);
-
// Close document picker when a sidebar slide-out panel (inbox, etc.) opens.
// React only on changes to the tick — comparing against the previously-seen
// value preserves the one-shot semantics of the prior window-event approach
@@ -937,11 +931,7 @@ const Composer: FC = () => {
className="min-h-[48px] sm:min-h-[24px] **:data-slate-placeholder:font-normal"
/>
-
+
{
interface ComposerActionProps {
isBlockedByOtherUser?: boolean;
- searchSpaceId: number;
- onChatModelSelected?: () => void;
}
-const ComposerAction: FC = ({
- isBlockedByOtherUser = false,
- searchSpaceId,
- onChatModelSelected,
-}) => {
+const ComposerAction: FC = ({ isBlockedByOtherUser = false }) => {
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
@@ -996,9 +980,9 @@ const ComposerAction: FC = ({
if (url) setPendingScreenImages((prev) => [...prev, url]);
}, [electronAPI, setPendingScreenImages]);
- const { data: globalModelConnections } = useAtomValue(globalModelConnectionsAtom);
- const { data: modelConnections } = useAtomValue(modelConnectionsAtom);
- const { data: modelRoles } = useAtomValue(modelRolesAtom);
+ const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
+ const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
+ const { data: preferences } = useAtomValue(llmPreferencesAtom);
const { data: agentTools } = useAtomValue(agentToolsAtom);
const disabledTools = useAtomValue(disabledToolsAtom);
@@ -1085,18 +1069,15 @@ const ComposerAction: FC = ({
}, [hydrateDisabled]);
const hasModelConfigured = useMemo(() => {
- const chatModelId = modelRoles?.chat_model_id ?? 0;
- if (chatModelId === 0) {
- return [...(globalModelConnections ?? []), ...(modelConnections ?? [])].some((connection) =>
- connection.models.some((model) => model.enabled && Boolean(model.supports_chat))
- );
+ if (!preferences) return false;
+ const agentLlmId = preferences.agent_llm_id;
+ if (agentLlmId === null || agentLlmId === undefined) return false;
+
+ if (agentLlmId <= 0) {
+ return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
}
- return [...(globalModelConnections ?? []), ...(modelConnections ?? [])].some((connection) =>
- connection.models.some(
- (model) => model.id === chatModelId && model.enabled && Boolean(model.supports_chat)
- )
- );
- }, [modelRoles?.chat_model_id, globalModelConnections, modelConnections]);
+ return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
+ }, [preferences, globalConfigs, userConfigs]);
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
@@ -1578,11 +1559,6 @@ const ComposerAction: FC = ({
)}
-
!thread.isRunning}>
= ({
isBlockedByOtherUser
? "Wait for AI to finish responding"
: !hasModelConfigured
- ? "Please select a model to start chatting"
+ ? "Please select a model from the header to start chatting"
: isComposerEmpty
? "Enter a message or add a screenshot to send"
: "Send message"
diff --git a/surfsense_web/components/assistant-ui/token-usage-context.tsx b/surfsense_web/components/assistant-ui/token-usage-context.tsx
index 8db8c2b50..dd80bcac3 100644
--- a/surfsense_web/components/assistant-ui/token-usage-context.tsx
+++ b/surfsense_web/components/assistant-ui/token-usage-context.tsx
@@ -9,18 +9,6 @@ import {
useSyncExternalStore,
} from "react";
-export interface TokenUsageModelBreakdown {
- prompt_tokens: number;
- completion_tokens: number;
- total_tokens: number;
- cost_micros?: number;
- model?: string | null;
- model_ref?: string | null;
- model_id?: string | null;
- display_name?: string | null;
- provider?: string | null;
-}
-
export interface TokenUsageData {
prompt_tokens: number;
completion_tokens: number;
@@ -32,8 +20,24 @@ export interface TokenUsageData {
* before the migration won't have it.
*/
cost_micros?: number;
- usage?: Record;
- model_breakdown?: Record;
+ usage?: Record<
+ string,
+ {
+ prompt_tokens: number;
+ completion_tokens: number;
+ total_tokens: number;
+ cost_micros?: number;
+ }
+ >;
+ model_breakdown?: Record<
+ string,
+ {
+ prompt_tokens: number;
+ completion_tokens: number;
+ total_tokens: number;
+ cost_micros?: number;
+ }
+ >;
}
type Listener = () => void;
diff --git a/surfsense_web/components/auth/sign-in-button.tsx b/surfsense_web/components/auth/sign-in-button.tsx
index 581e37603..7f5a77f36 100644
--- a/surfsense_web/components/auth/sign-in-button.tsx
+++ b/surfsense_web/components/auth/sign-in-button.tsx
@@ -3,7 +3,7 @@
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
-import { BUILD_TIME_AUTH_TYPE, buildBackendUrl } from "@/lib/env-config";
+import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
@@ -46,14 +46,14 @@ interface SignInButtonProps {
}
export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => {
- const isGoogleAuth = BUILD_TIME_AUTH_TYPE === "GOOGLE";
+ const isGoogleAuth = AUTH_TYPE === "GOOGLE";
const [isRedirecting, setIsRedirecting] = useState(false);
const handleGoogleLogin = () => {
if (isRedirecting) return;
setIsRedirecting(true);
trackLoginAttempt("google");
- window.location.href = buildBackendUrl("/auth/google/authorize-redirect");
+ window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
};
const getClassName = () => {
diff --git a/surfsense_web/components/documents/download-original-button.tsx b/surfsense_web/components/documents/download-original-button.tsx
index e04ead89a..b79b289b4 100644
--- a/surfsense_web/components/documents/download-original-button.tsx
+++ b/surfsense_web/components/documents/download-original-button.tsx
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
interface DownloadOriginalButtonProps {
documentId: number;
@@ -41,7 +41,7 @@ export function DownloadOriginalButton({ documentId }: DownloadOriginalButtonPro
setDownloading(true);
try {
const response = await authenticatedFetch(
- buildBackendUrl(`/api/v1/documents/${documentId}/download-original`),
+ `${BACKEND_URL}/api/v1/documents/${documentId}/download-original`,
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");
diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx
index 75283c81f..01983cbe1 100644
--- a/surfsense_web/components/editor-panel/editor-panel.tsx
+++ b/surfsense_web/components/editor-panel/editor-panel.tsx
@@ -17,7 +17,6 @@ import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { DownloadOriginalButton } from "@/components/documents/download-original-button";
import { VersionHistoryButton } from "@/components/documents/version-history";
-import { PlateErrorBoundary } from "@/components/editor/plate-error-boundary";
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
import {
fetchMemoryEditorDocument,
@@ -35,15 +34,14 @@ import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => }
);
-const LARGE_DOCUMENT_THRESHOLD = 1 * 1024 * 1024; // 1MB, matches backend
-const LARGE_DOCUMENT_LINE_THRESHOLD = 5000;
+const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
interface EditorContent {
document_id: number;
@@ -51,11 +49,9 @@ interface EditorContent {
document_type?: string;
source_markdown: string;
content_size_bytes?: number;
- line_count?: number;
chunk_count?: number;
viewer_mode?: ViewerMode;
editor_plate_max_bytes?: number;
- editor_plate_max_lines?: number;
}
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
@@ -122,15 +118,6 @@ function getUtf8ByteSize(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
-function countLines(value: string): number {
- if (!value) return 0;
- let count = 1;
- for (let i = 0; i < value.length; i++) {
- if (value.charCodeAt(i) === 10) count++;
- }
- return count;
-}
-
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
@@ -197,17 +184,10 @@ export function EditorPanelContent({
);
const plateMaxBytes = editorDoc?.editor_plate_max_bytes ?? LARGE_DOCUMENT_THRESHOLD;
- const plateMaxLines = editorDoc?.editor_plate_max_lines ?? LARGE_DOCUMENT_LINE_THRESHOLD;
- const docSizeBytes = editorDoc?.content_size_bytes ?? 0;
- const docLineCount =
- editorDoc?.line_count ??
- (editorDoc?.source_markdown ? countLines(editorDoc.source_markdown) : 0);
- const isLargeDocument = docSizeBytes > plateMaxBytes || docLineCount > plateMaxLines;
+ const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > plateMaxBytes;
const viewerMode: ViewerMode = isMemoryMode
? "plate"
- : editorDoc?.viewer_mode === "monaco" || isLargeDocument
- ? "monaco"
- : "plate";
+ : (editorDoc?.viewer_mode ?? (isLargeDocument ? "monaco" : "plate"));
useEffect(() => {
const controller = new AbortController();
@@ -280,12 +260,10 @@ export function EditorPanelContent({
return;
}
- const response = await authenticatedFetch(
- buildBackendUrl(
- `/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
- ),
- { method: "GET" }
+ const url = new URL(
+ `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
);
+ const response = await authenticatedFetch(url.toString(), { method: "GET" });
if (controller.signal.aborted) return;
@@ -424,7 +402,7 @@ export function EditorPanelContent({
return;
}
const response = await authenticatedFetch(
- buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`),
+ `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -443,8 +421,7 @@ export function EditorPanelContent({
setEditedMarkdown(null);
if (!options?.silent) {
const savedSizeBytes = getUtf8ByteSize(markdownRef.current);
- const savedLineCount = countLines(markdownRef.current);
- if (savedSizeBytes > plateMaxBytes || savedLineCount > plateMaxLines) {
+ if (savedSizeBytes > plateMaxBytes) {
toast.success("Document saved. It will reopen in raw markdown mode.");
} else {
toast.success("Document saved! Reindexing in background...");
@@ -470,7 +447,6 @@ export function EditorPanelContent({
memoryLimits,
memoryScope,
plateMaxBytes,
- plateMaxLines,
resolveLocalVirtualPath,
searchSpaceId,
]
@@ -491,12 +467,8 @@ export function EditorPanelContent({
const localFileLanguage = inferMonacoLanguageFromPath(localFilePath);
const activeMarkdown = editedMarkdown ?? editorDoc?.source_markdown ?? "";
const activeMarkdownSizeBytes = useMemo(() => getUtf8ByteSize(activeMarkdown), [activeMarkdown]);
- const activeMarkdownLineCount = useMemo(() => countLines(activeMarkdown), [activeMarkdown]);
- const isNearPlateLimit =
- activeMarkdownSizeBytes >= plateMaxBytes * 0.9 ||
- activeMarkdownLineCount >= plateMaxLines * 0.9;
- const isOverPlateLimit =
- activeMarkdownSizeBytes > plateMaxBytes || activeMarkdownLineCount > plateMaxLines;
+ const isNearPlateLimit = activeMarkdownSizeBytes >= plateMaxBytes * 0.9;
+ const isOverPlateLimit = activeMarkdownSizeBytes > plateMaxBytes;
const showPlateSizeWarning =
showEditingActions && !isMemoryMode && !isLocalFileMode && isNearPlateLimit;
const memoryLimitState = isMemoryMode
@@ -509,13 +481,6 @@ export function EditorPanelContent({
? "text-orange-500"
: "text-muted-foreground";
const saveDisabled = saving || !hasUnsavedChanges || (memoryLimitState?.isOverLimit ?? false);
- const editorInstanceKey = `${
- isMemoryMode
- ? `memory-${memoryScope ?? "user"}`
- : isLocalFileMode
- ? (localFilePath ?? "local-file")
- : documentId
- }-${isEditing ? "editing" : "viewing"}`;
const handleCancelEditing = useCallback(() => {
const savedContent = editorDoc?.source_markdown ?? "";
@@ -531,9 +496,7 @@ export function EditorPanelContent({
setDownloading(true);
try {
const response = await authenticatedFetch(
- buildBackendUrl(
- `/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`
- ),
+ `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");
@@ -562,7 +525,7 @@ export function EditorPanelContent({
This document is too large for the editor (
- {formatBytes(editorDoc.content_size_bytes ?? 0)}, {docLineCount.toLocaleString()} lines,{" "}
+ {Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
{editorDoc.chunk_count ?? 0} chunks). Showing raw markdown below.
{isOverPlateLimit
- ? `This document is ${formatBytes(activeMarkdownSizeBytes)} and ${activeMarkdownLineCount.toLocaleString()} lines, above the rich editor limit of ${formatBytes(plateMaxBytes)} or ${plateMaxLines.toLocaleString()} lines. You can save, but it will reopen in raw markdown mode.`
- : `This document is approaching the rich editor limit (${formatBytes(activeMarkdownSizeBytes)} of ${formatBytes(plateMaxBytes)}, ${activeMarkdownLineCount.toLocaleString()} of ${plateMaxLines.toLocaleString()} lines).`}
+ ? `This document is ${formatBytes(activeMarkdownSizeBytes)}, above the rich editor limit of ${formatBytes(plateMaxBytes)}. You can save, but it will reopen in raw markdown mode.`
+ : `This document is approaching the rich editor limit (${formatBytes(activeMarkdownSizeBytes)} of ${formatBytes(plateMaxBytes)}).`}
)}
) : (
diff --git a/surfsense_web/components/editor-panel/memory.ts b/surfsense_web/components/editor-panel/memory.ts
index 1beb977a6..aa5b1f68d 100644
--- a/surfsense_web/components/editor-panel/memory.ts
+++ b/surfsense_web/components/editor-panel/memory.ts
@@ -1,7 +1,6 @@
"use client";
import { authenticatedFetch } from "@/lib/auth-utils";
-import { buildBackendUrl } from "@/lib/env-config";
export type MemoryScope = "user" | "team";
@@ -30,6 +29,10 @@ function getMemoryPath(scope: MemoryScope, searchSpaceId?: number | null) {
return `/api/v1/searchspaces/${searchSpaceId}/memory`;
}
+function getBackendUrl(path: string) {
+ return `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${path}`;
+}
+
export function getMemoryLimitState(length: number, limits?: MemoryLimits | null) {
if (!limits) {
return {
@@ -62,7 +65,7 @@ export async function fetchMemoryEditorDocument({
title?: string | null;
signal?: AbortSignal;
}) {
- const response = await authenticatedFetch(buildBackendUrl(getMemoryPath(scope, searchSpaceId)), {
+ const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), {
method: "GET",
signal,
});
@@ -94,7 +97,7 @@ export async function saveMemoryMarkdown({
searchSpaceId?: number | null;
markdown: string;
}) {
- const response = await authenticatedFetch(buildBackendUrl(getMemoryPath(scope, searchSpaceId)), {
+ const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ memory_md: markdown }),
diff --git a/surfsense_web/components/editor/plate-error-boundary.tsx b/surfsense_web/components/editor/plate-error-boundary.tsx
deleted file mode 100644
index c5c18f5e0..000000000
--- a/surfsense_web/components/editor/plate-error-boundary.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-"use client";
-
-import { Component, type ReactNode } from "react";
-
-interface PlateErrorBoundaryProps {
- children: ReactNode;
- fallback: ReactNode;
-}
-
-interface PlateErrorBoundaryState {
- hasError: boolean;
-}
-
-export class PlateErrorBoundary extends Component<
- PlateErrorBoundaryProps,
- PlateErrorBoundaryState
-> {
- constructor(props: PlateErrorBoundaryProps) {
- super(props);
- this.state = { hasError: false };
- }
-
- static getDerivedStateFromError(): PlateErrorBoundaryState {
- return { hasError: true };
- }
-
- render() {
- if (this.state.hasError) {
- return this.props.fallback;
- }
-
- return this.props.children;
- }
-}
diff --git a/surfsense_web/components/free-chat/anonymous-chat.tsx b/surfsense_web/components/free-chat/anonymous-chat.tsx
index e3b8273bc..aff58f7bc 100644
--- a/surfsense_web/components/free-chat/anonymous-chat.tsx
+++ b/surfsense_web/components/free-chat/anonymous-chat.tsx
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { readSSEStream } from "@/lib/chat/streaming-state";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
import { QuotaBar } from "./quota-bar";
@@ -81,7 +81,7 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
content: m.content,
}));
- const response = await fetch(buildBackendUrl("/api/v1/public/anon-chat/stream"), {
+ const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
@@ -188,6 +188,9 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
{model.name}
+ {model.description && (
+ {model.description}
+ )}
Free to use · No login required · Start typing below
diff --git a/surfsense_web/components/free-chat/free-chat-page.tsx b/surfsense_web/components/free-chat/free-chat-page.tsx
index 966aaee60..b28b1e0a1 100644
--- a/surfsense_web/components/free-chat/free-chat-page.tsx
+++ b/surfsense_web/components/free-chat/free-chat-page.tsx
@@ -33,8 +33,9 @@ import {
updateThinkingSteps,
updateToolCall,
} from "@/lib/chat/streaming-state";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
+import { FreeModelSelector } from "./free-model-selector";
import { FreeThread } from "./free-thread";
import { RemoveAdsBanner } from "./remove-ads-banner";
@@ -62,21 +63,6 @@ function normalizeFreeChatErrorMessage(error: unknown): string {
if (code === "THREAD_BUSY") {
return "A previous response is still stopping. Please try again in a moment.";
}
- if (code === "MODEL_AUTH_FAILED") {
- return "This model’s API key is invalid or expired. Switch models, or update the API key.";
- }
- if (code === "MODEL_NOT_FOUND") {
- return "This model is unavailable or no longer exists. Please switch models.";
- }
- if (code === "MODEL_CONTEXT_LIMIT") {
- return "This request is too large for the selected model. Reduce the input or switch models.";
- }
- if (code === "MODEL_PROVIDER_UNAVAILABLE") {
- return "The selected model provider is temporarily unavailable. Please try again or switch models.";
- }
- if (code === "RATE_LIMITED") {
- return "This model is temporarily rate-limited. Please try again in a few seconds or switch models.";
- }
return error.message || "An unexpected error occurred";
}
@@ -168,7 +154,7 @@ export function FreeChatPage() {
assistantMsgId: string,
signal: AbortSignal,
turnstileToken: string | null
- ): Promise<"captcha" | undefined> => {
+ ): Promise<"captcha" | void> => {
const reqBody: Record = {
model_slug: modelSlug,
messages: messageHistory,
@@ -176,7 +162,7 @@ export function FreeChatPage() {
if (!webSearchEnabled) reqBody.disabled_tools = ["web_search"];
if (turnstileToken) reqBody.turnstile_token = turnstileToken;
- const response = await fetch(buildBackendUrl("/api/v1/public/anon-chat/stream"), {
+ const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
@@ -498,6 +484,10 @@ export function FreeChatPage() {
+
+
+
+
{captchaRequired && TURNSTILE_SITE_KEY && (
diff --git a/surfsense_web/components/free-chat/free-composer.tsx b/surfsense_web/components/free-chat/free-composer.tsx
index 162b906ad..46d9e0259 100644
--- a/surfsense_web/components/free-chat/free-composer.tsx
+++ b/surfsense_web/components/free-chat/free-composer.tsx
@@ -13,7 +13,6 @@ import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { cn } from "@/lib/utils";
-import { FreeModelSelector } from "./free-model-selector";
const ANON_ALLOWED_EXTENSIONS = new Set([
".md",
@@ -228,8 +227,7 @@ export const FreeComposer: FC = () => {
-
-
+
{!isRunning ? (
diff --git a/surfsense_web/components/free-chat/free-model-selector.tsx b/surfsense_web/components/free-chat/free-model-selector.tsx
index d04bca8a2..9bf4ecee5 100644
--- a/surfsense_web/components/free-chat/free-model-selector.tsx
+++ b/surfsense_web/components/free-chat/free-model-selector.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Check, ChevronDown, Cpu } from "lucide-react";
+import { Bot, Check, ChevronDown } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
@@ -82,7 +82,7 @@ export function FreeModelSelector({ className }: { className?: string }) {
>
) : (
<>
-
+
Select Model
>
)}
diff --git a/surfsense_web/components/homepage/global-announcement.tsx b/surfsense_web/components/homepage/global-announcement.tsx
deleted file mode 100644
index 212be42c7..000000000
--- a/surfsense_web/components/homepage/global-announcement.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { IconInfoCircle } from "@tabler/icons-react";
-import { GLOBAL_ANNOUNCEMENT_ENABLED, GLOBAL_ANNOUNCEMENT_MESSAGE } from "@/lib/env-config";
-
-/**
- * Small, site-wide banner for planned downtime / maintenance notices.
- *
- * Controlled entirely through build-time env vars so it can be toggled from
- * Vercel without a code change:
- * - NEXT_PUBLIC_GLOBAL_ANNOUNCEMENT_ENABLED ("true" to show)
- * - NEXT_PUBLIC_GLOBAL_ANNOUNCEMENT_MESSAGE (the copy to display)
- */
-export function GlobalAnnouncement() {
- const message = GLOBAL_ANNOUNCEMENT_MESSAGE.trim();
-
- if (!GLOBAL_ANNOUNCEMENT_ENABLED || !message) {
- return null;
- }
-
- return (
-
- );
-}
diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx
index 0f3bfe1aa..09cf316d8 100644
--- a/surfsense_web/components/homepage/hero-section.tsx
+++ b/surfsense_web/components/homepage/hero-section.tsx
@@ -37,7 +37,7 @@ import {
getAssetLabel,
usePrimaryDownload,
} from "@/lib/desktop-download-utils";
-import { BUILD_TIME_AUTH_TYPE, buildBackendUrl } from "@/lib/env-config";
+import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
@@ -314,14 +314,14 @@ export function HeroSection() {
}
function GetStartedButton() {
- const isGoogleAuth = BUILD_TIME_AUTH_TYPE === "GOOGLE";
+ const isGoogleAuth = AUTH_TYPE === "GOOGLE";
const [isRedirecting, setIsRedirecting] = useState(false);
const handleGoogleLogin = () => {
if (isRedirecting) return;
setIsRedirecting(true);
trackLoginAttempt("google");
- window.location.href = buildBackendUrl("/auth/google/authorize-redirect");
+ window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
};
if (isGoogleAuth) {
diff --git a/surfsense_web/components/icons/providers/azure.svg b/surfsense_web/components/icons/providers/azure.svg
deleted file mode 100644
index ba80f55ca..000000000
--- a/surfsense_web/components/icons/providers/azure.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/surfsense_web/components/icons/providers/bedrock.svg b/surfsense_web/components/icons/providers/bedrock.svg
index cde500c0d..195aa6594 100644
--- a/surfsense_web/components/icons/providers/bedrock.svg
+++ b/surfsense_web/components/icons/providers/bedrock.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/surfsense_web/components/icons/providers/claude.svg b/surfsense_web/components/icons/providers/claude.svg
deleted file mode 100644
index 8d732d5b0..000000000
--- a/surfsense_web/components/icons/providers/claude.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/surfsense_web/components/icons/providers/index.ts b/surfsense_web/components/icons/providers/index.ts
index 5c8276e62..aefa2a053 100644
--- a/surfsense_web/components/icons/providers/index.ts
+++ b/surfsense_web/components/icons/providers/index.ts
@@ -1,10 +1,8 @@
export { default as Ai21Icon } from "./ai21.svg";
export { default as AnthropicIcon } from "./anthropic.svg";
export { default as AnyscaleIcon } from "./anyscale.svg";
-export { default as AzureIcon } from "./azure.svg";
export { default as BedrockIcon } from "./bedrock.svg";
export { default as CerebrasIcon } from "./cerebras.svg";
-export { default as ClaudeIcon } from "./claude.svg";
export { default as CohereIcon } from "./cohere.svg";
export { default as CometApiIcon } from "./cometapi.svg";
export { default as DatabricksIcon } from "./dbrx.svg";
@@ -15,7 +13,6 @@ export { default as GeminiIcon } from "./gemini.svg";
export { default as GitHubModelsIcon } from "./github.svg";
export { default as GroqIcon } from "./groq.svg";
export { default as HuggingFaceIcon } from "./huggingface.svg";
-export { default as LmStudioIcon } from "./lm-studio.svg";
export { default as MiniMaxIcon } from "./minimax.svg";
export { default as MistralIcon } from "./mistral.svg";
export { default as MoonshotIcon } from "./moonshot.svg";
diff --git a/surfsense_web/components/icons/providers/lm-studio.svg b/surfsense_web/components/icons/providers/lm-studio.svg
deleted file mode 100644
index b6ae7db3e..000000000
--- a/surfsense_web/components/icons/providers/lm-studio.svg
+++ /dev/null
@@ -1,21 +0,0 @@
-
diff --git a/surfsense_web/components/icons/providers/vertexai.svg b/surfsense_web/components/icons/providers/vertexai.svg
index e46a3ca0f..45adce83b 100644
--- a/surfsense_web/components/icons/providers/vertexai.svg
+++ b/surfsense_web/components/icons/providers/vertexai.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 429a1fde8..549e6e7d7 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
-import { AlarmClock, AlertTriangle, Inbox, LibraryBig } from "lucide-react";
+import { AlertTriangle, Inbox, LibraryBig, Workflow } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@@ -342,7 +342,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
{
title: "Automations",
url: `/dashboard/${searchSpaceId}/automations`,
- icon: AlarmClock,
+ icon: Workflow,
isActive: isAutomationsActive,
},
isMobile
diff --git a/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx
index 009b2c120..6f385b465 100644
--- a/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx
+++ b/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx
@@ -67,7 +67,7 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
trackSearchSpaceCreated(result.id, values.name);
- router.push(`/dashboard/${result.id}/new-chat`);
+ router.push(`/dashboard/${result.id}/onboard`);
} catch (error) {
console.error("Failed to create search space:", error);
setIsSubmitting(false);
diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx
index ea700391a..79839622d 100644
--- a/surfsense_web/components/layout/ui/header/Header.tsx
+++ b/surfsense_web/components/layout/ui/header/Header.tsx
@@ -6,6 +6,7 @@ import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { activeTabAtom } from "@/atoms/tabs/tabs.atom";
import { ActionLogButton } from "@/components/agent-action-log/action-log-button";
+import { ChatHeader } from "@/components/new-chat/chat-header";
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import type { ThreadRecord } from "@/lib/chat/thread-persistence";
@@ -65,8 +66,13 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
return (
- {/* Left side - Mobile menu trigger */}
- {mobileMenuTrigger}
+ {/* Left side - Mobile menu trigger + Model selector */}
+
+ {mobileMenuTrigger}
+ {isChatPage && !isDocumentTab && searchSpaceId && (
+
+ )}
+
{/* Right side - Actions */}
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
index 44cc56ab0..6c6668319 100644
--- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
@@ -43,7 +43,6 @@ import type { FolderDisplay } from "@/components/documents/FolderNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { VersionHistoryDialog } from "@/components/documents/version-history";
-import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import {
DEFAULT_EXCLUDE_PATTERNS,
@@ -79,7 +78,7 @@ import { foldersApiService } from "@/lib/apis/folders-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index";
@@ -227,7 +226,6 @@ function AuthenticatedDocumentsSidebarBase({
const isMobile = !useMediaQuery("(min-width: 640px)");
const platformElectronAPI = useElectronAPI();
const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null;
- const { etlService } = useRuntimeConfig();
const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const openEditorPanel = useSetAtom(openEditorPanelAtom);
@@ -620,8 +618,7 @@ function AuthenticatedDocumentsSidebarBase({
folderName: matched.name,
searchSpaceId,
excludePatterns: matched.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS,
- fileExtensions:
- matched.fileExtensions ?? Array.from(getSupportedExtensionsSet(undefined, etlService)),
+ fileExtensions: matched.fileExtensions ?? Array.from(getSupportedExtensionsSet()),
rootFolderId: folder.id,
});
toast.success(`Re-scan complete: ${matched.name}`);
@@ -629,7 +626,7 @@ function AuthenticatedDocumentsSidebarBase({
toast.error((err as Error)?.message || "Failed to re-scan folder");
}
},
- [searchSpaceId, electronAPI, etlService]
+ [searchSpaceId, electronAPI]
);
const handleStopWatching = useCallback(
@@ -751,9 +748,7 @@ function AuthenticatedDocumentsSidebarBase({
.trim()
.slice(0, 80) || "folder";
await doExport(
- buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/export`, {
- folder_id: ctx.folder.id,
- }),
+ `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${ctx.folder.id}`,
`${safeName}.zip`
);
toast.success(`Folder "${ctx.folder.name}" exported`);
@@ -805,9 +800,7 @@ function AuthenticatedDocumentsSidebarBase({
.trim()
.slice(0, 80) || "folder";
await doExport(
- buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/export`, {
- folder_id: folder.id,
- }),
+ `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${folder.id}`,
`${safeName}.zip`
);
toast.success(`Folder "${folder.name}" exported`);
@@ -827,8 +820,8 @@ function AuthenticatedDocumentsSidebarBase({
try {
const endpoint =
doc.document_type === "USER_MEMORY"
- ? buildBackendUrl("/api/v1/users/me/memory")
- : buildBackendUrl(`/api/v1/searchspaces/${searchSpaceId}/memory`);
+ ? `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/users/me/memory`
+ : `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory`;
const response = await authenticatedFetch(endpoint, { method: "GET" });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
@@ -856,9 +849,7 @@ function AuthenticatedDocumentsSidebarBase({
try {
const response = await authenticatedFetch(
- buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export`, {
- format,
- }),
+ `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export?format=${format}`,
{ method: "GET" }
);
@@ -1037,8 +1028,8 @@ function AuthenticatedDocumentsSidebarBase({
}
const endpoint =
doc.document_type === "USER_MEMORY"
- ? buildBackendUrl("/api/v1/users/me/memory/reset")
- : buildBackendUrl(`/api/v1/searchspaces/${searchSpaceId}/memory/reset`);
+ ? `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/users/me/memory/reset`
+ : `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory/reset`;
try {
const response = await authenticatedFetch(endpoint, { method: "POST" });
if (!response.ok) {
@@ -1158,7 +1149,6 @@ function AuthenticatedDocumentsSidebarBase({
const showCloudSkeleton =
currentFilesystemTab === "cloud" &&
(zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete");
- const connectorButtonLabel = connectorCount > 0 ? "Manage connectors" : "Connect your connectors";
const cloudContent = (
<>
@@ -1171,7 +1161,9 @@ function AuthenticatedDocumentsSidebarBase({
className="shrink-0 mx-4 mt-6 mb-2.5 h-auto select-none justify-start gap-2 bg-muted px-3 py-1.5 text-xs text-muted-foreground"
>
-
{connectorButtonLabel}
+
+ {connectorCount > 0 ? "Manage connectors" : "Connect your connectors"}
+
{connectorCount > 0 && (
{connectorCount}
diff --git a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx
index d50d28a3c..61b8c3e25 100644
--- a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx
+++ b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx
@@ -11,7 +11,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
@@ -108,12 +108,10 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
}
try {
- const response = await authenticatedFetch(
- buildBackendUrl(
- `/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
- ),
- { method: "GET" }
+ const url = new URL(
+ `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
);
+ const response = await authenticatedFetch(url.toString(), { method: "GET" });
if (controller.signal.aborted) return;
@@ -167,7 +165,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
setSaving(true);
try {
const response = await authenticatedFetch(
- buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`),
+ `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -325,9 +323,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
setDownloading(true);
try {
const response = await authenticatedFetch(
- buildBackendUrl(
- `/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`
- ),
+ `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");
diff --git a/surfsense_web/components/new-chat/chat-example-prompts.tsx b/surfsense_web/components/new-chat/chat-example-prompts.tsx
index 344176629..61041cc29 100644
--- a/surfsense_web/components/new-chat/chat-example-prompts.tsx
+++ b/surfsense_web/components/new-chat/chat-example-prompts.tsx
@@ -1,12 +1,12 @@
"use client";
import {
- AlarmClock,
FilePlus2,
type LucideIcon,
Search,
Settings2,
WandSparkles,
+ Workflow,
X,
} from "lucide-react";
import { memo, useCallback, useState } from "react";
@@ -22,7 +22,7 @@ interface ChatExamplePromptsProps {
const CATEGORY_ICONS: Record = {
search: Search,
create: FilePlus2,
- automate: AlarmClock,
+ automate: Workflow,
tools: Settings2,
};
diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx
index 9882530d4..4716418ee 100644
--- a/surfsense_web/components/new-chat/chat-header.tsx
+++ b/surfsense_web/components/new-chat/chat-header.tsx
@@ -1,23 +1,167 @@
"use client";
-import { ImageModelSelector } from "./image-model-selector";
+import { useCallback, useState } from "react";
+import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
+import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
+import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
+import type {
+ GlobalImageGenConfig,
+ GlobalNewLLMConfig,
+ GlobalVisionLLMConfig,
+ ImageGenerationConfig,
+ NewLLMConfigPublic,
+ VisionLLMConfig,
+} from "@/contracts/types/new-llm-config.types";
import { ModelSelector } from "./model-selector";
interface ChatHeaderProps {
searchSpaceId: number;
className?: string;
- onChatModelSelected?: () => void;
}
-export function ChatHeader({ searchSpaceId, className, onChatModelSelected }: ChatHeaderProps) {
+export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
+ // LLM config dialog state
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [selectedConfig, setSelectedConfig] = useState<
+ NewLLMConfigPublic | GlobalNewLLMConfig | null
+ >(null);
+ const [isGlobal, setIsGlobal] = useState(false);
+ const [dialogMode, setDialogMode] = useState<"create" | "edit" | "view">("view");
+
+ // Image config dialog state
+ const [imageDialogOpen, setImageDialogOpen] = useState(false);
+ const [selectedImageConfig, setSelectedImageConfig] = useState<
+ ImageGenerationConfig | GlobalImageGenConfig | null
+ >(null);
+ const [isImageGlobal, setIsImageGlobal] = useState(false);
+ const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
+
+ // Vision config dialog state
+ const [visionDialogOpen, setVisionDialogOpen] = useState(false);
+ const [selectedVisionConfig, setSelectedVisionConfig] = useState<
+ VisionLLMConfig | GlobalVisionLLMConfig | null
+ >(null);
+ const [isVisionGlobal, setIsVisionGlobal] = useState(false);
+ const [visionDialogMode, setVisionDialogMode] = useState<"create" | "edit" | "view">("view");
+
+ // Default provider for create dialogs
+ const [defaultLLMProvider, setDefaultLLMProvider] = useState();
+ const [defaultImageProvider, setDefaultImageProvider] = useState();
+ const [defaultVisionProvider, setDefaultVisionProvider] = useState();
+
+ // LLM handlers
+ const handleEditLLMConfig = useCallback(
+ (config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
+ setSelectedConfig(config);
+ setIsGlobal(global);
+ setDialogMode(global ? "view" : "edit");
+ setDefaultLLMProvider(undefined);
+ setDialogOpen(true);
+ },
+ []
+ );
+
+ const handleAddNewLLM = useCallback((provider?: string) => {
+ setSelectedConfig(null);
+ setIsGlobal(false);
+ setDialogMode("create");
+ setDefaultLLMProvider(provider);
+ setDialogOpen(true);
+ }, []);
+
+ const handleDialogClose = useCallback((open: boolean) => {
+ setDialogOpen(open);
+ if (!open) setSelectedConfig(null);
+ }, []);
+
+ // Image model handlers
+ const handleAddImageModel = useCallback((provider?: string) => {
+ setSelectedImageConfig(null);
+ setIsImageGlobal(false);
+ setImageDialogMode("create");
+ setDefaultImageProvider(provider);
+ setImageDialogOpen(true);
+ }, []);
+
+ const handleEditImageConfig = useCallback(
+ (config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
+ setSelectedImageConfig(config);
+ setIsImageGlobal(global);
+ setImageDialogMode(global ? "view" : "edit");
+ setDefaultImageProvider(undefined);
+ setImageDialogOpen(true);
+ },
+ []
+ );
+
+ const handleImageDialogClose = useCallback((open: boolean) => {
+ setImageDialogOpen(open);
+ if (!open) setSelectedImageConfig(null);
+ }, []);
+
+ // Vision model handlers
+ const handleAddVisionModel = useCallback((provider?: string) => {
+ setSelectedVisionConfig(null);
+ setIsVisionGlobal(false);
+ setVisionDialogMode("create");
+ setDefaultVisionProvider(provider);
+ setVisionDialogOpen(true);
+ }, []);
+
+ const handleEditVisionConfig = useCallback(
+ (config: VisionLLMConfig | GlobalVisionLLMConfig, global: boolean) => {
+ setSelectedVisionConfig(config);
+ setIsVisionGlobal(global);
+ setVisionDialogMode(global ? "view" : "edit");
+ setDefaultVisionProvider(undefined);
+ setVisionDialogOpen(true);
+ },
+ []
+ );
+
+ const handleVisionDialogClose = useCallback((open: boolean) => {
+ setVisionDialogOpen(open);
+ if (!open) setSelectedVisionConfig(null);
+ }, []);
+
return (
-
+
+
+
);
}
diff --git a/surfsense_web/components/new-chat/image-model-selector.tsx b/surfsense_web/components/new-chat/image-model-selector.tsx
deleted file mode 100644
index e90a46c09..000000000
--- a/surfsense_web/components/new-chat/image-model-selector.tsx
+++ /dev/null
@@ -1,301 +0,0 @@
-"use client";
-
-import { useAtom, useAtomValue } from "jotai";
-import { Check, ChevronDown, ImagePlus, Search, SlidersHorizontal } from "lucide-react";
-import { useRouter } from "next/navigation";
-import type { UIEvent } from "react";
-import { useCallback, useMemo, useState } from "react";
-import { updateModelRolesMutationAtom } from "@/atoms/model-connections/model-connections-mutation.atoms";
-import {
- globalModelConnectionsAtom,
- modelConnectionsAtom,
- modelRolesAtom,
-} from "@/atoms/model-connections/model-connections-query.atoms";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import {
- Drawer,
- DrawerContent,
- DrawerHandle,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer";
-import { Input } from "@/components/ui/input";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { Spinner } from "@/components/ui/spinner";
-import type { ConnectionRead, ModelRead } from "@/contracts/types/model-connections.types";
-import { useIsMobile } from "@/hooks/use-mobile";
-import { AUTO_PROVIDER_ICON_KEY, getProviderIcon } from "@/lib/provider-icons";
-import { cn } from "@/lib/utils";
-import { providerDisplay } from "../settings/model-connections/provider-metadata";
-
-interface ImageModelSelectorProps {
- searchSpaceId: number;
- className?: string;
-}
-
-type ImageModel = ModelRead & {
- connectionId: number;
- connectionLabel: string;
- connectionScope: string;
- provider: string;
-};
-
-const AUTO_IMAGE_MODEL_ID = 0;
-
-function connectionLabel(connection: ConnectionRead) {
- if (connection.scope === "GLOBAL") return "Global";
- return providerDisplay(connection.provider).name;
-}
-
-function flattenImageModels(connections: ConnectionRead[]) {
- return connections.flatMap((connection) =>
- connection.models
- .filter((model) => model.enabled && Boolean(model.supports_image_generation))
- .map((model) => ({
- ...model,
- connectionId: connection.id,
- connectionLabel: connectionLabel(connection),
- connectionScope: connection.scope,
- provider: connection.provider,
- }))
- );
-}
-
-function isFreeGlobalModel(model: ImageModel) {
- return model.connectionScope === "GLOBAL" && model.billing_tier?.toLowerCase() === "free";
-}
-
-function modelName(model: ImageModel) {
- const name = model.display_name || model.model_id;
- if (model.connectionScope === "GLOBAL") {
- return name.replace(/\s+\(free\)$/i, "");
- }
- return name;
-}
-
-function filterImageModels(models: ImageModel[], search: string) {
- const normalized = search.trim().toLowerCase();
- if (!normalized) return models;
- return models.filter((model) =>
- [modelName(model), model.model_id, model.connectionLabel]
- .join(" ")
- .toLowerCase()
- .includes(normalized)
- );
-}
-
-function groupedModels(models: ImageModel[]) {
- return models.reduce>((groups, model) => {
- const key = model.connectionLabel;
- if (!groups[key]) groups[key] = [];
- groups[key].push(model);
- return groups;
- }, {});
-}
-
-export function ImageModelSelector({ searchSpaceId, className }: ImageModelSelectorProps) {
- const router = useRouter();
- const isMobile = useIsMobile();
- const [open, setOpen] = useState(false);
- const [search, setSearch] = useState("");
- const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
- const [{ data: globalConnections = [], isLoading: globalLoading }] = useAtom(
- globalModelConnectionsAtom
- );
- const [{ data: connections = [], isLoading: connectionsLoading }] = useAtom(modelConnectionsAtom);
- const [{ data: roles }] = useAtom(modelRolesAtom);
- const updateRoles = useAtomValue(updateModelRolesMutationAtom);
-
- const allImageModels = useMemo(
- () => flattenImageModels([...globalConnections, ...connections]),
- [globalConnections, connections]
- );
-
- const visibleImageModels = useMemo(
- () => filterImageModels(allImageModels, search),
- [allImageModels, search]
- );
- const imageModelsById = useMemo(
- () => new Map(allImageModels.map((model) => [model.id, model])),
- [allImageModels]
- );
- const selectedModelId = roles?.image_gen_model_id ?? AUTO_IMAGE_MODEL_ID;
- const selected = imageModelsById.get(selectedModelId);
- const groups = useMemo(() => groupedModels(visibleImageModels), [visibleImageModels]);
- const loading = globalLoading || connectionsLoading;
- const hasSearchQuery = search.trim().length > 0;
-
- function handleOpenChange(nextOpen: boolean) {
- if (!nextOpen) setSearch("");
- setOpen(nextOpen);
- }
-
- function selectModel(modelId: number) {
- updateRoles.mutate({ image_gen_model_id: modelId });
- setSearch("");
- setOpen(false);
- }
-
- function manageModelConnections() {
- setOpen(false);
- router.push(`/dashboard/${searchSpaceId}/search-space-settings/models`);
- }
-
- const handleScroll = useCallback((event: UIEvent) => {
- const el = event.currentTarget;
- const atTop = el.scrollTop <= 2;
- const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
- setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
- }, []);
-
- // Only surface this control when usable image-generation models exist.
- if (!loading && allImageModels.length === 0) {
- return null;
- }
-
- const content = (
-
-
-
-
- setSearch(event.target.value)}
- placeholder="Search image models"
- className="h-8 border-0 bg-transparent pl-6 text-sm shadow-none"
- />
-
-
-
-
- {loading ? (
-
-
-
- ) : Object.keys(groups).length === 0 ? (
-
- {hasSearchQuery
- ? "No matching image models."
- : "No enabled image models. Add or enable models in Settings."}
-
- ) : (
- Object.entries(groups).map(([connection, models]) => (
-
-
- {connection}
-
- {models.map((model) => (
-
- ))}
-
- ))
- )}
-
-
-
-
-
- );
-
- const trigger = (
-
- );
-
- if (isMobile) {
- return (
-
- {trigger}
-
-
-
- Select Image Model
-
- {content}
-
-
- );
- }
-
- return (
-
- {trigger}
-
- {content}
-
-
- );
-}
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx
index 22d86aa92..0a096f5f8 100644
--- a/surfsense_web/components/new-chat/model-selector.tsx
+++ b/surfsense_web/components/new-chat/model-selector.tsx
@@ -1,16 +1,40 @@
"use client";
-import { useAtom, useAtomValue } from "jotai";
-import { Check, ChevronDown, Search, SlidersHorizontal } from "lucide-react";
-import { useRouter } from "next/navigation";
-import type { UIEvent } from "react";
-import { useCallback, useMemo, useState } from "react";
-import { updateModelRolesMutationAtom } from "@/atoms/model-connections/model-connections-mutation.atoms";
+import { useAtomValue } from "jotai";
import {
- globalModelConnectionsAtom,
- modelConnectionsAtom,
- modelRolesAtom,
-} from "@/atoms/model-connections/model-connections-query.atoms";
+ Bot,
+ Check,
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ ChevronUp,
+ ImageIcon,
+ Layers,
+ Pencil,
+ Plus,
+ ScanEye,
+ Search,
+ Zap,
+} from "lucide-react";
+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,
+} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
+import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
+import {
+ globalNewLLMConfigsAtom,
+ llmPreferencesAtom,
+ newLLMConfigsAtom,
+} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
+import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
+import {
+ globalVisionLLMConfigsAtom,
+ visionLLMConfigsAtom,
+} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -21,284 +45,1389 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
-import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
-import type { ConnectionRead, ModelRead } from "@/contracts/types/model-connections.types";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import type {
+ GlobalImageGenConfig,
+ GlobalNewLLMConfig,
+ GlobalVisionLLMConfig,
+ ImageGenerationConfig,
+ NewLLMConfigPublic,
+ VisionLLMConfig,
+} from "@/contracts/types/new-llm-config.types";
import { useIsMobile } from "@/hooks/use-mobile";
-import { AUTO_PROVIDER_ICON_KEY, getProviderIcon } from "@/lib/provider-icons";
+import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
-import { providerDisplay } from "../settings/model-connections/provider-metadata";
-interface ModelSelectorProps {
- searchSpaceId: number;
- className?: string;
- onChatModelSelected?: () => void;
-}
+// ─── Helpers ────────────────────────────────────────────────────────
-type ChatModel = ModelRead & {
- connectionId: number;
- connectionLabel: string;
- connectionScope: string;
- provider: string;
+const PROVIDER_NAMES: Record = {
+ OPENAI: "OpenAI",
+ ANTHROPIC: "Anthropic",
+ GOOGLE: "Google",
+ AZURE: "Azure",
+ AZURE_OPENAI: "Azure OpenAI",
+ AWS_BEDROCK: "AWS Bedrock",
+ BEDROCK: "Bedrock",
+ DEEPSEEK: "DeepSeek",
+ MISTRAL: "Mistral",
+ COHERE: "Cohere",
+ GITHUB_MODELS: "GitHub Models",
+ GROQ: "Groq",
+ OLLAMA: "Ollama",
+ TOGETHER_AI: "Together AI",
+ FIREWORKS_AI: "Fireworks AI",
+ REPLICATE: "Replicate",
+ HUGGINGFACE: "HuggingFace",
+ PERPLEXITY: "Perplexity",
+ XAI: "xAI",
+ OPENROUTER: "OpenRouter",
+ CEREBRAS: "Cerebras",
+ SAMBANOVA: "SambaNova",
+ VERTEX_AI: "Vertex AI",
+ MINIMAX: "MiniMax",
+ MOONSHOT: "Moonshot",
+ ZHIPU: "Zhipu",
+ DEEPINFRA: "DeepInfra",
+ CLOUDFLARE: "Cloudflare",
+ DATABRICKS: "Databricks",
+ NSCALE: "NScale",
+ RECRAFT: "Recraft",
+ XINFERENCE: "XInference",
+ CUSTOM: "Custom",
+ AI21: "AI21",
+ ALIBABA_QWEN: "Qwen",
+ ANYSCALE: "Anyscale",
+ COMETAPI: "CometAPI",
};
-const AUTO_CHAT_MODEL_ID = 0;
+// Provider keys valid per model type, matching backend enums
+// (LiteLLMProvider, ImageGenProvider, VisionProvider in db.py)
+const LLM_PROVIDER_KEYS: string[] = [
+ "OPENAI",
+ "ANTHROPIC",
+ "GOOGLE",
+ "AZURE_OPENAI",
+ "BEDROCK",
+ "VERTEX_AI",
+ "GROQ",
+ "DEEPSEEK",
+ "XAI",
+ "MISTRAL",
+ "COHERE",
+ "OPENROUTER",
+ "TOGETHER_AI",
+ "FIREWORKS_AI",
+ "REPLICATE",
+ "PERPLEXITY",
+ "OLLAMA",
+ "CEREBRAS",
+ "SAMBANOVA",
+ "DEEPINFRA",
+ "AI21",
+ "ALIBABA_QWEN",
+ "MOONSHOT",
+ "ZHIPU",
+ "MINIMAX",
+ "HUGGINGFACE",
+ "CLOUDFLARE",
+ "DATABRICKS",
+ "ANYSCALE",
+ "COMETAPI",
+ "GITHUB_MODELS",
+ "CUSTOM",
+];
-function connectionLabel(connection: ConnectionRead) {
- if (connection.scope === "GLOBAL") return "Global";
- return providerDisplay(connection.provider).name;
-}
+const IMAGE_PROVIDER_KEYS: string[] = [
+ "OPENAI",
+ "AZURE_OPENAI",
+ "GOOGLE",
+ "VERTEX_AI",
+ "BEDROCK",
+ "RECRAFT",
+ "OPENROUTER",
+ "XINFERENCE",
+ "NSCALE",
+];
-function flattenChatModels(connections: ConnectionRead[]) {
- return connections.flatMap((connection) =>
- connection.models
- .filter((model) => model.enabled && Boolean(model.supports_chat))
- .map((model) => ({
- ...model,
- connectionId: connection.id,
- connectionLabel: connectionLabel(connection),
- connectionScope: connection.scope,
- provider: connection.provider,
- }))
+const VISION_PROVIDER_KEYS: string[] = [
+ "OPENAI",
+ "ANTHROPIC",
+ "GOOGLE",
+ "AZURE_OPENAI",
+ "VERTEX_AI",
+ "BEDROCK",
+ "XAI",
+ "OPENROUTER",
+ "OLLAMA",
+ "GROQ",
+ "TOGETHER_AI",
+ "FIREWORKS_AI",
+ "DEEPSEEK",
+ "MISTRAL",
+ "CUSTOM",
+];
+
+const PROVIDER_KEYS_BY_TAB: Record = {
+ llm: LLM_PROVIDER_KEYS,
+ image: IMAGE_PROVIDER_KEYS,
+ vision: VISION_PROVIDER_KEYS,
+};
+
+function formatProviderName(provider: string): string {
+ const key = provider.toUpperCase();
+ return (
+ PROVIDER_NAMES[key] ??
+ provider.charAt(0).toUpperCase() + provider.slice(1).toLowerCase().replace(/_/g, " ")
);
}
-function isFreeGlobalModel(model: ChatModel) {
- return model.connectionScope === "GLOBAL" && model.billing_tier?.toLowerCase() === "free";
+function normalizeText(input: string): string {
+ return input
+ .normalize("NFD")
+ .replace(/\p{Diacritic}/gu, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, " ")
+ .trim();
}
-function modelName(model: ChatModel) {
- const name = model.display_name || model.model_id;
- if (model.connectionScope === "GLOBAL") {
- return name.replace(/\s+\(free\)$/i, "");
+interface ConfigBase {
+ id: number;
+ name: string;
+ model_name: string;
+ provider: string;
+}
+
+function filterAndScore(
+ configs: T[],
+ selectedProvider: string,
+ searchQuery: string
+): T[] {
+ let result = configs;
+
+ if (selectedProvider !== "all") {
+ result = result.filter((c) => c.provider.toUpperCase() === selectedProvider);
}
- return name;
+
+ if (!searchQuery.trim()) return result;
+
+ const normalized = normalizeText(searchQuery);
+ const tokens = normalized.split(/\s+/).filter(Boolean);
+
+ const scored = result.map((c) => {
+ const aggregate = normalizeText([c.name, c.model_name, c.provider].join(" "));
+ let score = 0;
+ if (aggregate.includes(normalized)) score += 5;
+ for (const token of tokens) {
+ if (aggregate.includes(token)) score += 1;
+ }
+ return { config: c, score };
+ });
+
+ return scored
+ .filter((s) => s.score > 0)
+ .sort((a, b) => b.score - a.score)
+ .map((s) => s.config);
}
-function filterChatModels(models: ChatModel[], search: string) {
- const normalized = search.trim().toLowerCase();
- if (!normalized) return models;
- return models.filter((model) =>
- [modelName(model), model.model_id, model.connectionLabel]
- .join(" ")
- .toLowerCase()
- .includes(normalized)
+interface DisplayItem {
+ config: ConfigBase & Record;
+ isGlobal: boolean;
+ isAutoMode: boolean;
+}
+
+const TruncatedNameWithTooltip: React.FC<{
+ text: string;
+ className?: string;
+ enableTooltip: boolean;
+}> = ({ text, className, enableTooltip }) => {
+ const textRef = useRef(null);
+ const openTimerRef = useRef(undefined);
+ const [isTruncated, setIsTruncated] = useState(false);
+ const [open, setOpen] = useState(false);
+
+ const recalcTruncation = useCallback(() => {
+ const el = textRef.current;
+ if (!el) return;
+ setIsTruncated(el.scrollWidth > el.clientWidth + 1);
+ }, []);
+
+ useEffect(() => {
+ if (!enableTooltip) return;
+ const el = textRef.current;
+ if (!el) return;
+
+ const raf = requestAnimationFrame(recalcTruncation);
+ recalcTruncation();
+
+ const observer = new ResizeObserver(recalcTruncation);
+ observer.observe(el);
+ if (el.parentElement) observer.observe(el.parentElement);
+ window.addEventListener("resize", recalcTruncation);
+
+ return () => {
+ cancelAnimationFrame(raf);
+ observer.disconnect();
+ window.removeEventListener("resize", recalcTruncation);
+ };
+ }, [enableTooltip, recalcTruncation]);
+
+ useEffect(() => {
+ // Recompute when row text changes.
+ void text;
+ requestAnimationFrame(recalcTruncation);
+ }, [text, recalcTruncation]);
+
+ useEffect(
+ () => () => {
+ if (openTimerRef.current) window.clearTimeout(openTimerRef.current);
+ },
+ []
);
-}
-function groupedModels(models: ChatModel[]) {
- return models.reduce>((groups, model) => {
- const key = model.connectionLabel;
- if (!groups[key]) groups[key] = [];
- groups[key].push(model);
- return groups;
- }, {});
+ if (!enableTooltip) {
+ return (
+
+ {text}
+
+ );
+ }
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (openTimerRef.current) {
+ window.clearTimeout(openTimerRef.current);
+ openTimerRef.current = undefined;
+ }
+ if (!nextOpen) {
+ setOpen(false);
+ return;
+ }
+ if (!isTruncated) return;
+ openTimerRef.current = window.setTimeout(() => {
+ setOpen(true);
+ openTimerRef.current = undefined;
+ }, 220);
+ };
+
+ return (
+
+
+
+ {text}
+
+
+
+ {text}
+
+
+ );
+};
+
+// ─── Component ──────────────────────────────────────────────────────
+
+interface ModelSelectorProps {
+ onEditLLM: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
+ onAddNewLLM: (provider?: string) => void;
+ onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
+ onAddNewImage?: (provider?: string) => void;
+ onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void;
+ onAddNewVision?: (provider?: string) => void;
+ className?: string;
}
export function ModelSelector({
- searchSpaceId,
+ onEditLLM,
+ onAddNewLLM,
+ onEditImage,
+ onAddNewImage,
+ onEditVision,
+ onAddNewVision,
className,
- onChatModelSelected,
}: ModelSelectorProps) {
- const router = useRouter();
- const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
- const [search, setSearch] = useState("");
- const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
- const [{ data: globalConnections = [], isLoading: globalLoading }] = useAtom(
- globalModelConnectionsAtom
- );
- const [{ data: connections = [], isLoading: connectionsLoading }] = useAtom(modelConnectionsAtom);
- const [{ data: roles }] = useAtom(modelRolesAtom);
- const updateRoles = useAtomValue(updateModelRolesMutationAtom);
+ const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">("llm");
+ const [searchQuery, setSearchQuery] = useState("");
+ const [selectedProvider, setSelectedProvider] = useState("all");
+ const [focusedIndex, setFocusedIndex] = useState(-1);
+ const [modelScrollPos, setModelScrollPos] = useState<"top" | "middle" | "bottom">("top");
+ const [sidebarScrollPos, setSidebarScrollPos] = useState<"top" | "middle" | "bottom">("top");
+ const providerSidebarRef = useRef(null);
+ const modelListRef = useRef(null);
+ const searchInputRef = useRef(null);
+ const isMobile = useIsMobile();
- const allChatModels = useMemo(
- () => flattenChatModels([...globalConnections, ...connections]),
- [globalConnections, connections]
+ const handleOpenChange = useCallback(
+ (next: boolean) => {
+ if (next) {
+ setSearchQuery("");
+ setSelectedProvider("all");
+ if (!isMobile) {
+ requestAnimationFrame(() => searchInputRef.current?.focus());
+ }
+ }
+ setOpen(next);
+ },
+ [isMobile]
);
- const visibleChatModels = useMemo(
- () => filterChatModels(allChatModels, search),
- [allChatModels, search]
+ const handleTabChange = useCallback(
+ (next: "llm" | "image" | "vision") => {
+ setActiveTab(next);
+ setSelectedProvider("all");
+ setSearchQuery("");
+ setFocusedIndex(-1);
+ setModelScrollPos("top");
+ if (open && !isMobile) {
+ requestAnimationFrame(() => searchInputRef.current?.focus());
+ }
+ },
+ [open, isMobile]
);
- const chatModelsById = useMemo(
- () => new Map(allChatModels.map((model) => [model.id, model])),
- [allChatModels]
- );
- const selectedModelId = roles?.chat_model_id ?? AUTO_CHAT_MODEL_ID;
- const selected = chatModelsById.get(selectedModelId);
- const groups = useMemo(() => groupedModels(visibleChatModels), [visibleChatModels]);
- const loading = globalLoading || connectionsLoading;
- const hasSearchQuery = search.trim().length > 0;
- function handleOpenChange(nextOpen: boolean) {
- if (!nextOpen) setSearch("");
- setOpen(nextOpen);
- }
-
- function selectModel(modelId: number) {
- updateRoles.mutate({ chat_model_id: modelId });
- setSearch("");
- setOpen(false);
- requestAnimationFrame(() => {
- onChatModelSelected?.();
- });
- }
-
- function manageModelConnections() {
- setOpen(false);
- router.push(`/dashboard/${searchSpaceId}/search-space-settings/models`);
- }
-
- const handleScroll = useCallback((event: UIEvent) => {
- const el = event.currentTarget;
+ const handleModelListScroll = useCallback((e: React.UIEvent) => {
+ const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
- setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
+ setModelScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
- const content = (
-
-
-
-
- setSearch(event.target.value)}
- placeholder="Search chat models"
- className="h-8 border-0 bg-transparent pl-6 text-sm shadow-none"
- />
-
-
-
-
- {loading ? (
-
-
-
- ) : Object.keys(groups).length === 0 ? (
-
- {hasSearchQuery
- ? "No matching chat models."
- : "No enabled chat models. Add or enable models in Settings."}
-
- ) : (
- Object.entries(groups).map(([connection, models]) => (
-
-
- {connection}
-
- {models.map((model) => (
-
- ))}
-
- ))
- )}
-
-
-
-
-
+ const handleSidebarScroll = useCallback(
+ (e: React.UIEvent) => {
+ const el = e.currentTarget;
+ if (isMobile) {
+ const atStart = el.scrollLeft <= 2;
+ const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
+ setSidebarScrollPos(atStart ? "top" : atEnd ? "bottom" : "middle");
+ } else {
+ const atTop = el.scrollTop <= 2;
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
+ setSidebarScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
+ }
+ },
+ [isMobile]
);
- const trigger = (
+ const scrollProviderSidebar = useCallback(
+ (direction: "backward" | "forward") => {
+ const el = providerSidebarRef.current;
+ if (!el) return;
+ const delta = isMobile
+ ? Math.max(56, Math.floor(el.clientWidth * 0.5))
+ : Math.max(44, Math.floor(el.clientHeight * 0.4));
+
+ if (isMobile) {
+ el.scrollBy({
+ left: direction === "backward" ? -delta : delta,
+ behavior: "smooth",
+ });
+ return;
+ }
+
+ el.scrollBy({
+ top: direction === "backward" ? -delta : delta,
+ behavior: "smooth",
+ });
+ },
+ [isMobile]
+ );
+
+ // Cmd/Ctrl+M shortcut (desktop only)
+ useEffect(() => {
+ if (isMobile) return;
+ const handler = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "m") {
+ e.preventDefault();
+ // setOpen((prev) => !prev);
+ handleOpenChange(!open);
+ }
+ };
+ document.addEventListener("keydown", handler);
+ return () => document.removeEventListener("keydown", handler);
+ }, [isMobile, open, handleOpenChange]);
+
+ // ─── Data ───
+ const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
+ const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } =
+ useAtomValue(globalNewLLMConfigsAtom);
+ const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
+ const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
+ const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
+ const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } =
+ useAtomValue(globalImageGenConfigsAtom);
+ const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
+ const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue(
+ globalVisionLLMConfigsAtom
+ );
+ 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 ||
+ prefsLoading ||
+ imageGlobalLoading ||
+ imageUserLoading ||
+ visionGlobalLoading ||
+ visionUserLoading;
+
+ // ─── Current selected configs ───
+ const currentLLMConfig = useMemo(() => {
+ if (!preferences) return null;
+ const id = preferences.agent_llm_id;
+ if (id === null || id === undefined) return null;
+ if (id <= 0) return llmGlobalConfigs?.find((c) => c.id === id) ?? null;
+ return llmUserConfigs?.find((c) => c.id === id) ?? null;
+ }, [preferences, llmGlobalConfigs, llmUserConfigs]);
+
+ const isLLMAutoMode =
+ currentLLMConfig && "is_auto_mode" in currentLLMConfig && currentLLMConfig.is_auto_mode;
+
+ const currentImageConfig = useMemo(() => {
+ if (!preferences) return null;
+ const id = preferences.image_generation_config_id;
+ if (id === null || id === undefined) return null;
+ return (
+ imageGlobalConfigs?.find((c) => c.id === id) ??
+ imageUserConfigs?.find((c) => c.id === id) ??
+ null
+ );
+ }, [preferences, imageGlobalConfigs, imageUserConfigs]);
+
+ const isImageAutoMode =
+ currentImageConfig && "is_auto_mode" in currentImageConfig && currentImageConfig.is_auto_mode;
+
+ const currentVisionConfig = useMemo(() => {
+ if (!preferences) return null;
+ const id = preferences.vision_llm_config_id;
+ if (id === null || id === undefined) return null;
+ return (
+ visionGlobalConfigs?.find((c) => c.id === id) ??
+ visionUserConfigs?.find((c) => c.id === id) ??
+ null
+ );
+ }, [preferences, visionGlobalConfigs, visionUserConfigs]);
+
+ const isVisionAutoMode =
+ currentVisionConfig &&
+ "is_auto_mode" in currentVisionConfig &&
+ currentVisionConfig.is_auto_mode;
+
+ // ─── Filtered configs (separate global / user for section headers) ───
+ const filteredLLMGlobal = useMemo(
+ () => filterAndScore(llmGlobalConfigs ?? [], selectedProvider, searchQuery),
+ [llmGlobalConfigs, selectedProvider, searchQuery]
+ );
+ const filteredLLMUser = useMemo(
+ () => filterAndScore(llmUserConfigs ?? [], selectedProvider, searchQuery),
+ [llmUserConfigs, selectedProvider, searchQuery]
+ );
+ const filteredImageGlobal = useMemo(
+ () => filterAndScore(imageGlobalConfigs ?? [], selectedProvider, searchQuery),
+ [imageGlobalConfigs, selectedProvider, searchQuery]
+ );
+ const filteredImageUser = useMemo(
+ () => filterAndScore(imageUserConfigs ?? [], selectedProvider, searchQuery),
+ [imageUserConfigs, selectedProvider, searchQuery]
+ );
+ const filteredVisionGlobal = useMemo(
+ () => filterAndScore(visionGlobalConfigs ?? [], selectedProvider, searchQuery),
+ [visionGlobalConfigs, selectedProvider, searchQuery]
+ );
+ const filteredVisionUser = useMemo(
+ () => filterAndScore(visionUserConfigs ?? [], selectedProvider, searchQuery),
+ [visionUserConfigs, selectedProvider, searchQuery]
+ );
+
+ // Combined display list for keyboard navigation
+ const currentDisplayItems: DisplayItem[] = useMemo(() => {
+ const toItems = (configs: ConfigBase[], isGlobal: boolean): DisplayItem[] =>
+ configs.map((c) => ({
+ config: c as ConfigBase & Record,
+ isGlobal,
+ isAutoMode:
+ isGlobal && "is_auto_mode" in c && !!(c as Record).is_auto_mode,
+ }));
+
+ const sortGlobalItems = (items: DisplayItem[]): DisplayItem[] =>
+ [...items].sort((a, b) => {
+ if (a.isAutoMode !== b.isAutoMode) return a.isAutoMode ? -1 : 1;
+ const aPremium = !!(a.config as Record).is_premium;
+ const bPremium = !!(b.config as Record).is_premium;
+ if (aPremium !== bPremium) return aPremium ? 1 : -1;
+ return 0;
+ });
+
+ switch (activeTab) {
+ case "llm":
+ return [
+ ...sortGlobalItems(toItems(filteredLLMGlobal, true)),
+ ...toItems(filteredLLMUser, false),
+ ];
+ case "image":
+ return [
+ ...sortGlobalItems(toItems(filteredImageGlobal, true)),
+ ...toItems(filteredImageUser, false),
+ ];
+ case "vision":
+ return [
+ ...sortGlobalItems(toItems(filteredVisionGlobal, true)),
+ ...toItems(filteredVisionUser, false),
+ ];
+ }
+ }, [
+ activeTab,
+ filteredLLMGlobal,
+ filteredLLMUser,
+ filteredImageGlobal,
+ filteredImageUser,
+ filteredVisionGlobal,
+ filteredVisionUser,
+ ]);
+
+ // ─── Provider sidebar data ───
+ // Collect which providers actually have configured models for the active tab
+ const configuredProviderSet = useMemo(() => {
+ const configs =
+ activeTab === "llm"
+ ? [...(llmGlobalConfigs ?? []), ...(llmUserConfigs ?? [])]
+ : activeTab === "image"
+ ? [...(imageGlobalConfigs ?? []), ...(imageUserConfigs ?? [])]
+ : [...(visionGlobalConfigs ?? []), ...(visionUserConfigs ?? [])];
+ const set = new Set();
+ for (const c of configs) {
+ if (c.provider) set.add(c.provider.toUpperCase());
+ }
+ return set;
+ }, [
+ activeTab,
+ llmGlobalConfigs,
+ llmUserConfigs,
+ imageGlobalConfigs,
+ imageUserConfigs,
+ visionGlobalConfigs,
+ visionUserConfigs,
+ ]);
+
+ // Show only providers valid for the active tab; configured ones first
+ const activeProviders = useMemo(() => {
+ const tabKeys = PROVIDER_KEYS_BY_TAB[activeTab] ?? LLM_PROVIDER_KEYS;
+ const configured = tabKeys.filter((p) => configuredProviderSet.has(p));
+ const unconfigured = tabKeys.filter((p) => !configuredProviderSet.has(p));
+ return ["all", ...configured, ...unconfigured];
+ }, [activeTab, configuredProviderSet]);
+
+ const providerModelCounts = useMemo(() => {
+ const allConfigs =
+ activeTab === "llm"
+ ? [...(llmGlobalConfigs ?? []), ...(llmUserConfigs ?? [])]
+ : activeTab === "image"
+ ? [...(imageGlobalConfigs ?? []), ...(imageUserConfigs ?? [])]
+ : [...(visionGlobalConfigs ?? []), ...(visionUserConfigs ?? [])];
+ const counts: Record = { all: allConfigs.length };
+ for (const c of allConfigs) {
+ const p = c.provider.toUpperCase();
+ counts[p] = (counts[p] || 0) + 1;
+ }
+ return counts;
+ }, [
+ activeTab,
+ llmGlobalConfigs,
+ llmUserConfigs,
+ imageGlobalConfigs,
+ imageUserConfigs,
+ visionGlobalConfigs,
+ visionUserConfigs,
+ ]);
+
+ // ─── Selection handlers ───
+ const handleSelectLLM = useCallback(
+ async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
+ if (currentLLMConfig?.id === config.id) {
+ setOpen(false);
+ return;
+ }
+ if (!searchSpaceId) {
+ toast.error("No search space selected");
+ return;
+ }
+ try {
+ await updatePreferences({
+ search_space_id: Number(searchSpaceId),
+ data: { agent_llm_id: config.id },
+ });
+ toast.success(`Switched to ${config.name}`);
+ setOpen(false);
+ } catch {
+ toast.error("Failed to switch model");
+ }
+ },
+ [currentLLMConfig, searchSpaceId, updatePreferences]
+ );
+
+ const handleSelectImage = useCallback(
+ async (configId: number) => {
+ if (currentImageConfig?.id === configId) {
+ setOpen(false);
+ return;
+ }
+ if (!searchSpaceId) {
+ toast.error("No search space selected");
+ return;
+ }
+ try {
+ await updatePreferences({
+ search_space_id: Number(searchSpaceId),
+ data: { image_generation_config_id: configId },
+ });
+ toast.success("Image model updated");
+ setOpen(false);
+ } catch {
+ toast.error("Failed to switch image model");
+ }
+ },
+ [currentImageConfig, searchSpaceId, updatePreferences]
+ );
+
+ const handleSelectVision = useCallback(
+ async (configId: number) => {
+ if (currentVisionConfig?.id === configId) {
+ setOpen(false);
+ return;
+ }
+ if (!searchSpaceId) {
+ toast.error("No search space selected");
+ return;
+ }
+ try {
+ await updatePreferences({
+ search_space_id: Number(searchSpaceId),
+ data: { vision_llm_config_id: configId },
+ });
+ toast.success("Vision model updated");
+ setOpen(false);
+ } catch {
+ toast.error("Failed to switch vision model");
+ }
+ },
+ [currentVisionConfig, searchSpaceId, updatePreferences]
+ );
+
+ const handleSelectItem = useCallback(
+ (item: DisplayItem) => {
+ switch (activeTab) {
+ case "llm":
+ handleSelectLLM(item.config as NewLLMConfigPublic | GlobalNewLLMConfig);
+ break;
+ case "image":
+ handleSelectImage(item.config.id);
+ break;
+ case "vision":
+ handleSelectVision(item.config.id);
+ break;
+ }
+ },
+ [activeTab, handleSelectLLM, handleSelectImage, handleSelectVision]
+ );
+
+ const handleEditItem = useCallback(
+ (e: React.MouseEvent, item: DisplayItem) => {
+ e.stopPropagation();
+ setOpen(false);
+ switch (activeTab) {
+ case "llm":
+ onEditLLM(item.config as NewLLMConfigPublic | GlobalNewLLMConfig, item.isGlobal);
+ break;
+ case "image":
+ onEditImage?.(item.config as ImageGenerationConfig | GlobalImageGenConfig, item.isGlobal);
+ break;
+ case "vision":
+ onEditVision?.(item.config as VisionLLMConfig | GlobalVisionLLMConfig, item.isGlobal);
+ break;
+ }
+ },
+ [activeTab, onEditLLM, onEditImage, onEditVision]
+ );
+
+ // ─── Keyboard navigation ───
+ // biome-ignore lint/correctness/useExhaustiveDependencies: searchQuery and selectedProvider are intentional triggers to reset focus
+ useEffect(() => {
+ setFocusedIndex(-1);
+ }, [searchQuery, selectedProvider]);
+
+ useEffect(() => {
+ if (focusedIndex < 0 || !modelListRef.current) return;
+ const items = modelListRef.current.querySelectorAll("[data-model-index]");
+ items[focusedIndex]?.scrollIntoView({
+ block: "nearest",
+ behavior: "smooth",
+ });
+ }, [focusedIndex]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ const count = currentDisplayItems.length;
+
+ // Arrow Left/Right cycle provider filters
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
+ e.preventDefault();
+ const providers = activeProviders;
+ const idx = providers.indexOf(selectedProvider);
+ let next: number;
+ if (e.key === "ArrowLeft") {
+ next = idx > 0 ? idx - 1 : providers.length - 1;
+ } else {
+ next = idx < providers.length - 1 ? idx + 1 : 0;
+ }
+ setSelectedProvider(providers[next]);
+ if (providerSidebarRef.current) {
+ const buttons = providerSidebarRef.current.querySelectorAll("button");
+ buttons[next]?.scrollIntoView({
+ block: "nearest",
+ inline: "nearest",
+ behavior: "smooth",
+ });
+ }
+ return;
+ }
+
+ if (count === 0) return;
+
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault();
+ setFocusedIndex((prev) => (prev < count - 1 ? prev + 1 : 0));
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ setFocusedIndex((prev) => (prev > 0 ? prev - 1 : count - 1));
+ break;
+ case "Enter":
+ e.preventDefault();
+ if (focusedIndex >= 0 && focusedIndex < count) {
+ handleSelectItem(currentDisplayItems[focusedIndex]);
+ }
+ break;
+ case "Home":
+ e.preventDefault();
+ setFocusedIndex(0);
+ break;
+ case "End":
+ e.preventDefault();
+ setFocusedIndex(count - 1);
+ break;
+ }
+ },
+ [currentDisplayItems, focusedIndex, activeProviders, selectedProvider, handleSelectItem]
+ );
+
+ // ─── Render: Provider sidebar ───
+ const renderProviderSidebar = () => {
+ const configuredCount = configuredProviderSet.size;
+
+ return (
+
+ {!isMobile && (
+
+
+
+ )}
+ {isMobile && (
+
+
+
+ )}
+
+ {activeProviders.map((provider, idx) => {
+ const isAll = provider === "all";
+ const isActive = selectedProvider === provider;
+ const count = providerModelCounts[provider] || 0;
+ const isConfigured = isAll || configuredProviderSet.has(provider);
+
+ // Separator between configured and unconfigured providers
+ // idx 0 is "all", configured run from 1..configuredCount, unconfigured start at configuredCount+1
+ const showSeparator = !isAll && idx === configuredCount + 1 && configuredCount > 0;
+
+ return (
+
+ {showSeparator &&
+ (isMobile ? (
+
+ ) : (
+
+ ))}
+
+
+
+
+
+ {isAll ? "All Models" : formatProviderName(provider)}
+ {isConfigured ? ` (${count})` : " (not configured)"}
+
+
+
+ );
+ })}
+
+ {!isMobile && (
+
+
+
+ )}
+ {isMobile && (
+
+
+
+ )}
+
+ );
+ };
+
+ // ─── Render: Model card ───
+ const getSelectedId = () => {
+ switch (activeTab) {
+ case "llm":
+ return currentLLMConfig?.id;
+ case "image":
+ return currentImageConfig?.id;
+ case "vision":
+ return currentVisionConfig?.id;
+ }
+ };
+
+ const renderModelCard = (item: DisplayItem, index: number) => {
+ const { config, isAutoMode } = item;
+ const isSelected = getSelectedId() === config.id;
+ const isFocused = focusedIndex === index;
+ const hasCitations = "citations_enabled" in config && !!config.citations_enabled;
+ const hasPremiumStatus = "is_premium" in config && !isAutoMode;
+ const isPremium = hasPremiumStatus && !!(config as Record).is_premium;
+ // 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).supports_image_input === false;
+
+ return (
+ handleSelectItem(item)}
+ onKeyDown={
+ isMobile
+ ? undefined
+ : (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleSelectItem(item);
+ }
+ }
+ }
+ onMouseEnter={() => setFocusedIndex(index)}
+ className={cn(
+ "group flex items-center gap-2.5 px-3 py-2 rounded-xl",
+ "transition-colors duration-150 mx-2 cursor-pointer",
+ "hover:bg-accent hover:text-accent-foreground",
+ isFocused && "bg-accent text-accent-foreground",
+ isSelected && "bg-accent text-accent-foreground"
+ )}
+ >
+ {/* Provider icon */}
+
+ {getProviderIcon(config.provider as string, {
+ isAutoMode,
+ className: "size-5",
+ })}
+
+
+ {/* Model info */}
+
+
+
+ {isAutoMode && (
+
+ Recommended
+
+ )}
+ {isImageIncompatibleChatModel && (
+
+ No image
+
+ )}
+
+ {isAutoMode ? (
+
+ Auto Mode
+
+ ) : (
+ (hasPremiumStatus || hasCitations) && (
+
+ {hasPremiumStatus && (
+
+ {isPremium ? "Premium" : "Free"}
+
+ )}
+ {hasCitations && (
+
+ Citations
+
+ )}
+
+ )
+ )}
+
+
+ {/* Actions */}
+
+ {!isAutoMode && (
+
+ )}
+ {isSelected && (
+
+
+
+ )}
+
+
+ );
+ };
+
+ // ─── Render: Full content ───
+ const renderContent = () => {
+ const globalItems = currentDisplayItems.filter((i) => i.isGlobal);
+ const userItems = currentDisplayItems.filter((i) => !i.isGlobal);
+ const globalStartIdx = 0;
+ const userStartIdx = globalItems.length;
+
+ const addHandler =
+ activeTab === "llm" ? onAddNewLLM : activeTab === "image" ? onAddNewImage : onAddNewVision;
+ const addLabel =
+ activeTab === "llm"
+ ? "Add Model"
+ : activeTab === "image"
+ ? "Add Image Model"
+ : "Add Vision Model";
+
+ return (
+
+ {/* Tab header */}
+
+
+ {(
+ [
+ {
+ value: "llm" as const,
+ icon: Zap,
+ label: "LLM",
+ },
+ {
+ value: "image" as const,
+ icon: ImageIcon,
+ label: "Image",
+ },
+ {
+ value: "vision" as const,
+ icon: ScanEye,
+ label: "Vision",
+ },
+ ] as const
+ ).map(({ value, icon: Icon, label }) => (
+
+ ))}
+
+
+
+ {/* Two-pane layout */}
+
+ {/* Provider sidebar */}
+ {renderProviderSidebar()}
+
+ {/* Main content */}
+
+ {/* Search */}
+
+
+ setSearchQuery(e.target.value)}
+ onKeyDown={isMobile ? undefined : handleKeyDown}
+ role="combobox"
+ aria-expanded={true}
+ aria-controls="model-selector-list"
+ className={cn(
+ "w-full pl-8 pr-3 py-2.5 text-sm bg-transparent",
+ "focus:outline-none",
+ "placeholder:text-muted-foreground"
+ )}
+ />
+
+
+ {/* Provider header when filtered */}
+ {selectedProvider !== "all" && (
+
+ {getProviderIcon(selectedProvider, {
+ className: "size-4",
+ })}
+ {formatProviderName(selectedProvider)}
+
+ {configuredProviderSet.has(selectedProvider)
+ ? `${providerModelCounts[selectedProvider] || 0} models`
+ : "Not configured"}
+
+
+ )}
+
+ {/* Model list */}
+
+ {currentDisplayItems.length === 0 ? (
+
+ {selectedProvider !== "all" && !configuredProviderSet.has(selectedProvider) ? (
+ <>
+
+ {getProviderIcon(selectedProvider, {
+ className: "size-10",
+ })}
+
+
+ No {formatProviderName(selectedProvider)} models configured
+
+
+ Add a model with this provider to get started
+
+ {addHandler && (
+
+ )}
+ >
+ ) : searchQuery ? (
+ <>
+
+
No models found
+
+ Try a different search term
+
+ >
+ ) : (
+ <>
+
+ No models configured
+
+
+ Configure models in your search space settings
+
+ >
+ )}
+
+ ) : (
+ <>
+ {globalItems.length > 0 && (
+ <>
+
+ Global Models
+
+ {globalItems.map((item, i) => renderModelCard(item, globalStartIdx + i))}
+ >
+ )}
+ {globalItems.length > 0 && userItems.length > 0 && (
+
+ )}
+ {userItems.length > 0 && (
+ <>
+
+ Your Configurations
+
+ {userItems.map((item, i) => renderModelCard(item, userStartIdx + i))}
+ >
+ )}
+ >
+ )}
+
+
+ {/* Add model button */}
+ {addHandler && (
+
+
+
+ )}
+
+
+
+ );
+ };
+
+ // ─── Trigger button ───
+ const triggerButton = (
);
+ // ─── Shell: Drawer on mobile, Popover on desktop ───
if (isMobile) {
return (
- {trigger}
+ {triggerButton}
-
- Select Chat Model
+
+ Select Model
- {content}
+ {renderContent()}
);
@@ -306,12 +1435,14 @@ export function ModelSelector({
return (
- {trigger}
+ {triggerButton}
e.preventDefault()}
>
- {content}
+ {renderContent()}
);
diff --git a/surfsense_web/components/providers/ZeroProvider.tsx b/surfsense_web/components/providers/ZeroProvider.tsx
index 35d51311a..5bb43db99 100644
--- a/surfsense_web/components/providers/ZeroProvider.tsx
+++ b/surfsense_web/components/providers/ZeroProvider.tsx
@@ -12,15 +12,7 @@ import { getBearerToken, handleUnauthorized, refreshAccessToken } from "@/lib/au
import { queries } from "@/zero/queries";
import { schema } from "@/zero/schema";
-const configuredCacheURL = process.env.NEXT_PUBLIC_ZERO_CACHE_URL;
-
-function getCacheURL() {
- if (configuredCacheURL) return configuredCacheURL;
- if (typeof window !== "undefined") {
- return `${window.location.origin}/zero`;
- }
- return "http://localhost:4848";
-}
+const cacheURL = process.env.NEXT_PUBLIC_ZERO_CACHE_URL || "http://localhost:4848";
function ZeroAuthSync() {
const zero = useZero();
@@ -50,7 +42,6 @@ function ZeroAuthSync() {
export function ZeroProvider({ children }: { children: React.ReactNode }) {
const { data: user } = useAtomValue(currentUserAtom);
- const cacheURL = useMemo(() => getCacheURL(), []);
const userId = user?.id;
const hasUser = !!userId;
@@ -74,7 +65,7 @@ export function ZeroProvider({ children }: { children: React.ReactNode }) {
cacheURL,
auth,
}),
- [userID, context, cacheURL, auth]
+ [userID, context, auth]
);
return (
diff --git a/surfsense_web/components/providers/runtime-config.server.tsx b/surfsense_web/components/providers/runtime-config.server.tsx
deleted file mode 100644
index c515820c2..000000000
--- a/surfsense_web/components/providers/runtime-config.server.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { connection } from "next/server";
-import { RuntimeConfigProvider } from "@/components/providers/runtime-config";
-import {
- BUILD_TIME_AUTH_TYPE,
- BUILD_TIME_DEPLOYMENT_MODE,
- BUILD_TIME_ETL_SERVICE,
-} from "@/lib/env-config";
-
-export async function RuntimeConfig({ children }: { children: React.ReactNode }) {
- await connection();
-
- const value = {
- authType: process.env.AUTH_TYPE ?? BUILD_TIME_AUTH_TYPE,
- etlService: process.env.ETL_SERVICE ?? BUILD_TIME_ETL_SERVICE,
- deploymentMode: process.env.DEPLOYMENT_MODE ?? BUILD_TIME_DEPLOYMENT_MODE,
- };
-
- return {children};
-}
diff --git a/surfsense_web/components/providers/runtime-config.tsx b/surfsense_web/components/providers/runtime-config.tsx
deleted file mode 100644
index 560acd597..000000000
--- a/surfsense_web/components/providers/runtime-config.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-"use client";
-
-import { createContext, useContext } from "react";
-
-export type AuthType = "LOCAL" | "GOOGLE" | string;
-export type DeploymentMode = "self-hosted" | "cloud" | string;
-
-export interface RuntimeConfigValue {
- authType: AuthType;
- etlService: string;
- deploymentMode: DeploymentMode;
-}
-
-const RuntimeConfigContext = createContext(null);
-
-export function RuntimeConfigProvider({
- value,
- children,
-}: {
- value: RuntimeConfigValue;
- children: React.ReactNode;
-}) {
- return {children};
-}
-
-export function useRuntimeConfig() {
- const context = useContext(RuntimeConfigContext);
- if (!context) {
- throw new Error("useRuntimeConfig must be used within RuntimeConfigProvider");
- }
- return context;
-}
-
-export function useIsLocalAuth() {
- return useRuntimeConfig().authType === "LOCAL";
-}
-
-export function useIsGoogleAuth() {
- return useRuntimeConfig().authType === "GOOGLE";
-}
-
-export function useIsSelfHosted() {
- return useRuntimeConfig().deploymentMode === "self-hosted";
-}
-
-export function useIsCloud() {
- return useRuntimeConfig().deploymentMode === "cloud";
-}
diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx
index 53b0c9867..682235e0f 100644
--- a/surfsense_web/components/report-panel/report-panel.tsx
+++ b/surfsense_web/components/report-panel/report-panel.tsx
@@ -22,7 +22,7 @@ import { Spinner } from "@/components/ui/spinner";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
function ReportPanelSkeleton() {
return (
@@ -245,7 +245,7 @@ export function ReportPanelContent({
URL.revokeObjectURL(url);
} else {
const response = await authenticatedFetch(
- buildBackendUrl(`/api/v1/reports/${activeReportId}/export`, { format }),
+ `${BACKEND_URL}/api/v1/reports/${activeReportId}/export?format=${format}`,
{ method: "GET" }
);
@@ -278,7 +278,7 @@ export function ReportPanelContent({
setSaving(true);
try {
const response = await authenticatedFetch(
- buildBackendUrl(`/api/v1/reports/${activeReportId}/content`),
+ `${BACKEND_URL}/api/v1/reports/${activeReportId}/content`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -506,11 +506,7 @@ export function ReportPanelContent({
) : reportContent.content_type === "typst" ? (
diff --git a/surfsense_web/components/settings/agent-model-manager.tsx b/surfsense_web/components/settings/agent-model-manager.tsx
new file mode 100644
index 000000000..507a263e0
--- /dev/null
+++ b/surfsense_web/components/settings/agent-model-manager.tsx
@@ -0,0 +1,423 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { AlertCircle, Dot, FileText, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
+import { useMemo, useState } from "react";
+import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
+import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
+import {
+ globalNewLLMConfigsAtom,
+ newLLMConfigsAtom,
+} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
+import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ 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 { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
+import { useMediaQuery } from "@/hooks/use-media-query";
+import { getProviderIcon } from "@/lib/provider-icons";
+import { cn } from "@/lib/utils";
+
+interface AgentModelManagerProps {
+ searchSpaceId: number;
+}
+
+function getInitials(name: string): string {
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[1][0]).toUpperCase();
+ }
+ return name.slice(0, 2).toUpperCase();
+}
+
+export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+ // Mutations
+ const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue(
+ deleteNewLLMConfigMutationAtom
+ );
+
+ // Queries
+ const {
+ data: configs,
+ isFetching: isLoading,
+ error: fetchError,
+ refetch: refreshConfigs,
+ } = useAtomValue(newLLMConfigsAtom);
+ const { data: globalConfigs = [] } = useAtomValue(globalNewLLMConfigsAtom);
+
+ // Members for user resolution
+ const { data: members } = useAtomValue(membersAtom);
+ const memberMap = useMemo(() => {
+ const map = new Map();
+ if (members) {
+ for (const m of members) {
+ map.set(m.user_id, {
+ name: m.user_display_name || m.user_email || "Unknown",
+ email: m.user_email || undefined,
+ avatarUrl: m.user_avatar_url || undefined,
+ });
+ }
+ }
+ return map;
+ }, [members]);
+
+ // Permissions
+ const { data: access } = useAtomValue(myAccessAtom);
+ const canCreate =
+ !!access && (access.is_owner || (access.permissions?.includes("llm_configs:create") ?? false));
+ const canUpdate =
+ !!access && (access.is_owner || (access.permissions?.includes("llm_configs:update") ?? false));
+ const canDelete =
+ !!access && (access.is_owner || (access.permissions?.includes("llm_configs:delete") ?? false));
+ const isReadOnly = !canCreate && !canUpdate && !canDelete;
+
+ // Local state
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [editingConfig, setEditingConfig] = useState(null);
+ const [configToDelete, setConfigToDelete] = useState(null);
+
+ const handleDelete = async () => {
+ if (!configToDelete) return;
+ try {
+ await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
+ setConfigToDelete(null);
+ } catch {
+ // Error handled by mutation state
+ }
+ };
+
+ const openEditDialog = (config: NewLLMConfig) => {
+ setEditingConfig(config);
+ setIsDialogOpen(true);
+ };
+
+ const openNewDialog = () => {
+ setEditingConfig(null);
+ setIsDialogOpen(true);
+ };
+
+ return (
+
+ {/* Header actions */}
+
+
+ {canCreate && (
+
+ )}
+
+
+ {/* Fetch Error Alert */}
+ {fetchError && (
+
+
+
+
+ {fetchError?.message ?? "Failed to load configurations"}
+
+
+
+ )}
+
+ {/* Read-only / Limited permissions notice */}
+ {access && !isLoading && isReadOnly && (
+
+
+
+
+
+ You have read-only access to LLM
+ configurations. Contact a space owner to request additional permissions.
+
+
+
+
+ )}
+ {access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && (
+
+
+
+
+
+ You can{" "}
+ {[canCreate && "create", canUpdate && "edit", canDelete && "delete"]
+ .filter(Boolean)
+ .join(" and ")}{" "}
+ configurations
+ {!canDelete && ", but cannot delete them"}.
+
+
+
+
+ )}
+
+ {/* Global Configs Info */}
+ {(isLoading || globalConfigs.length > 0) && (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+ {globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}
+ {" "}
+ available from your administrator.
+
+ )}
+
+
+ )}
+
+ {/* Loading Skeleton */}
+ {isLoading && (
+
+ {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Configurations List */}
+ {!isLoading && (
+
+ {configs?.length === 0 ? (
+
+
+
+ No Models Yet
+
+ {canCreate
+ ? "Add your first model to power chat, reports, and other agent capabilities"
+ : "No models have been added to this space yet. Contact a space owner to add one"}
+
+
+
+
+ ) : (
+
+ {configs?.map((config) => {
+ const member = config.user_id ? memberMap.get(config.user_id) : null;
+
+ return (
+
+
+
+ {/* Header: Icon + Name + Actions */}
+
+
+
+ {getProviderIcon(config.provider, { className: "size-4" })}
+
+
+
+ {config.name}
+
+ {config.description && (
+
+ {config.description}
+
+ )}
+
+
+ {(canUpdate || canDelete) && (
+
+ {canUpdate && (
+
+
+
+
+
+ Edit
+
+
+ )}
+ {canDelete && (
+
+
+
+
+
+ Delete
+
+
+ )}
+
+ )}
+
+
+ {/* Feature badges */}
+
+ {config.citations_enabled && (
+
+ Citations
+
+ )}
+ {!config.use_default_system_instructions &&
+ config.system_instructions && (
+
+
+ Custom
+
+ )}
+
+
+ {/* Footer: Date + Creator */}
+
+
+
+
+ {new Date(config.created_at).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}
+
+ {member && (
+ <>
+
+
+
+
+
+
+ {member.avatarUrl && (
+
+ )}
+
+ {getInitials(member.name)}
+
+
+
+ {member.name}
+
+
+
+
+ {member.email || member.name}
+
+
+
+ >
+ )}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {/* Add/Edit Configuration Dialog */}
+
{
+ setIsDialogOpen(open);
+ if (!open) setEditingConfig(null);
+ }}
+ config={editingConfig}
+ isGlobal={false}
+ searchSpaceId={searchSpaceId}
+ mode={editingConfig ? "edit" : "create"}
+ />
+
+ {/* Delete Confirmation Dialog */}
+ !open && setConfigToDelete(null)}
+ >
+
+
+ Delete Model
+
+ Are you sure you want to delete{" "}
+ {configToDelete?.name}? This
+ action cannot be undone.
+
+
+
+ Cancel
+
+ {isDeleting ? (
+ <>
+
+ Deleting
+ >
+ ) : (
+ "Delete"
+ )}
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/components/settings/general-settings-manager.tsx b/surfsense_web/components/settings/general-settings-manager.tsx
index 68ff21f07..a308acfad 100644
--- a/surfsense_web/components/settings/general-settings-manager.tsx
+++ b/surfsense_web/components/settings/general-settings-manager.tsx
@@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner";
@@ -49,7 +49,7 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
setIsExporting(true);
try {
const response = await authenticatedFetch(
- buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/export`),
+ `${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
{ method: "GET" }
);
if (!response.ok) {
diff --git a/surfsense_web/components/settings/image-model-manager.tsx b/surfsense_web/components/settings/image-model-manager.tsx
new file mode 100644
index 000000000..494f7aae9
--- /dev/null
+++ b/surfsense_web/components/settings/image-model-manager.tsx
@@ -0,0 +1,489 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { AlertCircle, Dot, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
+import { useMemo, useState } from "react";
+import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
+import {
+ globalImageGenConfigsAtom,
+ imageGenConfigsAtom,
+} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
+import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
+import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ 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 { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
+import { useMediaQuery } from "@/hooks/use-media-query";
+import { getProviderIcon } from "@/lib/provider-icons";
+import { cn } from "@/lib/utils";
+
+interface ImageModelManagerProps {
+ searchSpaceId: number;
+}
+
+function getInitials(name: string): string {
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[1][0]).toUpperCase();
+ }
+ return name.slice(0, 2).toUpperCase();
+}
+
+export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+
+ const {
+ mutateAsync: deleteConfig,
+ isPending: isDeleting,
+ error: deleteError,
+ } = useAtomValue(deleteImageGenConfigMutationAtom);
+
+ const {
+ data: userConfigs,
+ isFetching: configsLoading,
+ error: fetchError,
+ refetch: refreshConfigs,
+ } = useAtomValue(imageGenConfigsAtom);
+ const { data: globalConfigs = [], isFetching: globalLoading } =
+ useAtomValue(globalImageGenConfigsAtom);
+
+ const { data: members } = useAtomValue(membersAtom);
+ const memberMap = useMemo(() => {
+ const map = new Map();
+ if (members) {
+ for (const m of members) {
+ map.set(m.user_id, {
+ name: m.user_display_name || m.user_email || "Unknown",
+ email: m.user_email || undefined,
+ avatarUrl: m.user_avatar_url || undefined,
+ });
+ }
+ }
+ return map;
+ }, [members]);
+
+ const { data: access } = useAtomValue(myAccessAtom);
+ const canCreate =
+ !!access &&
+ (access.is_owner || (access.permissions?.includes("image_generations:create") ?? false));
+ const canDelete =
+ !!access &&
+ (access.is_owner || (access.permissions?.includes("image_generations:delete") ?? false));
+ const canUpdate = canCreate;
+ const isReadOnly = !canCreate && !canDelete;
+
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [editingConfig, setEditingConfig] = useState(null);
+ const [configToDelete, setConfigToDelete] = useState(null);
+
+ const isLoading = configsLoading || globalLoading;
+ const errors = [deleteError, fetchError].filter(Boolean) as Error[];
+
+ const openEditDialog = (config: ImageGenerationConfig) => {
+ setEditingConfig(config);
+ setIsDialogOpen(true);
+ };
+
+ const openNewDialog = () => {
+ setEditingConfig(null);
+ setIsDialogOpen(true);
+ };
+
+ const handleDelete = async () => {
+ if (!configToDelete) return;
+ try {
+ await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
+ setConfigToDelete(null);
+ } catch {
+ // Error handled by mutation
+ }
+ };
+
+ return (
+
+ {/* Header actions */}
+
+
+ {canCreate && (
+
+ )}
+
+
+ {/* Errors */}
+ {errors.map((err) => (
+
+ ))}
+
+ {/* Read-only / Limited permissions notice */}
+ {access && !isLoading && isReadOnly && (
+
+
+
+
+
+ You have read-only access to image generation
+ configurations. Contact a space owner to request additional permissions.
+
+
+
+
+ )}
+ {access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
+
+
+
+
+
+ You can{" "}
+ {[canCreate && "create and edit", canDelete && "delete"]
+ .filter(Boolean)
+ .join(" and ")}{" "}
+ image model configurations
+ {!canDelete && ", but cannot delete them"}.
+
+
+
+
+ )}
+
+ {/* Global info */}
+ {(isLoading ||
+ globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0) && (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+ {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
+ global image{" "}
+ {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length ===
+ 1
+ ? "model"
+ : "models"}
+ {" "}
+ available from your administrator. {(() => {
+ const nonAuto = globalConfigs.filter(
+ (g) => !("is_auto_mode" in g && g.is_auto_mode)
+ );
+ const premium = nonAuto.filter(
+ (g) =>
+ "billing_tier" in g &&
+ (g as { billing_tier?: string }).billing_tier === "premium"
+ ).length;
+ const free = nonAuto.length - premium;
+ if (premium > 0 && free > 0) {
+ return `${premium} premium, ${free} free.`;
+ }
+ if (premium > 0) {
+ return `All ${premium} premium — debits your shared credit pool.`;
+ }
+ return `All ${free} free.`;
+ })()}
+
+ )}
+
+
+ )}
+
+ {/* 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 && (
+
+
+ {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 (
+
+
+
+
+ {getProviderIcon(cfg.provider, { className: "size-4" })}
+
+
+
+ {cfg.name}
+
+ {isPremium ? (
+
+ Premium
+
+ ) : (
+
+ Free
+
+ )}
+
+
+ {cfg.description && (
+
+ {cfg.description}
+
+ )}
+
+
+
+
+ {cfg.model_name}
+
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Loading Skeleton */}
+ {isLoading && (
+
+
+
+ {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* User Configs */}
+ {!isLoading && (
+
+ {(userConfigs?.length ?? 0) === 0 ? (
+
+
+ No Image Models Yet
+
+ {canCreate
+ ? "Add your own image generation model (DALL-E 3, GPT Image 1, etc.)"
+ : "No image models have been added to this space yet. Contact a space owner to add one."}
+
+
+
+ ) : (
+
+ {userConfigs?.map((config) => {
+ const member = config.user_id ? memberMap.get(config.user_id) : null;
+
+ return (
+
+
+
+ {/* Header: Icon + Name + Actions */}
+
+
+
+ {getProviderIcon(config.provider, { className: "size-4" })}
+
+
+
+ {config.name}
+
+ {config.description && (
+
+ {config.description}
+
+ )}
+
+
+ {(canUpdate || canDelete) && (
+
+ {canUpdate && (
+
+
+
+
+
+ Edit
+
+
+ )}
+ {canDelete && (
+
+
+
+
+
+ Delete
+
+
+ )}
+
+ )}
+
+
+ {/* Footer: Date + Creator */}
+
+
+
+
+ {new Date(config.created_at).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}
+
+ {member && (
+ <>
+
+
+
+
+
+
+ {member.avatarUrl && (
+
+ )}
+
+ {getInitials(member.name)}
+
+
+
+ {member.name}
+
+
+
+
+ {member.email || member.name}
+
+
+
+ >
+ )}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {/* Create/Edit Dialog — shared component */}
+
{
+ setIsDialogOpen(open);
+ if (!open) setEditingConfig(null);
+ }}
+ config={editingConfig}
+ isGlobal={false}
+ searchSpaceId={searchSpaceId}
+ mode={editingConfig ? "edit" : "create"}
+ />
+
+ {/* Delete Confirmation */}
+ !open && setConfigToDelete(null)}
+ >
+
+
+ Delete Image Model
+
+ Are you sure you want to delete{" "}
+ {configToDelete?.name}?
+
+
+
+ Cancel
+
+ Delete
+ {isDeleting && }
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/components/settings/llm-role-manager.tsx b/surfsense_web/components/settings/llm-role-manager.tsx
new file mode 100644
index 000000000..c32e79a8e
--- /dev/null
+++ b/surfsense_web/components/settings/llm-role-manager.tsx
@@ -0,0 +1,443 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import {
+ AlertCircle,
+ Bot,
+ CircleCheck,
+ CircleDashed,
+ FileText,
+ ImageIcon,
+ RefreshCw,
+ ScanEye,
+} from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ globalImageGenConfigsAtom,
+ imageGenConfigsAtom,
+} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
+import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
+import {
+ globalNewLLMConfigsAtom,
+ llmPreferencesAtom,
+ newLLMConfigsAtom,
+} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
+import {
+ globalVisionLLMConfigsAtom,
+ visionLLMConfigsAtom,
+} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
+import { cn } from "@/lib/utils";
+
+const ROLE_DESCRIPTIONS = {
+ agent: {
+ icon: Bot,
+ title: "Agent LLM",
+ description: "Primary LLM for chat interactions and agent operations",
+ color: "text-muted-foreground",
+ bgColor: "bg-muted",
+ prefKey: "agent_llm_id" as const,
+ configType: "llm" as const,
+ },
+ image_generation: {
+ icon: ImageIcon,
+ title: "Image Generation Model",
+ description: "Model used for AI image generation (DALL-E, GPT Image, etc.)",
+ color: "text-muted-foreground",
+ bgColor: "bg-muted",
+ prefKey: "image_generation_config_id" as const,
+ configType: "image" as const,
+ },
+ vision: {
+ icon: ScanEye,
+ title: "Vision LLM",
+ description: "Vision-capable model for screenshot analysis and context extraction",
+ color: "text-muted-foreground",
+ bgColor: "bg-muted",
+ prefKey: "vision_llm_config_id" as const,
+ configType: "vision" as const,
+ },
+};
+
+interface LLMRoleManagerProps {
+ searchSpaceId: number;
+}
+
+export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
+ // LLM configs
+ const {
+ data: newLLMConfigs = [],
+ isFetching: configsLoading,
+ error: configsError,
+ refetch: refreshConfigs,
+ } = useAtomValue(newLLMConfigsAtom);
+ const {
+ data: globalConfigs = [],
+ isFetching: globalConfigsLoading,
+ error: globalConfigsError,
+ } = useAtomValue(globalNewLLMConfigsAtom);
+
+ // Image gen configs
+ const {
+ data: userImageConfigs = [],
+ isFetching: imageConfigsLoading,
+ error: imageConfigsError,
+ } = useAtomValue(imageGenConfigsAtom);
+ const {
+ data: globalImageConfigs = [],
+ isFetching: globalImageConfigsLoading,
+ error: globalImageConfigsError,
+ } = useAtomValue(globalImageGenConfigsAtom);
+
+ // Vision LLM configs
+ const {
+ data: userVisionConfigs = [],
+ isFetching: visionConfigsLoading,
+ error: visionConfigsError,
+ } = useAtomValue(visionLLMConfigsAtom);
+ const {
+ data: globalVisionConfigs = [],
+ isFetching: globalVisionConfigsLoading,
+ error: globalVisionConfigsError,
+ } = useAtomValue(globalVisionLLMConfigsAtom);
+
+ // Preferences
+ const {
+ data: preferences = {},
+ isFetching: preferencesLoading,
+ error: preferencesError,
+ } = useAtomValue(llmPreferencesAtom);
+
+ const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
+
+ const [assignments, setAssignments] = useState>(() => ({
+ agent_llm_id: preferences.agent_llm_id ?? null,
+ image_generation_config_id: preferences.image_generation_config_id ?? null,
+ vision_llm_config_id: preferences.vision_llm_config_id ?? null,
+ }));
+
+ // Sync local state when preferences load/change. Without this, the selects
+ // stay on their initial (often empty) value while the query is in flight,
+ // so a saved assignment — including Auto mode (id 0) — never appears.
+ useEffect(() => {
+ setAssignments({
+ agent_llm_id: preferences.agent_llm_id ?? null,
+ image_generation_config_id: preferences.image_generation_config_id ?? null,
+ vision_llm_config_id: preferences.vision_llm_config_id ?? null,
+ });
+ }, [
+ preferences.agent_llm_id,
+ preferences.image_generation_config_id,
+ preferences.vision_llm_config_id,
+ ]);
+
+ const [savingRole, setSavingRole] = useState(null);
+
+ const handleRoleAssignment = useCallback(
+ async (prefKey: string, configId: string) => {
+ // "unassigned" clears the role (null). Every other option — including
+ // Auto mode, whose config id is 0 — must be sent as-is. Using a falsy
+ // check here (e.g. `value || undefined`) would drop id 0 and silently
+ // fail to persist Auto mode.
+ const value = configId === "unassigned" ? null : Number(configId);
+
+ setAssignments((prev) => ({ ...prev, [prefKey]: value }));
+ setSavingRole(prefKey);
+
+ try {
+ await updatePreferences({
+ search_space_id: searchSpaceId,
+ data: { [prefKey]: value },
+ });
+ toast.success("Role assignment updated");
+ } finally {
+ setSavingRole(null);
+ }
+ },
+ [updatePreferences, searchSpaceId]
+ );
+
+ // Combine global and custom LLM configs
+ const allLLMConfigs = [
+ ...globalConfigs.map((config) => ({ ...config, is_global: true })),
+ ...newLLMConfigs.filter((config) => config.id && config.id.toString().trim() !== ""),
+ ];
+
+ // Combine global and custom image gen configs
+ const allImageConfigs = [
+ ...globalImageConfigs.map((config) => ({ ...config, is_global: true })),
+ ...(userImageConfigs ?? []).filter((config) => config.id && config.id.toString().trim() !== ""),
+ ];
+
+ // Combine global and custom vision LLM configs
+ const allVisionConfigs = [
+ ...globalVisionConfigs.map((config) => ({ ...config, is_global: true })),
+ ...(userVisionConfigs ?? []).filter(
+ (config) => config.id && config.id.toString().trim() !== ""
+ ),
+ ];
+
+ const isLoading =
+ configsLoading ||
+ preferencesLoading ||
+ globalConfigsLoading ||
+ imageConfigsLoading ||
+ globalImageConfigsLoading ||
+ visionConfigsLoading ||
+ globalVisionConfigsLoading;
+ const hasError =
+ configsError ||
+ preferencesError ||
+ globalConfigsError ||
+ imageConfigsError ||
+ globalImageConfigsError ||
+ visionConfigsError ||
+ globalVisionConfigsError;
+ const hasAnyConfigs = allLLMConfigs.length > 0 || allImageConfigs.length > 0;
+
+ return (
+
+ {/* Header actions */}
+
+
+
+
+ {/* Error Alert */}
+ {hasError && (
+
+
+
+
+ {(configsError?.message ?? "Failed to load LLM configurations") ||
+ (preferencesError?.message ?? "Failed to load preferences") ||
+ (globalConfigsError?.message ?? "Failed to load global configurations")}
+
+
+
+ )}
+
+ {/* Loading Skeleton */}
+ {isLoading && (
+
+ {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* No configs warning */}
+ {!isLoading && !hasError && !hasAnyConfigs && (
+
+
+
+ No configurations found. Please add at least one LLM provider or image model in the
+ respective settings tabs before assigning roles.
+
+
+ )}
+
+ {/* Role Assignment Cards */}
+ {!isLoading && !hasError && hasAnyConfigs && (
+
+ {Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
+ const IconComponent = role.icon;
+ const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
+
+ // Pick the right config lists based on role type
+ const roleGlobalConfigs =
+ role.configType === "image"
+ ? globalImageConfigs
+ : role.configType === "vision"
+ ? globalVisionConfigs
+ : globalConfigs;
+ const roleUserConfigs =
+ role.configType === "image"
+ ? (userImageConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
+ : role.configType === "vision"
+ ? (userVisionConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
+ : newLLMConfigs.filter((c) => c.id && c.id.toString().trim() !== "");
+ const roleAllConfigs =
+ role.configType === "image"
+ ? allImageConfigs
+ : role.configType === "vision"
+ ? allVisionConfigs
+ : allLLMConfigs;
+
+ const assignedConfig = roleAllConfigs.find((config) => config.id === currentAssignment);
+ const isAssigned = !!assignedConfig;
+
+ return (
+
+
+
+ {/* Role Header */}
+
+
+
+
+
+
+
{role.title}
+
+ {role.description}
+
+
+
+ {savingRole === role.prefKey ? (
+
+ ) : isAssigned ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Selector */}
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/components/settings/model-connections-settings.tsx b/surfsense_web/components/settings/model-connections-settings.tsx
deleted file mode 100644
index 3b30b1558..000000000
--- a/surfsense_web/components/settings/model-connections-settings.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-"use client";
-
-import { useAtom, useAtomValue } from "jotai";
-import { Dot } from "lucide-react";
-import { updateModelRolesMutationAtom } from "@/atoms/model-connections/model-connections-mutation.atoms";
-import {
- globalModelConnectionsAtom,
- modelConnectionsAtom,
- modelRolesAtom,
-} from "@/atoms/model-connections/model-connections-query.atoms";
-import { Label } from "@/components/ui/label";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Separator } from "@/components/ui/separator";
-import type { ConnectionRead, ModelRead } from "@/contracts/types/model-connections.types";
-import { AUTO_PROVIDER_ICON_KEY, getProviderIcon } from "@/lib/provider-icons";
-import { ModelProviderConnectionsPanel } from "./model-connections/model-provider-connections-panel";
-import { capability, modelLabel } from "./model-connections/model-utils";
-import { providerDisplay, providerIcon } from "./model-connections/provider-metadata";
-
-function flattenModels(connections: ConnectionRead[]) {
- return connections.flatMap((connection) =>
- connection.models.map((model) => ({
- ...model,
- connectionName: providerDisplay(connection.provider).name,
- connectionId: connection.id,
- provider: connection.provider,
- }))
- );
-}
-
-function roleSelectValue(modelId: number | null | undefined, models: Array<{ id: number }>) {
- if (!modelId) return "0";
- return models.some((model) => model.id === modelId) ? String(modelId) : "0";
-}
-
-function renderAutoModeOption() {
- return (
-
-
- {getProviderIcon(AUTO_PROVIDER_ICON_KEY)}
- Auto mode
-
-
- );
-}
-
-export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: number }) {
- const [{ data: globalConnections = [] }] = useAtom(globalModelConnectionsAtom);
- const [{ data: connections = [] }] = useAtom(modelConnectionsAtom);
- const [{ data: roles }] = useAtom(modelRolesAtom);
- const updateRoles = useAtomValue(updateModelRolesMutationAtom);
-
- const allConnections = [...globalConnections, ...connections];
- const enabledModels = flattenModels(allConnections).filter((model) => model.enabled);
- const chatModels = enabledModels.filter((model) => capability(model, "chat"));
- const visionModels = enabledModels.filter((model) => capability(model, "vision"));
- const imageModels = enabledModels.filter((model) => capability(model, "image_gen"));
-
- function renderModelOption(model: ModelRead & { connectionName: string; provider: string }) {
- return (
-
-
- {providerIcon(model.provider)}
-
- {modelLabel(model)}
-
- {model.connectionName}
-
-
-
- );
- }
-
- return (
-
-
-
-
Model Roles
-
- Pick which enabled model powers chat, vision, and image generation for this search
- space.
-
-
-
-
-
-
- Primary model for chat responses and agent tasks. You can also change it from the
- chat.
-
-
-
-
-
-
- Used to understand images in uploads, documents, connectors, and automations. Falls
- back to chat model when possible.
-
-
-
-
-
-
Used when generating images in chat.
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/surfsense_web/components/settings/model-connections/azure-connect-form.tsx b/surfsense_web/components/settings/model-connections/azure-connect-form.tsx
deleted file mode 100644
index 451f053db..000000000
--- a/surfsense_web/components/settings/model-connections/azure-connect-form.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { useEffect, useState } from "react";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { ApiKeyField } from "./connect-fields";
-import {
- isValidAzureTargetUri,
- type ProviderConnectFormProps,
- parseAzureTargetUri,
-} from "./provider-metadata";
-
-/**
- * Azure OpenAI connect form. The user pastes a single Target URI, which we parse
- * into api base, api version, and the deployment name (seeded as the model).
- */
-export function AzureConnectForm({ onDraftChange }: ProviderConnectFormProps) {
- const [targetUri, setTargetUri] = useState("");
- const [apiKey, setApiKey] = useState("");
- const canSubmit = isValidAzureTargetUri(targetUri) && Boolean(apiKey.trim());
-
- useEffect(() => {
- const parsed = parseAzureTargetUri(targetUri);
- onDraftChange(
- {
- base_url: parsed?.origin ?? null,
- api_key: apiKey || null,
- extra: parsed?.apiVersion ? { api_version: parsed.apiVersion } : {},
- seedModelId: parsed?.deploymentName || undefined,
- },
- canSubmit
- );
- }, [apiKey, canSubmit, onDraftChange, targetUri]);
-
- return (
-
-
-
-
setTargetUri(event.target.value)}
- placeholder="https://your-resource.cognitiveservices.azure.com/openai/deployments/deployment-name/chat/completions?api-version=2025-01-01-preview"
- />
-
- Paste your endpoint target URI from Azure OpenAI (including API base, deployment name, and
- API version).
-
-
-
-
- );
-}
diff --git a/surfsense_web/components/settings/model-connections/bedrock-connect-form.tsx b/surfsense_web/components/settings/model-connections/bedrock-connect-form.tsx
deleted file mode 100644
index f76308421..000000000
--- a/surfsense_web/components/settings/model-connections/bedrock-connect-form.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-import { useEffect, useState } from "react";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { ApiKeyField } from "./connect-fields";
-import {
- AWS_REGION_OPTIONS,
- BEDROCK_AUTH_ACCESS_KEY,
- BEDROCK_AUTH_IAM,
- BEDROCK_AUTH_LONG_TERM_API_KEY,
- type ProviderConnectFormProps,
-} from "./provider-metadata";
-
-/**
- * Amazon Bedrock connect form. Region + auth method drive which AWS credentials
- * are collected; everything rides along in `extra.litellm_params`.
- */
-export function BedrockConnectForm({ onDraftChange }: ProviderConnectFormProps) {
- const [region, setRegion] = useState("");
- const [authMethod, setAuthMethod] = useState(BEDROCK_AUTH_ACCESS_KEY);
- const [accessKeyId, setAccessKeyId] = useState("");
- const [secretAccessKey, setSecretAccessKey] = useState("");
- const [bearerToken, setBearerToken] = useState("");
-
- const canSubmit = (() => {
- if (!region) return false;
- if (authMethod === BEDROCK_AUTH_ACCESS_KEY) {
- return Boolean(accessKeyId && secretAccessKey);
- }
- if (authMethod === BEDROCK_AUTH_LONG_TERM_API_KEY) {
- return Boolean(bearerToken);
- }
- return true;
- })();
-
- useEffect(() => {
- const params: Record = { aws_region_name: region };
- if (authMethod === BEDROCK_AUTH_ACCESS_KEY) {
- params.aws_access_key_id = accessKeyId;
- params.aws_secret_access_key = secretAccessKey;
- } else if (authMethod === BEDROCK_AUTH_LONG_TERM_API_KEY) {
- params.aws_bearer_token_bedrock = bearerToken;
- }
- onDraftChange({ base_url: null, api_key: null, extra: { litellm_params: params } }, canSubmit);
- }, [accessKeyId, authMethod, bearerToken, canSubmit, onDraftChange, region, secretAccessKey]);
-
- return (
-
-
-
-
-
-
-
-
-
- {authMethod === BEDROCK_AUTH_ACCESS_KEY ? (
- <>
-
-
- setAccessKeyId(event.target.value)}
- placeholder="Enter your AWS access key ID"
- />
-
-
- >
- ) : null}
- {authMethod === BEDROCK_AUTH_LONG_TERM_API_KEY ? (
-
- ) : null}
- {authMethod === BEDROCK_AUTH_IAM ? (
-
- SurfSense will use the IAM role attached to the environment it's running in to
- authenticate.
-
- ) : null}
-
- Add Bedrock model IDs from the provider's settings after connecting.
-
-
- );
-}
diff --git a/surfsense_web/components/settings/model-connections/connect-fields.tsx b/surfsense_web/components/settings/model-connections/connect-fields.tsx
deleted file mode 100644
index 584fb98b0..000000000
--- a/surfsense_web/components/settings/model-connections/connect-fields.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { Eye, EyeOff } from "lucide-react";
-import type { ReactNode } from "react";
-import { useState } from "react";
-import { Button } from "@/components/ui/button";
-import { DialogFooter } from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Spinner } from "@/components/ui/spinner";
-
-interface ApiBaseUrlFieldProps {
- value: string;
- onChange: (value: string) => void;
- /** Placeholder, typically the provider's prefilled default base URL. */
- placeholder?: string;
- hint?: ReactNode;
-}
-
-/** Shared API Base URL input. The prefilled default is passed in via `value`. */
-export function ApiBaseUrlField({ value, onChange, placeholder, hint }: ApiBaseUrlFieldProps) {
- return (
-
-
-
onChange(event.target.value)}
- placeholder={placeholder || "https://api.example.com/v1"}
- />
- {hint ?
{hint}
: null}
-
- );
-}
-
-interface ApiKeyFieldProps {
- value: string;
- onChange: (value: string) => void;
- label?: string;
- placeholder?: string;
-}
-
-/** Shared masked API Key input. */
-export function ApiKeyField({
- value,
- onChange,
- label = "API Key",
- placeholder = "API key",
-}: ApiKeyFieldProps) {
- const [showApiKey, setShowApiKey] = useState(false);
-
- return (
-
-
-
- onChange(event.target.value)}
- placeholder={placeholder}
- type={showApiKey ? "text" : "password"}
- className="pr-11"
- />
-
-
-
- );
-}
-
-interface ConnectFormFooterProps {
- onCancel: () => void;
- onSubmit: () => void;
- canSubmit: boolean;
- isPending: boolean;
-}
-
-/** Shared Cancel / Connect footer for every provider connect form. */
-export function ConnectFormFooter({
- onCancel,
- onSubmit,
- canSubmit,
- isPending,
-}: ConnectFormFooterProps) {
- return (
-
-
-
-
- );
-}
diff --git a/surfsense_web/components/settings/model-connections/connection-card.tsx b/surfsense_web/components/settings/model-connections/connection-card.tsx
deleted file mode 100644
index b482cac9f..000000000
--- a/surfsense_web/components/settings/model-connections/connection-card.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-"use client";
-
-import { useAtomValue } from "jotai";
-import { Trash2 } from "lucide-react";
-import { deleteModelConnectionMutationAtom } from "@/atoms/model-connections/model-connections-mutation.atoms";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import type { ConnectionRead } from "@/contracts/types/model-connections.types";
-import { ConnectionSettingsDialog } from "./connection-settings-dialog";
-import { providerDisplay, providerIcon } from "./provider-metadata";
-
-export function ConnectionCard({ connection }: { connection: ConnectionRead }) {
- const deleteConnection = useAtomValue(deleteModelConnectionMutationAtom);
-
- const providerMeta = providerDisplay(connection.provider);
- const providerLabel = providerMeta.name;
-
- function deleteCurrentConnection() {
- deleteConnection.mutate(connection.id);
- }
-
- return (
-
-
-
-
- {providerIcon(connection.provider)}
- {providerLabel}
- {connection.scope === "GLOBAL" ? (
-
- Default
-
- ) : null}
-
-
- {connection.base_url || "Provider default endpoint"}
-
-
-
-
-
-
-
-
-
-
- Delete this provider?
-
- {providerLabel} and all of
- its models will be removed from this search space. This cannot be undone.
-
-
-
- Cancel
-
- Delete
-
-
-
-
-
-
-
- );
-}
diff --git a/surfsense_web/components/settings/model-connections/connection-settings-dialog.tsx b/surfsense_web/components/settings/model-connections/connection-settings-dialog.tsx
deleted file mode 100644
index 1f16c3bd0..000000000
--- a/surfsense_web/components/settings/model-connections/connection-settings-dialog.tsx
+++ /dev/null
@@ -1,333 +0,0 @@
-import { useAtomValue } from "jotai";
-import { Eye, EyeOff, Settings } from "lucide-react";
-import { useMemo, useState } from "react";
-import {
- addManualModelMutationAtom,
- bulkUpdateModelsMutationAtom,
- discoverConnectionModelsMutationAtom,
- testPreviewModelMutationAtom,
- updateModelConnectionMutationAtom,
-} from "@/atoms/model-connections/model-connections-mutation.atoms";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
-import { Spinner } from "@/components/ui/spinner";
-import type {
- ConnectionRead,
- ConnectionUpdateRequest,
-} from "@/contracts/types/model-connections.types";
-import { capability, type SelectableModel } from "./model-utils";
-import { ModelsSelectionPanel } from "./models-selection-panel";
-import { providerIcon } from "./provider-metadata";
-
-interface ConnectionSettingsDialogProps {
- connection: ConnectionRead;
- providerLabel: string;
-}
-
-function enabledModelIds(models: SelectableModel[]) {
- return new Set(
- models
- .filter((model) => typeof model.id === "number" && model.enabled)
- .map((model) => Number(model.id))
- );
-}
-
-export function ConnectionSettingsDialog({
- connection,
- providerLabel,
-}: ConnectionSettingsDialogProps) {
- const discoverModels = useAtomValue(discoverConnectionModelsMutationAtom);
- const testPreviewModel = useAtomValue(testPreviewModelMutationAtom);
- const updateConnection = useAtomValue(updateModelConnectionMutationAtom);
- const addManualModel = useAtomValue(addManualModelMutationAtom);
- const bulkUpdateModels = useAtomValue(bulkUpdateModelsMutationAtom);
-
- const allowlist = Array.isArray(connection.extra?.model_ids)
- ? (connection.extra.model_ids as string[])
- : [];
- const [isOpen, setIsOpen] = useState(false);
- const [baseUrlDraft, setBaseUrlDraft] = useState(connection.base_url ?? "");
- const [apiKeyDraft, setApiKeyDraft] = useState("");
- const [showApiKey, setShowApiKey] = useState(false);
- const [allowlistText, setAllowlistText] = useState(allowlist.join(", "));
- const [isSavingConnectionSettings, setIsSavingConnectionSettings] = useState(false);
- const [draftEnabledModelIds, setDraftEnabledModelIds] = useState(() =>
- enabledModelIds(connection.models)
- );
-
- const isLocal =
- connection.provider === "ollama_chat" ||
- connection.provider === "lm_studio" ||
- !connection.base_url?.startsWith("https");
- const hasConnectionChanges =
- baseUrlDraft.trim() !== (connection.base_url ?? "") ||
- apiKeyDraft.trim() !== (connection.api_key ?? "");
- const draftModels = useMemo(
- () =>
- connection.models.map((model) =>
- typeof model.id === "number"
- ? { ...model, enabled: draftEnabledModelIds.has(model.id) }
- : model
- ),
- [connection.models, draftEnabledModelIds]
- );
- const hasModelChanges = connection.models.some(
- (model) => typeof model.id === "number" && draftEnabledModelIds.has(model.id) !== model.enabled
- );
- const canUpdate = hasConnectionChanges || hasModelChanges;
-
- function handleOpenChange(open: boolean) {
- setIsOpen(open);
- if (open) {
- setBaseUrlDraft(connection.base_url ?? "");
- setApiKeyDraft(connection.api_key ?? "");
- setShowApiKey(false);
- setAllowlistText(allowlist.join(", "));
- setIsSavingConnectionSettings(false);
- setDraftEnabledModelIds(enabledModelIds(connection.models));
- }
- }
-
- async function saveModelChanges() {
- const toEnable = connection.models
- .filter((model) => typeof model.id === "number" && draftEnabledModelIds.has(model.id))
- .filter((model) => !model.enabled)
- .map((model) => Number(model.id));
- const toDisable = connection.models
- .filter((model) => typeof model.id === "number" && !draftEnabledModelIds.has(model.id))
- .filter((model) => model.enabled)
- .map((model) => Number(model.id));
-
- if (toEnable.length > 0) {
- await bulkUpdateModels.mutateAsync({
- connectionId: connection.id,
- data: { model_ids: toEnable, enabled: true },
- });
- }
- if (toDisable.length > 0) {
- await bulkUpdateModels.mutateAsync({
- connectionId: connection.id,
- data: { model_ids: toDisable, enabled: false },
- });
- }
- }
-
- async function saveConnectionSettings() {
- if (isSavingConnectionSettings) return;
-
- const data: ConnectionUpdateRequest = {
- base_url: baseUrlDraft.trim() || null,
- };
-
- if (apiKeyDraft.trim() !== (connection.api_key ?? "")) {
- data.api_key = apiKeyDraft.trim() || null;
- }
- const apiKeyForTest = Object.hasOwn(data, "api_key")
- ? (data.api_key ?? null)
- : (connection.api_key ?? null);
-
- const enabledModels = draftModels.filter((model) => model.enabled);
- const testModel = enabledModels.find((model) => capability(model, "chat")) ?? enabledModels[0];
- setIsSavingConnectionSettings(true);
- try {
- if (hasConnectionChanges) {
- if (testModel) {
- const result = await testPreviewModel.mutateAsync({
- provider: connection.provider,
- base_url: data.base_url,
- api_key: apiKeyForTest,
- scope: "SEARCH_SPACE",
- search_space_id: connection.search_space_id,
- extra: connection.extra ?? {},
- enabled: connection.enabled,
- models: [],
- model_id: testModel.model_id,
- });
- if (!result.ok) return;
- }
- await updateConnection.mutateAsync({ id: connection.id, data });
- setApiKeyDraft("");
- }
-
- if (hasModelChanges) {
- await saveModelChanges();
- }
- } finally {
- setIsSavingConnectionSettings(false);
- }
- }
-
- function saveAllowlist() {
- const ids = allowlistText
- .split(",")
- .map((value) => value.trim())
- .filter(Boolean);
- updateConnection.mutate({
- id: connection.id,
- data: { extra: { ...(connection.extra ?? {}), model_ids: ids } },
- });
- }
-
- function handleToggleModel(model: SelectableModel, enabled: boolean) {
- if (typeof model.id !== "number") return;
- const modelId = model.id;
- setDraftEnabledModelIds((current) => {
- const next = new Set(current);
- if (enabled) {
- next.add(modelId);
- } else {
- next.delete(modelId);
- }
- return next;
- });
- }
-
- function handleBulkToggle(models: SelectableModel[], enabled: boolean) {
- const modelIds = models
- .map((model) => model.id)
- .filter((id): id is number => typeof id === "number");
- if (modelIds.length === 0) return;
- setDraftEnabledModelIds((current) => {
- const next = new Set(current);
- for (const id of modelIds) {
- if (enabled) {
- next.add(id);
- } else {
- next.delete(id);
- }
- }
- return next;
- });
- }
-
- return (
-
- );
-}
diff --git a/surfsense_web/components/settings/model-connections/default-connect-form.tsx b/surfsense_web/components/settings/model-connections/default-connect-form.tsx
deleted file mode 100644
index e3111202d..000000000
--- a/surfsense_web/components/settings/model-connections/default-connect-form.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useEffect, useState } from "react";
-import { ApiBaseUrlField, ApiKeyField } from "./connect-fields";
-import type { ProviderConnectFormProps } from "./provider-metadata";
-
-const OPTIONAL_API_KEY_PROVIDERS = new Set(["ollama_chat", "lm_studio", "openai_compatible"]);
-
-function baseUrlHint(provider: string) {
- if (provider === "ollama_chat" || provider === "lm_studio") {
- return "For local servers, use host.docker.internal instead of localhost.";
- }
- if (provider === "openai_compatible") {
- return "Enter the full endpoint URL.";
- }
- if (provider === "openai" || provider === "anthropic" || provider === "openrouter") {
- return "Override only if you route through a proxy or gateway.";
- }
- return undefined;
-}
-
-/**
- * Connect form for OpenAI-compatible / native key providers (OpenAI, Anthropic,
- * OpenRouter, OpenAI-Compatible, LM Studio, Ollama, …). The base URL is
- * prefilled from the provider default.
- */
-export function DefaultConnectForm({
- provider,
- defaultBaseUrl,
- baseUrlRequired,
- onDraftChange,
-}: ProviderConnectFormProps) {
- const [baseUrl, setBaseUrl] = useState(defaultBaseUrl);
- const [apiKey, setApiKey] = useState("");
- const isApiKeyOptional = OPTIONAL_API_KEY_PROVIDERS.has(provider);
- const hint = baseUrlHint(provider);
- const apiKeyValue = apiKey.trim();
- const canSubmit =
- !(baseUrlRequired && !baseUrl.trim()) && (isApiKeyOptional || Boolean(apiKeyValue));
-
- useEffect(() => {
- onDraftChange(
- { base_url: baseUrl || null, api_key: apiKeyValue || null, extra: {} },
- canSubmit
- );
- }, [apiKeyValue, baseUrl, canSubmit, onDraftChange]);
-
- return (
-
- );
-}
diff --git a/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx b/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx
deleted file mode 100644
index a703ab1c8..000000000
--- a/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx
+++ /dev/null
@@ -1,299 +0,0 @@
-"use client";
-
-import { useAtomValue } from "jotai";
-import { type ReactNode, useState } from "react";
-import { toast } from "sonner";
-import {
- createModelConnectionMutationAtom,
- previewConnectionModelsMutationAtom,
- testPreviewModelMutationAtom,
-} from "@/atoms/model-connections/model-connections-mutation.atoms";
-import { modelProvidersAtom } from "@/atoms/model-connections/model-connections-query.atoms";
-import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import type { ConnectionRead, ModelSelection } from "@/contracts/types/model-connections.types";
-import { ConnectionCard } from "./connection-card";
-import { capability, type SelectableModel } from "./model-utils";
-import { ProviderConnectDialog } from "./provider-connect-dialog";
-import {
- type ConnectionDraft,
- PROVIDER_ORDER,
- providerDisplay,
- providerIcon,
-} from "./provider-metadata";
-
-interface ModelProviderConnectionsPanelProps {
- searchSpaceId: number;
- connections: ConnectionRead[];
- className?: string;
- addProviderTitle?: string;
- addProviderDescription?: string;
- availableProvidersTitle?: string;
- footerAction?: ReactNode;
- showAddProviderHeader?: boolean;
-}
-
-function toModelSelection(model: SelectableModel): ModelSelection {
- return {
- model_id: model.model_id,
- display_name: model.display_name,
- source: model.source || "DISCOVERED",
- supports_chat: model.supports_chat,
- max_input_tokens: model.max_input_tokens,
- supports_image_input: model.supports_image_input,
- supports_tools: model.supports_tools,
- supports_image_generation: model.supports_image_generation,
- enabled: model.enabled,
- metadata: "metadata" in model ? (model.metadata ?? {}) : (model.catalog ?? {}),
- };
-}
-
-export function ModelProviderConnectionsPanel({
- searchSpaceId,
- connections,
- className,
- addProviderTitle = "Add Provider",
- addProviderDescription = "SurfSense supports popular providers and self-hosted model endpoints.",
- availableProvidersTitle = "Available Providers",
- footerAction,
- showAddProviderHeader = true,
-}: ModelProviderConnectionsPanelProps) {
- const { data: providers = [] } = useAtomValue(modelProvidersAtom);
- const createConnection = useAtomValue(createModelConnectionMutationAtom);
- const previewModels = useAtomValue(previewConnectionModelsMutationAtom);
- const testPreviewModel = useAtomValue(testPreviewModelMutationAtom);
-
- const [isAddProviderOpen, setIsAddProviderOpen] = useState(false);
- const [provider, setProvider] = useState("openai_compatible");
- const [connectModels, setConnectModels] = useState([]);
- const selectedProvider = providers.find((item) => item.provider === provider);
-
- const sortedProviders = [...providers].sort((left, right) => {
- const leftIndex = PROVIDER_ORDER.indexOf(left.provider);
- const rightIndex = PROVIDER_ORDER.indexOf(right.provider);
- if (leftIndex !== -1 || rightIndex !== -1) {
- return (
- (leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex) -
- (rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex)
- );
- }
- return providerDisplay(left.provider).name.localeCompare(providerDisplay(right.provider).name);
- });
-
- function resetConnectState() {
- setConnectModels([]);
- }
-
- function handleConnectOpenChange(open: boolean) {
- setIsAddProviderOpen(open);
- if (!open) {
- resetConnectState();
- }
- }
-
- function mergePreviewModels(fetchedModels: SelectableModel[]) {
- setConnectModels((current) => {
- const currentById = new Map(current.map((model) => [model.model_id, model]));
- return fetchedModels.map((model) => {
- const prior = currentById.get(model.model_id);
- return {
- ...toModelSelection(model),
- enabled: prior ? prior.enabled : model.enabled,
- };
- });
- });
- }
-
- function connectionModelsForDraft(draft: ConnectionDraft) {
- const models = [...connectModels];
- if (draft.seedModelId && !models.some((model) => model.model_id === draft.seedModelId)) {
- models.push({
- model_id: draft.seedModelId,
- display_name: draft.seedModelId,
- source: "MANUAL",
- enabled: true,
- metadata: {},
- });
- }
- return models;
- }
-
- function representativeTestModel(models: ModelSelection[]) {
- const enabledModels = models.filter((model) => model.enabled);
- return enabledModels.find((model) => capability(model, "chat")) ?? enabledModels[0];
- }
-
- // Each provider connect form builds its own credential payload; the backend
- // resolver (`to_litellm`) forwards `extra.litellm_params` straight to LiteLLM.
- function handleCreate(draft: ConnectionDraft) {
- const models = connectionModelsForDraft(draft);
- const testModel = representativeTestModel(models);
- if (!testModel) {
- toast.error("Select at least one model before connecting");
- return;
- }
-
- const request = {
- provider,
- base_url: draft.base_url,
- api_key: draft.api_key,
- scope: "SEARCH_SPACE" as const,
- search_space_id: searchSpaceId,
- extra: draft.extra,
- enabled: true,
- models,
- };
-
- testPreviewModel.mutate(
- { ...request, model_id: testModel.model_id },
- {
- onSuccess: (result) => {
- if (!result.ok) return;
- createConnection.mutate(request, {
- onSuccess: () => {
- setIsAddProviderOpen(false);
- resetConnectState();
- },
- });
- },
- }
- );
- }
-
- function openProviderDialog(providerId: string) {
- resetConnectState();
- setProvider(providerId);
- setIsAddProviderOpen(true);
- if (providerId === "vertex_ai") {
- previewModels.mutate(
- {
- provider: providerId,
- base_url: null,
- api_key: null,
- scope: "SEARCH_SPACE",
- search_space_id: searchSpaceId,
- extra: {},
- enabled: true,
- models: [],
- },
- {
- onSuccess: mergePreviewModels,
- }
- );
- }
- }
-
- function refreshConnectModels(draft: ConnectionDraft) {
- previewModels.mutate(
- {
- provider,
- base_url: draft.base_url,
- api_key: draft.api_key,
- scope: "SEARCH_SPACE",
- search_space_id: searchSpaceId,
- extra: draft.extra,
- enabled: true,
- models: [],
- },
- {
- onSuccess: mergePreviewModels,
- }
- );
- }
-
- function addConnectModel(modelId: string) {
- setConnectModels((current) => {
- if (current.some((model) => model.model_id === modelId)) return current;
- return [
- ...current,
- {
- model_id: modelId,
- display_name: modelId,
- source: "MANUAL",
- enabled: true,
- metadata: {},
- },
- ];
- });
- }
-
- function toggleConnectModel(model: SelectableModel, enabled: boolean) {
- setConnectModels((current) =>
- current.map((item) => (item.model_id === model.model_id ? { ...item, enabled } : item))
- );
- }
-
- function bulkToggleConnectModels(models: SelectableModel[], enabled: boolean) {
- const modelIds = new Set(models.map((model) => model.model_id));
- setConnectModels((current) =>
- current.map((item) => (modelIds.has(item.model_id) ? { ...item, enabled } : item))
- );
- }
-
- return (
-
-
- {showAddProviderHeader ? (
-
-
{addProviderTitle}
-
{addProviderDescription}
-
- ) : null}
-
- {sortedProviders.map((item) => {
- const meta = providerDisplay(item.provider);
-
- return (
-
- );
- })}
-
-
-
-
-
- {connections.length > 0 ? (
-
-
-
{availableProvidersTitle}
-
- {connections.map((connection) => (
-
- ))}
-
-
- ) : null}
- {footerAction ?
{footerAction}
: null}
-
- );
-}
diff --git a/surfsense_web/components/settings/model-connections/model-utils.ts b/surfsense_web/components/settings/model-connections/model-utils.ts
deleted file mode 100644
index 2887f2179..000000000
--- a/surfsense_web/components/settings/model-connections/model-utils.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import type { ModelPreviewRead, ModelRead } from "@/contracts/types/model-connections.types";
-
-export type ModelCapabilityFilter = "chat" | "vision" | "image_gen";
-
-export const MODEL_CAPABILITY_FILTERS: { key: ModelCapabilityFilter; label: string }[] = [
- { key: "chat", label: "Chat" },
- { key: "vision", label: "Vision" },
- { key: "image_gen", label: "Image" },
-];
-
-export type SelectableModel = (ModelRead | ModelPreviewRead) & {
- id?: number | string;
- connection_id?: number;
-};
-
-export function modelLabel(model: SelectableModel) {
- return model.display_name || model.model_id;
-}
-
-export function capability(model: SelectableModel, key: ModelCapabilityFilter) {
- if (key === "chat") return Boolean(model.supports_chat);
- if (key === "vision") return Boolean(model.supports_image_input);
- return Boolean(model.supports_image_generation);
-}
-
-export function capabilityLabels(model: SelectableModel) {
- return MODEL_CAPABILITY_FILTERS.filter((filter) => capability(model, filter.key))
- .map((filter) => filter.label.toLowerCase())
- .join(", ");
-}
diff --git a/surfsense_web/components/settings/model-connections/models-selection-panel.tsx b/surfsense_web/components/settings/model-connections/models-selection-panel.tsx
deleted file mode 100644
index 3c6990afb..000000000
--- a/surfsense_web/components/settings/model-connections/models-selection-panel.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-import { RefreshCw } from "lucide-react";
-import { useState } from "react";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Input } from "@/components/ui/input";
-import { Spinner } from "@/components/ui/spinner";
-import {
- capability,
- capabilityLabels,
- MODEL_CAPABILITY_FILTERS,
- type ModelCapabilityFilter,
- modelLabel,
- type SelectableModel,
-} from "./model-utils";
-
-interface ModelsSelectionPanelProps {
- models: SelectableModel[];
- description?: string;
- emptyMessage?: string;
- manualInputPlaceholder?: string;
- refreshLabel?: string;
- isRefreshing?: boolean;
- isAddingManual?: boolean;
- isUpdatingModel?: boolean;
- isBulkUpdating?: boolean;
- onRefresh?: () => void;
- onAddManual?: (modelId: string) => void;
- onToggleModel?: (model: SelectableModel, enabled: boolean) => void;
- onBulkToggle?: (models: SelectableModel[], enabled: boolean) => void;
-}
-
-export function ModelsSelectionPanel({
- models,
- description = "Select models to make available for this provider.",
- emptyMessage = "No models available.",
- manualInputPlaceholder = "Add a model ID manually",
- refreshLabel = "Refresh models",
- isRefreshing = false,
- isAddingManual = false,
- isUpdatingModel = false,
- isBulkUpdating = false,
- onRefresh,
- onAddManual,
- onToggleModel,
- onBulkToggle,
-}: ModelsSelectionPanelProps) {
- const [manualModelId, setManualModelId] = useState("");
- const [modelFilter, setModelFilter] = useState(null);
-
- const filteredModels = modelFilter
- ? models.filter((model) => capability(model, modelFilter))
- : models;
- const allFilteredModelsEnabled =
- filteredModels.length > 0 && filteredModels.every((model) => model.enabled);
-
- function addModel() {
- const modelId = manualModelId.trim();
- if (!modelId || !onAddManual) return;
- onAddManual(modelId);
- setManualModelId("");
- }
-
- function toggleFilteredModels() {
- const nextEnabled = !allFilteredModelsEnabled;
- const changedModels = filteredModels.filter((model) => model.enabled !== nextEnabled);
- if (changedModels.length === 0) return;
- onBulkToggle?.(changedModels, nextEnabled);
- }
-
- return (
-
-
-
-
Models
-
{description}
-
-
-
- {onRefresh ? (
-
- ) : null}
-
-
-
- {onAddManual ? (
-
- setManualModelId(event.target.value)}
- onKeyDown={(event) => {
- if (event.key === "Enter") {
- event.preventDefault();
- addModel();
- }
- }}
- placeholder={manualInputPlaceholder}
- />
-
-
- ) : null}
-
- {models.length > 0 ? (
-
- Filter models
- {MODEL_CAPABILITY_FILTERS.map((filter) => {
- const count = models.filter((model) => capability(model, filter.key)).length;
- const isActive = modelFilter === filter.key;
-
- return (
-
- );
- })}
-
- ) : null}
-
-
- {models.length === 0 ? (
-
- {emptyMessage}
-
- ) : null}
- {filteredModels.length === 0 && modelFilter ? (
-
- No{" "}
- {MODEL_CAPABILITY_FILTERS.find(
- (filter) => filter.key === modelFilter
- )?.label.toLowerCase()}{" "}
- models found on this connection.
-
- ) : null}
-
- {filteredModels.map((model) => (
-
-
onToggleModel?.(model, checked === true)}
- disabled={!onToggleModel || isUpdatingModel}
- />
-
-
- {modelLabel(model)}
- {model.source === "MANUAL" ? (
-
- manual
-
- ) : null}
-
-
- {capabilityLabels(model) || "No discovered capabilities"}
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx b/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx
deleted file mode 100644
index 2eee2cf8c..000000000
--- a/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-import { useCallback, useRef, useState } from "react";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Separator } from "@/components/ui/separator";
-import type { ModelProviderRead } from "@/contracts/types/model-connections.types";
-import { AzureConnectForm } from "./azure-connect-form";
-import { BedrockConnectForm } from "./bedrock-connect-form";
-import { ConnectFormFooter } from "./connect-fields";
-import { DefaultConnectForm } from "./default-connect-form";
-import type { SelectableModel } from "./model-utils";
-import { ModelsSelectionPanel } from "./models-selection-panel";
-import {
- type ConnectionDraft,
- type ProviderConnectFormProps,
- providerDefaultBaseUrl,
- providerDisplay,
- providerIcon,
-} from "./provider-metadata";
-import { VertexConnectForm } from "./vertex-connect-form";
-
-interface ProviderConnectDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- provider: string;
- selectedProvider?: ModelProviderRead;
- isPending: boolean;
- onSubmit: (draft: ConnectionDraft) => void;
- previewModels?: SelectableModel[];
- isPreviewingModels?: boolean;
- onPreviewModels?: (draft: ConnectionDraft) => void;
- onAddPreviewModel?: (modelId: string) => void;
- onTogglePreviewModel?: (model: SelectableModel, enabled: boolean) => void;
- onBulkTogglePreviewModels?: (models: SelectableModel[], enabled: boolean) => void;
-}
-
-/**
- * Shared dialog shell for the "Add Provider" flow. It owns the header and routes
- * to the provider-specific connect form. Forms remount on open (Radix unmounts
- * closed content), so each gets fresh, prefilled state.
- */
-export function ProviderConnectDialog({
- open,
- onOpenChange,
- provider,
- selectedProvider,
- isPending,
- onSubmit,
- previewModels = [],
- isPreviewingModels = false,
- onPreviewModels,
- onAddPreviewModel,
- onTogglePreviewModel,
- onBulkTogglePreviewModels,
-}: ProviderConnectDialogProps) {
- const meta = providerDisplay(provider);
- const isAzure = provider === "azure";
- const isBedrock = provider === "bedrock";
- const isVertex = provider === "vertex_ai";
- const titleRef = useRef(null);
- const [currentDraft, setCurrentDraft] = useState({
- base_url: null,
- api_key: null,
- extra: {},
- });
- const [canSubmit, setCanSubmit] = useState(false);
-
- const handleDraftChange = useCallback((draft: ConnectionDraft, nextCanSubmit: boolean) => {
- setCurrentDraft(draft);
- setCanSubmit(nextCanSubmit);
- }, []);
-
- const formProps: ProviderConnectFormProps = {
- provider,
- defaultBaseUrl: providerDefaultBaseUrl(provider, selectedProvider?.default_base_url),
- baseUrlRequired: Boolean(selectedProvider?.base_url_required),
- onDraftChange: handleDraftChange,
- };
-
- const modelDescription = (() => {
- if (isAzure) {
- return "Select the models to enable for Azure OpenAI";
- }
- if (isBedrock) {
- return "Select the models to enable for Amazon Bedrock";
- }
- if (isVertex) {
- return "Select the models to enable for Gemini";
- }
- return "Select the models to enable for this provider";
- })();
-
- const canRefreshModels = !isAzure && !isVertex && (!isBedrock || canSubmit);
- const hasEnabledModel =
- previewModels.some((model) => model.enabled) || Boolean(currentDraft.seedModelId);
- const canConnect = canSubmit && hasEnabledModel;
-
- return (
-
- );
-}
diff --git a/surfsense_web/components/settings/model-connections/provider-metadata.tsx b/surfsense_web/components/settings/model-connections/provider-metadata.tsx
deleted file mode 100644
index 8b8a877b9..000000000
--- a/surfsense_web/components/settings/model-connections/provider-metadata.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { getProviderIcon } from "@/lib/provider-icons";
-
-export const PROVIDER_ORDER = [
- "openai",
- "anthropic",
- "vertex_ai",
- "bedrock",
- "azure",
- "openrouter",
- "ollama_chat",
- "lm_studio",
- "openai_compatible",
-];
-
-export const PROVIDER_DISPLAY: Record<
- string,
- { name: string; subtitle: string; iconKey?: string; defaultBaseUrl?: string }
-> = {
- anthropic: {
- name: "Claude",
- subtitle: "Anthropic",
- iconKey: "claude",
- defaultBaseUrl: "https://api.anthropic.com/v1",
- },
- azure: { name: "Azure OpenAI", subtitle: "Microsoft Azure", iconKey: "azure" },
- bedrock: { name: "Amazon Bedrock", subtitle: "AWS", iconKey: "bedrock" },
- lm_studio: { name: "LM Studio", subtitle: "LM Studio", iconKey: "lm_studio" },
- ollama_chat: { name: "Ollama", subtitle: "Ollama", iconKey: "ollama" },
- openai: {
- name: "GPT",
- subtitle: "OpenAI",
- iconKey: "openai",
- defaultBaseUrl: "https://api.openai.com/v1",
- },
- openai_compatible: {
- name: "OpenAI-Compatible",
- subtitle: "OpenAI-compatible endpoint",
- iconKey: "custom",
- },
- openrouter: {
- name: "OpenRouter",
- subtitle: "OpenRouter",
- iconKey: "openrouter",
- defaultBaseUrl: "https://openrouter.ai/api/v1",
- },
- vertex_ai: { name: "Gemini", subtitle: "Google Cloud Vertex AI", iconKey: "vertex_ai" },
-};
-
-export function providerDisplay(provider: string) {
- const fallback = provider
- .split("_")
- .filter(Boolean)
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
- .join(" ");
-
- return (
- PROVIDER_DISPLAY[provider] ?? {
- name: fallback || provider,
- subtitle: provider,
- iconKey: provider,
- }
- );
-}
-
-export function providerIcon(provider: string, className = "size-4") {
- return getProviderIcon(providerDisplay(provider).iconKey ?? provider, { className });
-}
-
-export function providerDefaultBaseUrl(provider: string, registryDefault?: string | null) {
- return registryDefault ?? PROVIDER_DISPLAY[provider]?.defaultBaseUrl ?? "";
-}
-
-export const AWS_REGION_OPTIONS = [
- "us-east-1",
- "us-east-2",
- "us-west-2",
- "us-gov-east-1",
- "us-gov-west-1",
- "ap-northeast-1",
- "ap-south-1",
- "ap-southeast-1",
- "ap-southeast-2",
- "ap-east-1",
- "ca-central-1",
- "eu-central-1",
- "eu-west-2",
-];
-
-export const VERTEX_DEFAULT_LOCATION = "global";
-
-export const BEDROCK_AUTH_IAM = "iam";
-export const BEDROCK_AUTH_ACCESS_KEY = "access_key";
-export const BEDROCK_AUTH_LONG_TERM_API_KEY = "long_term_api_key";
-
-export const VERTEX_AUTH_SERVICE_ACCOUNT = "service_account_json";
-export const VERTEX_AUTH_WORKLOAD_IDENTITY = "workload_identity";
-
-// Mirrors Onyx's Azure "Target URI" parser: the user pastes the full endpoint
-// (e.g. https://res.cognitiveservices.azure.com/openai/deployments//chat/completions?api-version=)
-// which we split into api base (origin), api version, and deployment name.
-export function parseAzureTargetUri(rawUri: string) {
- try {
- const url = new URL(rawUri);
- const deploymentMatch = url.pathname.match(/\/openai\/deployments\/([^/]+)/i);
- return {
- origin: url.origin,
- apiVersion: url.searchParams.get("api-version")?.trim() ?? "",
- deploymentName: deploymentMatch?.[1] ? deploymentMatch[1].toLowerCase() : "",
- isResponsesPath: /\/openai\/responses/i.test(url.pathname),
- };
- } catch {
- return null;
- }
-}
-
-export function isValidAzureTargetUri(rawUri: string) {
- const parsed = parseAzureTargetUri(rawUri);
- if (!parsed) return false;
- return Boolean(parsed.apiVersion) && (Boolean(parsed.deploymentName) || parsed.isResponsesPath);
-}
-
-/** Connection payload produced by a provider connect form. */
-export interface ConnectionDraft {
- base_url: string | null;
- api_key: string | null;
- extra: Record;
- /** Model id to seed after creation (providers without discovery, e.g. Azure). */
- seedModelId?: string;
-}
-
-/** Props shared by every provider-specific connect form. */
-export interface ProviderConnectFormProps {
- provider: string;
- defaultBaseUrl: string;
- baseUrlRequired: boolean;
- onDraftChange: (draft: ConnectionDraft, canSubmit: boolean) => void;
-}
diff --git a/surfsense_web/components/settings/model-connections/vertex-connect-form.tsx b/surfsense_web/components/settings/model-connections/vertex-connect-form.tsx
deleted file mode 100644
index 1027742bc..000000000
--- a/surfsense_web/components/settings/model-connections/vertex-connect-form.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { useEffect, useState } from "react";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {
- type ProviderConnectFormProps,
- VERTEX_AUTH_SERVICE_ACCOUNT,
- VERTEX_AUTH_WORKLOAD_IDENTITY,
- VERTEX_DEFAULT_LOCATION,
-} from "./provider-metadata";
-
-/**
- * Google Vertex AI (Gemini) connect form. Service-account auth uploads a
- * credentials JSON file (read into a string); workload identity collects a
- * project id. Credentials ride along in `extra.litellm_params`.
- */
-export function VertexConnectForm({ onDraftChange }: ProviderConnectFormProps) {
- const [authMethod, setAuthMethod] = useState(VERTEX_AUTH_SERVICE_ACCOUNT);
- const [location, setLocation] = useState(VERTEX_DEFAULT_LOCATION);
- const [credentials, setCredentials] = useState("");
- const [project, setProject] = useState("");
-
- const canSubmit =
- authMethod === VERTEX_AUTH_SERVICE_ACCOUNT ? Boolean(credentials) : Boolean(project);
-
- async function handleCredentialsFile(file: File | undefined) {
- if (!file) return;
- setCredentials(await file.text());
- }
-
- useEffect(() => {
- const params: Record = {};
- if (location) params.vertex_location = location;
- if (authMethod === VERTEX_AUTH_SERVICE_ACCOUNT) {
- if (credentials) params.vertex_credentials = credentials;
- } else if (project) {
- params.vertex_project = project;
- }
- onDraftChange({ base_url: null, api_key: null, extra: { litellm_params: params } }, canSubmit);
- }, [authMethod, canSubmit, credentials, location, onDraftChange, project]);
-
- return (
-
-
-
-
-
-
-
-
setLocation(event.target.value)}
- placeholder={VERTEX_DEFAULT_LOCATION}
- />
-
- Region where your Google Vertex AI models are hosted.
-
-
- {authMethod === VERTEX_AUTH_SERVICE_ACCOUNT ? (
-
-
-
handleCredentialsFile(event.target.files?.[0])}
- />
-
-
- {credentials
- ? "Credentials file loaded."
- : "Attach your service account key JSON from Google Cloud."}
-
-
- ) : (
-
-
-
setProject(event.target.value)}
- placeholder="my-vertex-project"
- />
-
- The GCP project where Vertex AI is enabled.
-
-
- )}
-
- Add Vertex AI model IDs from the provider's settings after connecting.
-
-
- );
-}
diff --git a/surfsense_web/components/settings/vision-model-manager.tsx b/surfsense_web/components/settings/vision-model-manager.tsx
new file mode 100644
index 000000000..31578b4f1
--- /dev/null
+++ b/surfsense_web/components/settings/vision-model-manager.tsx
@@ -0,0 +1,486 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { AlertCircle, Dot, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
+import { useMemo, useState } from "react";
+import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
+import { deleteVisionLLMConfigMutationAtom } from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
+import {
+ globalVisionLLMConfigsAtom,
+ visionLLMConfigsAtom,
+} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
+import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ 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 { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import type { VisionLLMConfig } from "@/contracts/types/new-llm-config.types";
+import { useMediaQuery } from "@/hooks/use-media-query";
+import { getProviderIcon } from "@/lib/provider-icons";
+import { cn } from "@/lib/utils";
+
+interface VisionModelManagerProps {
+ searchSpaceId: number;
+}
+
+function getInitials(name: string): string {
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[1][0]).toUpperCase();
+ }
+ return name.slice(0, 2).toUpperCase();
+}
+
+export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+
+ const {
+ mutateAsync: deleteConfig,
+ isPending: isDeleting,
+ error: deleteError,
+ } = useAtomValue(deleteVisionLLMConfigMutationAtom);
+
+ const {
+ data: userConfigs,
+ isFetching: configsLoading,
+ error: fetchError,
+ refetch: refreshConfigs,
+ } = useAtomValue(visionLLMConfigsAtom);
+ const { data: globalConfigs = [], isFetching: globalLoading } = useAtomValue(
+ globalVisionLLMConfigsAtom
+ );
+
+ const { data: members } = useAtomValue(membersAtom);
+ const memberMap = useMemo(() => {
+ const map = new Map();
+ if (members) {
+ for (const m of members) {
+ map.set(m.user_id, {
+ name: m.user_display_name || m.user_email || "Unknown",
+ email: m.user_email || undefined,
+ avatarUrl: m.user_avatar_url || undefined,
+ });
+ }
+ }
+ return map;
+ }, [members]);
+
+ const { data: access } = useAtomValue(myAccessAtom);
+ const canCreate = useMemo(() => {
+ if (!access) return false;
+ if (access.is_owner) return true;
+ return access.permissions?.includes("vision_configs:create") ?? false;
+ }, [access]);
+ const canDelete = useMemo(() => {
+ if (!access) return false;
+ if (access.is_owner) return true;
+ return access.permissions?.includes("vision_configs:delete") ?? false;
+ }, [access]);
+ const canUpdate = canCreate;
+ const isReadOnly = !canCreate && !canDelete;
+
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [editingConfig, setEditingConfig] = useState(null);
+ const [configToDelete, setConfigToDelete] = useState(null);
+
+ const isLoading = configsLoading || globalLoading;
+ const errors = [deleteError, fetchError].filter(Boolean) as Error[];
+
+ const openEditDialog = (config: VisionLLMConfig) => {
+ setEditingConfig(config);
+ setIsDialogOpen(true);
+ };
+
+ const openNewDialog = () => {
+ setEditingConfig(null);
+ setIsDialogOpen(true);
+ };
+
+ const handleDelete = async () => {
+ if (!configToDelete) return;
+ try {
+ await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
+ setConfigToDelete(null);
+ } catch {
+ // Error handled by mutation
+ }
+ };
+
+ return (
+
+
+
+ {canCreate && (
+
+ )}
+
+
+ {errors.map((err) => (
+
+ ))}
+
+ {access && !isLoading && isReadOnly && (
+
+
+
+
+
+ You have read-only access to vision model
+ configurations. Contact a space owner to request additional permissions.
+
+
+
+
+ )}
+ {access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
+
+
+
+
+
+ You can{" "}
+ {[canCreate && "create and edit", canDelete && "delete"]
+ .filter(Boolean)
+ .join(" and ")}{" "}
+ vision model configurations
+ {!canDelete && ", but cannot delete them"}.
+
+
+
+
+ )}
+
+ {(isLoading ||
+ globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0) && (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+ {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
+ global vision{" "}
+ {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length ===
+ 1
+ ? "model"
+ : "models"}
+ {" "}
+ available from your administrator. {(() => {
+ const nonAuto = globalConfigs.filter(
+ (g) => !("is_auto_mode" in g && g.is_auto_mode)
+ );
+ const premium = nonAuto.filter(
+ (g) =>
+ "billing_tier" in g &&
+ (g as { billing_tier?: string }).billing_tier === "premium"
+ ).length;
+ const free = nonAuto.length - premium;
+ if (premium > 0 && free > 0) {
+ return `${premium} premium, ${free} free.`;
+ }
+ if (premium > 0) {
+ return `All ${premium} premium — debits your shared credit pool.`;
+ }
+ return `All ${free} free.`;
+ })()}
+
+ )}
+
+
+ )}
+
+ {/* 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 && (
+
+
+ {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 (
+
+
+
+
+ {getProviderIcon(cfg.provider, { className: "size-4" })}
+
+
+
+ {cfg.name}
+
+ {isPremium ? (
+
+ Premium
+
+ ) : (
+
+ Free
+
+ )}
+
+
+ {cfg.description && (
+
+ {cfg.description}
+
+ )}
+
+
+
+
+ {cfg.model_name}
+
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {isLoading && (
+
+
+
+ {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {!isLoading && (
+
+ {(userConfigs?.length ?? 0) === 0 ? (
+
+
+ No Vision Models Yet
+
+ {canCreate
+ ? "Add your own vision-capable model (GPT-4o, Claude, Gemini, etc.)"
+ : "No vision models have been added to this space yet. Contact a space owner to add one."}
+
+
+
+ ) : (
+
+ {userConfigs?.map((config) => {
+ const member = config.user_id ? memberMap.get(config.user_id) : null;
+
+ return (
+
+
+
+ {/* Header: Icon + Name + Actions */}
+
+
+
+ {getProviderIcon(config.provider, { className: "size-4" })}
+
+
+
+ {config.name}
+
+ {config.description && (
+
+ {config.description}
+
+ )}
+
+
+ {(canUpdate || canDelete) && (
+
+ {canUpdate && (
+
+
+
+
+
+ Edit
+
+
+ )}
+ {canDelete && (
+
+
+
+
+
+ Delete
+
+
+ )}
+
+ )}
+
+
+ {/* Footer: Date + Creator */}
+
+
+
+
+ {new Date(config.created_at).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}
+
+ {member && (
+ <>
+
+
+
+
+
+
+ {member.avatarUrl && (
+
+ )}
+
+ {getInitials(member.name)}
+
+
+
+ {member.name}
+
+
+
+
+ {member.email || member.name}
+
+
+
+ >
+ )}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+
{
+ setIsDialogOpen(open);
+ if (!open) setEditingConfig(null);
+ }}
+ config={editingConfig}
+ isGlobal={false}
+ searchSpaceId={searchSpaceId}
+ mode={editingConfig ? "edit" : "create"}
+ />
+
+ !open && setConfigToDelete(null)}
+ >
+
+
+ Delete Vision Model
+
+ Are you sure you want to delete{" "}
+ {configToDelete?.name}?
+
+
+
+ Cancel
+
+ Delete
+ {isDeleting && }
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/components/shared/image-config-dialog.tsx b/surfsense_web/components/shared/image-config-dialog.tsx
new file mode 100644
index 000000000..36d16081a
--- /dev/null
+++ b/surfsense_web/components/shared/image-config-dialog.tsx
@@ -0,0 +1,456 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import {
+ createImageGenConfigMutationAtom,
+ updateImageGenConfigMutationAtom,
+} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
+import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Spinner } from "@/components/ui/spinner";
+import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers";
+import type {
+ GlobalImageGenConfig,
+ ImageGenerationConfig,
+ ImageGenProvider,
+} from "@/contracts/types/new-llm-config.types";
+import { cn } from "@/lib/utils";
+
+interface ImageConfigDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ config: ImageGenerationConfig | GlobalImageGenConfig | null;
+ isGlobal: boolean;
+ searchSpaceId: number;
+ mode: "create" | "edit" | "view";
+ defaultProvider?: string;
+}
+
+const INITIAL_FORM = {
+ name: "",
+ description: "",
+ provider: "",
+ model_name: "",
+ api_key: "",
+ api_base: "",
+ api_version: "",
+};
+
+export function ImageConfigDialog({
+ open,
+ onOpenChange,
+ config,
+ isGlobal,
+ searchSpaceId,
+ mode,
+ defaultProvider,
+}: ImageConfigDialogProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [formData, setFormData] = useState(INITIAL_FORM);
+ const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
+ const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ if (open) {
+ if (mode === "edit" && config && !isGlobal) {
+ setFormData({
+ name: config.name || "",
+ description: config.description || "",
+ provider: config.provider || "",
+ model_name: config.model_name || "",
+ api_key: (config as ImageGenerationConfig).api_key || "",
+ api_base: config.api_base || "",
+ api_version: config.api_version || "",
+ });
+ } else if (mode === "create") {
+ setFormData({ ...INITIAL_FORM, provider: defaultProvider ?? "" });
+ }
+ setScrollPos("top");
+ }
+ }, [open, mode, config, isGlobal, defaultProvider]);
+
+ const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
+ const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
+ const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
+
+ const handleScroll = useCallback((e: React.UIEvent) => {
+ const el = e.currentTarget;
+ const atTop = el.scrollTop <= 2;
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
+ setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
+ }, []);
+
+ const suggestedModels = useMemo(() => {
+ if (!formData.provider) return [];
+ return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider);
+ }, [formData.provider]);
+
+ const getTitle = () => {
+ if (mode === "create") return "Add Image Model";
+ if (isGlobal) return "View Global Image Model";
+ return "Edit Image Model";
+ };
+
+ const getSubtitle = () => {
+ if (mode === "create") return "Set up a new image generation provider";
+ if (isGlobal) return "Read-only global configuration";
+ return "Update your image model settings";
+ };
+
+ const handleSubmit = useCallback(async () => {
+ setIsSubmitting(true);
+ try {
+ if (mode === "create") {
+ const result = await createConfig({
+ name: formData.name,
+ provider: formData.provider as ImageGenProvider,
+ model_name: formData.model_name,
+ api_key: formData.api_key,
+ api_base: formData.api_base || undefined,
+ api_version: formData.api_version || undefined,
+ description: formData.description || undefined,
+ search_space_id: searchSpaceId,
+ });
+ if (result?.id) {
+ await updatePreferences({
+ search_space_id: searchSpaceId,
+ data: { image_generation_config_id: result.id },
+ });
+ }
+ onOpenChange(false);
+ } else if (!isGlobal && config) {
+ await updateConfig({
+ id: config.id,
+ data: {
+ name: formData.name,
+ description: formData.description || undefined,
+ provider: formData.provider as ImageGenProvider,
+ model_name: formData.model_name,
+ api_key: formData.api_key,
+ api_base: formData.api_base || undefined,
+ api_version: formData.api_version || undefined,
+ },
+ });
+ onOpenChange(false);
+ }
+ } catch (error) {
+ console.error("Failed to save image config:", error);
+ toast.error("Failed to save image model");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [
+ mode,
+ isGlobal,
+ config,
+ formData,
+ searchSpaceId,
+ createConfig,
+ updateConfig,
+ updatePreferences,
+ onOpenChange,
+ ]);
+
+ const handleUseGlobalConfig = useCallback(async () => {
+ if (!config || !isGlobal) return;
+ setIsSubmitting(true);
+ try {
+ await updatePreferences({
+ search_space_id: searchSpaceId,
+ data: { image_generation_config_id: config.id },
+ });
+ toast.success(`Now using ${config.name}`);
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Failed to set image model:", error);
+ toast.error("Failed to set image model");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
+
+ const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
+ const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
+
+ return (
+
+ );
+}
diff --git a/surfsense_web/components/shared/llm-config-form.tsx b/surfsense_web/components/shared/llm-config-form.tsx
new file mode 100644
index 000000000..06de4129b
--- /dev/null
+++ b/surfsense_web/components/shared/llm-config-form.tsx
@@ -0,0 +1,527 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useAtomValue } from "jotai";
+import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { type Resolver, useForm } from "react-hook-form";
+import { z } from "zod";
+import {
+ defaultSystemInstructionsAtom,
+ modelListAtom,
+} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
+import type { CreateNewLLMConfigRequest } from "@/contracts/types/new-llm-config.types";
+import { cn } from "@/lib/utils";
+import InferenceParamsEditor from "../inference-params-editor";
+
+// Form schema with zod
+const formSchema = z.object({
+ name: z.string().min(1, "Name is required").max(100),
+ description: z.string().max(500).optional().nullable(),
+ provider: z.string().min(1, "Provider is required"),
+ custom_provider: z.string().max(100).optional().nullable(),
+ model_name: z.string().min(1, "Model name is required").max(100),
+ api_key: z.string().min(1, "API key is required"),
+ api_base: z.string().max(500).optional().nullable(),
+ litellm_params: z.record(z.string(), z.any()).optional().nullable(),
+ system_instructions: z.string().default(""),
+ use_default_system_instructions: z.boolean().default(true),
+ citations_enabled: z.boolean().default(true),
+ search_space_id: z.number(),
+});
+
+type FormValues = z.infer;
+
+export type LLMConfigFormData = CreateNewLLMConfigRequest;
+
+interface LLMConfigFormProps {
+ initialData?: Partial;
+ searchSpaceId: number;
+ onSubmit: (data: LLMConfigFormData) => Promise;
+ mode?: "create" | "edit";
+ showAdvanced?: boolean;
+ formId?: string;
+}
+
+export function LLMConfigForm({
+ initialData,
+ searchSpaceId,
+ onSubmit,
+ mode = "create",
+ showAdvanced = true,
+ formId,
+}: LLMConfigFormProps) {
+ const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
+ defaultSystemInstructionsAtom
+ );
+ const { data: dynamicModels } = useAtomValue(modelListAtom);
+ const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false);
+
+ const form = useForm({
+ resolver: zodResolver(formSchema) as Resolver,
+ defaultValues: {
+ name: initialData?.name ?? "",
+ description: initialData?.description ?? "",
+ provider: initialData?.provider ?? "",
+ custom_provider: initialData?.custom_provider ?? "",
+ model_name: initialData?.model_name ?? "",
+ api_key: initialData?.api_key ?? "",
+ api_base: initialData?.api_base ?? "",
+ litellm_params: initialData?.litellm_params ?? {},
+ system_instructions: initialData?.system_instructions ?? "",
+ use_default_system_instructions: initialData?.use_default_system_instructions ?? true,
+ citations_enabled: initialData?.citations_enabled ?? true,
+ search_space_id: searchSpaceId,
+ },
+ });
+
+ // Load default instructions when available (only for new configs)
+ useEffect(() => {
+ if (
+ mode === "create" &&
+ defaultInstructionsLoaded &&
+ defaultInstructions?.default_system_instructions &&
+ !form.getValues("system_instructions")
+ ) {
+ form.setValue("system_instructions", defaultInstructions.default_system_instructions);
+ }
+ }, [defaultInstructionsLoaded, defaultInstructions, mode, form]);
+
+ const watchProvider = form.watch("provider");
+ const selectedProvider = LLM_PROVIDERS.find((p) => p.value === watchProvider);
+ const availableModels = useMemo(
+ () => (dynamicModels ?? []).filter((m) => m.provider === watchProvider),
+ [dynamicModels, watchProvider]
+ );
+
+ const handleProviderChange = (value: string) => {
+ form.setValue("provider", value);
+ form.setValue("model_name", "");
+
+ // Auto-fill API base for certain providers
+ const provider = LLM_PROVIDERS.find((p) => p.value === value);
+ if (provider?.apiBase) {
+ form.setValue("api_base", provider.apiBase);
+ }
+ };
+
+ const handleFormSubmit = async (values: FormValues) => {
+ await onSubmit(values as LLMConfigFormData);
+ };
+
+ return (
+
+
+ );
+}
diff --git a/surfsense_web/components/shared/model-config-dialog.tsx b/surfsense_web/components/shared/model-config-dialog.tsx
new file mode 100644
index 000000000..d4f57ff7d
--- /dev/null
+++ b/surfsense_web/components/shared/model-config-dialog.tsx
@@ -0,0 +1,339 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { AlertCircle } from "lucide-react";
+import { useCallback, useRef, useState } from "react";
+import { toast } from "sonner";
+import {
+ createNewLLMConfigMutationAtom,
+ updateLLMPreferencesMutationAtom,
+ updateNewLLMConfigMutationAtom,
+} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
+import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { Spinner } from "@/components/ui/spinner";
+import type {
+ GlobalNewLLMConfig,
+ LiteLLMProvider,
+ NewLLMConfigPublic,
+} from "@/contracts/types/new-llm-config.types";
+
+interface ModelConfigDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
+ isGlobal: boolean;
+ searchSpaceId: number;
+ mode: "create" | "edit" | "view";
+ defaultProvider?: string;
+}
+
+export function ModelConfigDialog({
+ open,
+ onOpenChange,
+ config,
+ isGlobal,
+ searchSpaceId,
+ mode,
+ defaultProvider,
+}: ModelConfigDialogProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
+ const scrollRef = useRef(null);
+
+ const handleScroll = useCallback((e: React.UIEvent) => {
+ const el = e.currentTarget;
+ const atTop = el.scrollTop <= 2;
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
+ setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
+ }, []);
+
+ const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
+ const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
+ const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
+
+ const getTitle = () => {
+ if (mode === "create") return "Add New Configuration";
+ if (isGlobal) return "View Global Configuration";
+ return "Edit Configuration";
+ };
+
+ const getSubtitle = () => {
+ if (mode === "create") return "Set up a new LLM provider for this search space";
+ if (isGlobal) return "Read-only global configuration";
+ return "Update your configuration settings";
+ };
+
+ const handleSubmit = useCallback(
+ async (data: LLMConfigFormData) => {
+ setIsSubmitting(true);
+ try {
+ if (mode === "create") {
+ const result = await createConfig({
+ ...data,
+ search_space_id: searchSpaceId,
+ });
+
+ if (result?.id) {
+ await updatePreferences({
+ search_space_id: searchSpaceId,
+ data: {
+ agent_llm_id: result.id,
+ },
+ });
+ }
+
+ onOpenChange(false);
+ } else if (!isGlobal && config) {
+ await updateConfig({
+ id: config.id,
+ data: {
+ name: data.name,
+ description: data.description,
+ provider: data.provider,
+ custom_provider: data.custom_provider,
+ model_name: data.model_name,
+ api_key: data.api_key,
+ api_base: data.api_base,
+ litellm_params: data.litellm_params,
+ system_instructions: data.system_instructions,
+ use_default_system_instructions: data.use_default_system_instructions,
+ citations_enabled: data.citations_enabled,
+ },
+ });
+ onOpenChange(false);
+ }
+ } catch (error) {
+ console.error("Failed to save configuration:", error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [
+ mode,
+ isGlobal,
+ config,
+ searchSpaceId,
+ createConfig,
+ updateConfig,
+ updatePreferences,
+ onOpenChange,
+ ]
+ );
+
+ const handleUseGlobalConfig = useCallback(async () => {
+ if (!config || !isGlobal) return;
+ setIsSubmitting(true);
+ try {
+ await updatePreferences({
+ search_space_id: searchSpaceId,
+ data: {
+ agent_llm_id: config.id,
+ },
+ });
+ toast.success(`Now using ${config.name}`);
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Failed to set model:", error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
+
+ return (
+
+ );
+}
diff --git a/surfsense_web/components/shared/vision-config-dialog.tsx b/surfsense_web/components/shared/vision-config-dialog.tsx
new file mode 100644
index 000000000..2646f3842
--- /dev/null
+++ b/surfsense_web/components/shared/vision-config-dialog.tsx
@@ -0,0 +1,478 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
+import {
+ createVisionLLMConfigMutationAtom,
+ updateVisionLLMConfigMutationAtom,
+} from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
+import { visionModelListAtom } from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Spinner } from "@/components/ui/spinner";
+import { VISION_PROVIDERS } from "@/contracts/enums/vision-providers";
+import type {
+ GlobalVisionLLMConfig,
+ VisionLLMConfig,
+ VisionProvider,
+} from "@/contracts/types/new-llm-config.types";
+import { cn } from "@/lib/utils";
+
+interface VisionConfigDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ config: VisionLLMConfig | GlobalVisionLLMConfig | null;
+ isGlobal: boolean;
+ searchSpaceId: number;
+ mode: "create" | "edit" | "view";
+ defaultProvider?: string;
+}
+
+const INITIAL_FORM = {
+ name: "",
+ description: "",
+ provider: "",
+ model_name: "",
+ api_key: "",
+ api_base: "",
+ api_version: "",
+};
+
+export function VisionConfigDialog({
+ open,
+ onOpenChange,
+ config,
+ isGlobal,
+ searchSpaceId,
+ mode,
+ defaultProvider,
+}: VisionConfigDialogProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [formData, setFormData] = useState(INITIAL_FORM);
+ const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ if (open) {
+ if (mode === "edit" && config && !isGlobal) {
+ setFormData({
+ name: config.name || "",
+ description: config.description || "",
+ provider: config.provider || "",
+ model_name: config.model_name || "",
+ api_key: (config as VisionLLMConfig).api_key || "",
+ api_base: config.api_base || "",
+ api_version: (config as VisionLLMConfig).api_version || "",
+ });
+ } else if (mode === "create") {
+ setFormData({ ...INITIAL_FORM, provider: defaultProvider ?? "" });
+ }
+ setScrollPos("top");
+ }
+ }, [open, mode, config, isGlobal, defaultProvider]);
+
+ const { mutateAsync: createConfig } = useAtomValue(createVisionLLMConfigMutationAtom);
+ const { mutateAsync: updateConfig } = useAtomValue(updateVisionLLMConfigMutationAtom);
+ const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
+
+ const handleScroll = useCallback((e: React.UIEvent) => {
+ const el = e.currentTarget;
+ const atTop = el.scrollTop <= 2;
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
+ setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
+ }, []);
+
+ const getTitle = () => {
+ if (mode === "create") return "Add Vision Model";
+ if (isGlobal) return "View Global Vision Model";
+ return "Edit Vision Model";
+ };
+
+ const getSubtitle = () => {
+ if (mode === "create") return "Set up a new vision-capable LLM provider";
+ if (isGlobal) return "Read-only global configuration";
+ return "Update your vision model settings";
+ };
+
+ const handleSubmit = useCallback(async () => {
+ setIsSubmitting(true);
+ try {
+ if (mode === "create") {
+ const result = await createConfig({
+ name: formData.name,
+ provider: formData.provider as VisionProvider,
+ model_name: formData.model_name,
+ api_key: formData.api_key,
+ api_base: formData.api_base || undefined,
+ api_version: formData.api_version || undefined,
+ description: formData.description || undefined,
+ search_space_id: searchSpaceId,
+ });
+ if (result?.id) {
+ await updatePreferences({
+ search_space_id: searchSpaceId,
+ data: { vision_llm_config_id: result.id },
+ });
+ }
+ onOpenChange(false);
+ } else if (!isGlobal && config) {
+ await updateConfig({
+ id: config.id,
+ data: {
+ name: formData.name,
+ description: formData.description || undefined,
+ provider: formData.provider as VisionProvider,
+ model_name: formData.model_name,
+ api_key: formData.api_key,
+ api_base: formData.api_base || undefined,
+ api_version: formData.api_version || undefined,
+ },
+ });
+ onOpenChange(false);
+ }
+ } catch (error) {
+ console.error("Failed to save vision config:", error);
+ toast.error("Failed to save vision model");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [
+ mode,
+ isGlobal,
+ config,
+ formData,
+ searchSpaceId,
+ createConfig,
+ updateConfig,
+ updatePreferences,
+ onOpenChange,
+ ]);
+
+ const handleUseGlobalConfig = useCallback(async () => {
+ if (!config || !isGlobal) return;
+ setIsSubmitting(true);
+ try {
+ await updatePreferences({
+ search_space_id: searchSpaceId,
+ data: { vision_llm_config_id: config.id },
+ });
+ toast.success(`Now using ${config.name}`);
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Failed to set vision model:", error);
+ toast.error("Failed to set vision model");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
+
+ const { data: dynamicModels } = useAtomValue(visionModelListAtom);
+ const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
+
+ const availableModels = useMemo(
+ () => (dynamicModels ?? []).filter((m) => m.provider === formData.provider),
+ [dynamicModels, formData.provider]
+ );
+
+ const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
+ const selectedProvider = VISION_PROVIDERS.find((p) => p.value === formData.provider);
+
+ return (
+
+ );
+}
diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx
index 8ee203765..3f68f6d64 100644
--- a/surfsense_web/components/sources/DocumentUploadTab.tsx
+++ b/surfsense_web/components/sources/DocumentUploadTab.tsx
@@ -8,7 +8,6 @@ import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } f
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
-import { useRuntimeConfig } from "@/components/providers/runtime-config";
import {
Accordion,
AccordionContent,
@@ -137,7 +136,6 @@ export function DocumentUploadTab({
onAccordionStateChange,
}: DocumentUploadTabProps) {
const t = useTranslations("upload_documents");
- const { etlService } = useRuntimeConfig();
const [files, setFiles] = useState([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [accordionValue, setAccordionValue] = useState("");
@@ -162,7 +160,7 @@ export function DocumentUploadTab({
const electronAPI = useElectronAPI();
const isElectron = !!electronAPI?.browseFiles;
- const acceptedFileTypes = useMemo(() => getAcceptedFileTypes(etlService), [etlService]);
+ const acceptedFileTypes = useMemo(() => getAcceptedFileTypes(), []);
const supportedExtensions = useMemo(
() => getSupportedExtensions(acceptedFileTypes),
[acceptedFileTypes]
diff --git a/surfsense_web/components/sources/FolderWatchDialog.tsx b/surfsense_web/components/sources/FolderWatchDialog.tsx
index 7a64f3835..8c5629276 100644
--- a/surfsense_web/components/sources/FolderWatchDialog.tsx
+++ b/surfsense_web/components/sources/FolderWatchDialog.tsx
@@ -3,7 +3,6 @@
import { X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
-import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -49,7 +48,6 @@ export function FolderWatchDialog({
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState(null);
const abortRef = useRef(null);
- const { etlService } = useRuntimeConfig();
useEffect(() => {
if (open && initialFolder) {
@@ -57,10 +55,7 @@ export function FolderWatchDialog({
}
}, [open, initialFolder]);
- const supportedExtensions = useMemo(
- () => Array.from(getSupportedExtensionsSet(undefined, etlService)),
- [etlService]
- );
+ const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []);
const handleSelectFolder = useCallback(async () => {
const api = window.electronAPI;
diff --git a/surfsense_web/components/tool-ui/automation/create-automation.tsx b/surfsense_web/components/tool-ui/automation/create-automation.tsx
index 8775b275b..24e9d66bd 100644
--- a/surfsense_web/components/tool-ui/automation/create-automation.tsx
+++ b/surfsense_web/components/tool-ui/automation/create-automation.tsx
@@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
-import { AlarmClock, AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil } from "lucide-react";
+import { AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil, Workflow } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@@ -113,7 +113,7 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const eligibleModels = useAutomationEligibleModels();
const [modelSelection, setModelSelection] = useState({
- chatModelId: 0,
+ agentLlmId: 0,
imageConfigId: 0,
visionConfigId: 0,
});
@@ -121,7 +121,7 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
// default. No effect seeds async hook data into state.
const resolvedModels = useMemo(
() => ({
- chatModelId: modelSelection.chatModelId || eligibleModels.llm.defaultId || 0,
+ agentLlmId: modelSelection.agentLlmId || eligibleModels.llm.defaultId || 0,
imageConfigId: modelSelection.imageConfigId || eligibleModels.image.defaultId || 0,
visionConfigId: modelSelection.visionConfigId || eligibleModels.vision.defaultId || 0,
}),
@@ -133,7 +133,7 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
]
);
const modelsResolved =
- resolvedModels.chatModelId !== 0 &&
+ resolvedModels.agentLlmId !== 0 &&
resolvedModels.imageConfigId !== 0 &&
resolvedModels.visionConfigId !== 0;
@@ -147,9 +147,9 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
definition: {
...baseDefinition,
models: {
- chat_model_id: resolvedModels.chatModelId,
- image_gen_model_id: resolvedModels.imageConfigId,
- vision_model_id: resolvedModels.visionConfigId,
+ agent_llm_id: resolvedModels.agentLlmId,
+ image_generation_config_id: resolvedModels.imageConfigId,
+ vision_llm_config_id: resolvedModels.visionConfigId,
},
},
};
@@ -162,9 +162,9 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
trigger_type:
(triggers[0] as { type?: string } | undefined)?.type ??
(triggers.length ? undefined : "none"),
- chat_model_id: resolvedModels.chatModelId,
- image_gen_model_id: resolvedModels.imageConfigId,
- vision_model_id: resolvedModels.visionConfigId,
+ agent_llm_id: resolvedModels.agentLlmId,
+ image_generation_config_id: resolvedModels.imageConfigId,
+ vision_llm_config_id: resolvedModels.visionConfigId,
});
onDecision({
type: "edit",
@@ -211,7 +211,7 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
-
+
{phase === "rejected"
@@ -404,7 +404,7 @@ function SavedCard({ result }: { result: SavedResult }) {
return (
-
+
Automation saved
{result.name}
diff --git a/surfsense_web/components/tool-ui/generate-resume.tsx b/surfsense_web/components/tool-ui/generate-resume.tsx
index 9147d4199..5533674bf 100644
--- a/surfsense_web/components/tool-ui/generate-resume.tsx
+++ b/surfsense_web/components/tool-ui/generate-resume.tsx
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { getAuthHeaders } from "@/lib/auth-utils";
-import { buildBackendUrl } from "@/lib/env-config";
+import { BACKEND_URL } from "@/lib/env-config";
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
@@ -223,7 +223,7 @@ function ResumeCard({
const previewPath = shareToken
? `/api/v1/public/${shareToken}/reports/${reportId}/preview`
: `/api/v1/reports/${reportId}/preview`;
- setPdfUrl(buildBackendUrl(previewPath));
+ setPdfUrl(`${BACKEND_URL}${previewPath}`);
if (autoOpen && isDesktop && !autoOpenedRef.current) {
autoOpenedRef.current = true;
diff --git a/surfsense_web/components/tool-ui/podcast/brief-review.tsx b/surfsense_web/components/tool-ui/podcast/brief-review.tsx
index d982d6f85..3473b64d6 100644
--- a/surfsense_web/components/tool-ui/podcast/brief-review.tsx
+++ b/surfsense_web/components/tool-ui/podcast/brief-review.tsx
@@ -1,20 +1,11 @@
"use client";
-import { Check, ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
+import { Loader2, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -24,10 +15,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
- type LanguageOptions,
- MAX_DURATION_SECONDS,
MAX_SPEAKERS,
- MIN_DURATION_SECONDS,
type PodcastSpec,
type PodcastStyle,
podcastStyle,
@@ -67,11 +55,7 @@ interface BriefReviewProps {
*/
export function BriefReview({ podcast, spec }: BriefReviewProps) {
const [draft, setDraft] = useState
(spec);
- const [durationUnit, setDurationUnit] = useState(() =>
- defaultDurationUnit(spec.duration.max_seconds)
- );
const [voices, setVoices] = useState(null);
- const [offering, setOffering] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// A pushed spec change (saved edit or concurrent editor) resets the form to
@@ -79,7 +63,6 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
// biome-ignore lint/correctness/useExhaustiveDependencies: reset only when the server version moves
useEffect(() => {
setDraft(spec);
- setDurationUnit(defaultDurationUnit(spec.duration.max_seconds));
}, [podcast.specVersion]);
useEffect(() => {
@@ -92,26 +75,19 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
.catch(() => {
if (!cancelled) setVoices([]);
});
- podcastsApiService
- .listLanguages()
- .then((options) => {
- if (!cancelled) setOffering(options);
- })
- .catch(() => {
- if (!cancelled) setOffering({ languages: [], allows_custom: false });
- });
return () => {
cancelled = true;
};
}, []);
- // The backend owns the offering; the draft's language stays listed even
- // when it falls outside it (e.g. a custom tag entered earlier).
const languages = useMemo(() => {
- const tags = new Set(offering?.languages ?? []);
+ const tags = new Set();
+ for (const voice of voices ?? []) {
+ if (voice.language !== ANY_LANGUAGE) tags.add(voice.language);
+ }
tags.add(draft.language);
return [...tags].sort();
- }, [offering, draft.language]);
+ }, [voices, draft.language]);
const voicesForLanguage = useMemo(
() => (voices ?? []).filter((voice) => speaks(voice, draft.language)),
@@ -217,22 +193,18 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
- {offering?.allows_custom ? (
-
- ) : (
-
- )}
+
@@ -332,66 +304,39 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
))}
-
-
-
-
+
+
+
+
+ setDraft((current) => ({
+ ...current,
+ duration: { ...current.duration, min_minutes: Number(e.target.value) || 1 },
+ }))
+ }
+ />
-
-
-
- {
- const seconds = clampDurationSeconds(
- fromUnitValue(Number(e.target.value), durationUnit)
- );
- setDraft((current) => ({
- ...current,
- duration: { ...current.duration, min_seconds: seconds },
- }));
- }}
- />
-
-
-
- {
- const parsed = Number(e.target.value);
- const fallback = secondsToUnitValue(draft.duration.min_seconds, durationUnit);
- const seconds = clampDurationSeconds(
- fromUnitValue(Number.isFinite(parsed) ? parsed : fallback, durationUnit)
- );
- setDraft((current) => ({
- ...current,
- duration: { ...current.duration, max_seconds: seconds },
- }));
- }}
- />
-
+
+
+
+ setDraft((current) => ({
+ ...current,
+ duration: {
+ ...current.duration,
+ max_minutes: Number(e.target.value) || current.duration.min_minutes,
+ },
+ }))
+ }
+ />
@@ -420,7 +365,7 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {