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 8314a5179..74c604173 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 { CalendarDays, Info, Workflow } from "lucide-react";
+import { AlarmClock, CalendarDays, Info } 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 59967080f..a68e53a1c 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(
() => ({
- agentLlmId: form.models.agentLlmId || eligibleModels.llm.defaultId || 0,
+ chatModelId: form.models.chatModelId || 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 2c4a0bf60..6dd42366b 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 {
- agentLlmId: number;
+ chatModelId: number;
imageConfigId: number;
visionConfigId: number;
}
@@ -39,7 +39,7 @@ interface AutomationModelFieldsProps {
}
/**
- * Three eligible-only model pickers (Agent LLM / Image / Vision) for the
+ * Three eligible-only model pickers (Chat / 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/roles`;
+ const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/models`;
return (
onChange({ agentLlmId: id })}
+ error={errors?.chatModelId}
+ onChange={(id) => onChange({ chatModelId: id })}
/>
{
- return isLlmOnboardingComplete(preferences.agent_llm_id, globalConfigs.length > 0);
- }, [preferences.agent_llm_id, globalConfigs.length]);
+ const { data: modelRoles = {}, isLoading: loading, error } = useAtomValue(modelRolesAtom);
+ const { data: globalConnections = [], isLoading: globalConfigsLoading } = useAtomValue(
+ globalModelConnectionsAtom
+ );
+ const { data: modelConnections = [], isLoading: modelConnectionsLoading } =
+ useAtomValue(modelConnectionsAtom);
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,13 +63,18 @@ export function DashboardClientLayout({
}
if (
+ isSearchSpaceReady &&
!loading &&
!accessLoading &&
!globalConfigsLoading &&
- !hasCheckedOnboarding &&
- !isAutoConfiguring
+ !modelConnectionsLoading &&
+ !hasCheckedOnboarding
) {
- const onboardingComplete = isOnboardingComplete();
+ const onboardingComplete = isLlmOnboardingComplete(
+ modelRoles.chat_model_id,
+ globalConnections,
+ modelConnections
+ );
if (onboardingComplete) {
setHasCheckedOnboarding(true);
@@ -84,56 +86,23 @@ 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,
- isOnboardingComplete,
+ modelConnectionsLoading,
+ modelRoles.chat_model_id,
+ globalConnections,
+ modelConnections,
isOnboardingPage,
isOwner,
- isAutoConfiguring,
- globalConfigs,
router,
searchSpaceId,
hasCheckedOnboarding,
- updatePreferences,
- refetchPreferences,
]);
const electronAPI = useElectronAPI();
@@ -185,10 +154,13 @@ export function DashboardClientLayout({
// Determine if we should show loading
const shouldShowLoading =
- (!hasCheckedOnboarding &&
- (loading || accessLoading || globalConfigsLoading) &&
- !isOnboardingPage) ||
- isAutoConfiguring;
+ !hasCheckedOnboarding &&
+ (!isSearchSpaceReady ||
+ loading ||
+ accessLoading ||
+ globalConfigsLoading ||
+ modelConnectionsLoading) &&
+ !isOnboardingPage;
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(shouldShowLoading);
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 f048376cc..0c4fa63ec 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
@@ -613,6 +613,18 @@ 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]
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 de5c961e8..02d36811f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
@@ -2,193 +2,74 @@
import { useAtomValue } from "jotai";
import { useParams, useRouter } from "next/navigation";
-import { useEffect, useRef, useState } from "react";
-import { toast } from "sonner";
+import { useEffect, useMemo } from "react";
import {
- 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";
+ globalModelConnectionsAtom,
+ modelConnectionsAtom,
+ modelRolesAtom,
+} from "@/atoms/model-connections/model-connections-query.atoms";
import { Logo } from "@/components/Logo";
-import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
+import { ModelProviderConnectionsPanel } from "@/components/settings/model-connections/model-provider-connections-panel";
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 { isLlmOnboardingComplete } from "@/lib/onboarding";
+import { hasEnabledChatModel, isLlmOnboardingComplete } from "@/lib/onboarding";
export default function OnboardPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
- // 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
+ const { data: globalConnections = [], isLoading: globalLoading } = useAtomValue(
+ globalModelConnectionsAtom
);
+ const { data: connections = [] } = useAtomValue(modelConnectionsAtom);
+ const { data: roles = {}, isLoading: rolesLoading } = useAtomValue(modelRolesAtom);
- // State
- const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
- const hasAttemptedAutoConfig = useRef(false);
-
- // Check authentication
useEffect(() => {
- const token = getBearerToken();
- if (!token) {
- redirectToLogin();
- }
+ if (!getBearerToken()) redirectToLogin();
}, []);
- const isOnboardingComplete = isLlmOnboardingComplete(
- preferences.agent_llm_id,
- globalConfigs.length > 0
+ const hasUsableChatModel = useMemo(
+ () => hasEnabledChatModel([...globalConnections, ...connections]),
+ [globalConnections, connections]
);
- useEffect(() => {
- if (!preferencesLoading && globalConfigsLoaded && isOnboardingComplete) {
- router.push(`/dashboard/${searchSpaceId}/new-chat`);
- }
- }, [preferencesLoading, globalConfigsLoaded, isOnboardingComplete, router, searchSpaceId]);
+ const onboardingComplete = isLlmOnboardingComplete(
+ roles.chat_model_id,
+ globalConnections,
+ connections
+ );
- useEffect(() => {
- const autoConfigureWithGlobal = async () => {
- if (hasAttemptedAutoConfig.current) return;
- if (globalConfigsLoading || preferencesLoading) return;
- if (!globalConfigsLoaded) return;
- if (isOnboardingComplete) return;
-
- 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;
+ const isLoading = globalLoading || rolesLoading;
useGlobalLoadingEffect(isLoading);
- if (isLoading) {
- return null;
- }
-
- if (globalConfigs.length > 0 && !isAutoConfiguring) {
- return null;
- }
+ if (isLoading) return null;
return (
-
-
- {/* Header */}
-
-
-
-
Configure Your AI
-
- Add your LLM provider to get started with SurfSense
-
-
-
-
- {/* Form card */}
-
-
-
-
- {/* Footer */}
-
-
-
You can add more configurations later
+
+
+
+
+
Choose a model
+
+ Connect any supported provider, then enable the models you want SurfSense to use.
+
+
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
deleted file mode 100644
index b300f8078..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/image-models/page.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-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 22f68edab..bb928f8f7 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,15 +1,6 @@
"use client";
-import {
- BookText,
- Bot,
- CircleUser,
- Earth,
- ImageIcon,
- ListChecks,
- ScanEye,
- UserKey,
-} from "lucide-react";
+import { BookText, Cpu, Earth, Settings, UserKey } from "lucide-react";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -20,10 +11,7 @@ import { cn } from "@/lib/utils";
export type SearchSpaceSettingsTab =
| "general"
- | "roles"
| "models"
- | "image-models"
- | "vision-models"
| "team-roles"
| "prompts"
| "public-links";
@@ -55,27 +43,12 @@ export function SearchSpaceSettingsLayoutShell({
{
value: "general" as const,
label: t("nav_general"),
- icon:
,
- },
- {
- value: "roles" as const,
- label: t("nav_role_assignments"),
- icon:
,
+ icon:
,
},
{
value: "models" as const,
- 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:
,
+ label: t("nav_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 d68194782..c97ef7630 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 { AgentModelManager } from "@/components/settings/agent-model-manager";
+import { ModelConnectionsSettings } from "@/components/settings/model-connections-settings";
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
deleted file mode 100644
index 5bad50cd3..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/roles/page.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index 06aea003a..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/vision-models/page.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-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/docs/layout.tsx b/surfsense_web/app/docs/layout.tsx
index 9311a45b4..cc5f2118a 100644
--- a/surfsense_web/app/docs/layout.tsx
+++ b/surfsense_web/app/docs/layout.tsx
@@ -8,12 +8,15 @@ 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 (
({
task_count: variables.definition.plan.length,
trigger_type: variables.triggers?.[0]?.type ?? "none",
has_schedule: (variables.triggers?.length ?? 0) > 0,
- 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,
+ 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,
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
deleted file mode 100644
index 922c398c9..000000000
--- a/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-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
deleted file mode 100644
index a45e69a03..000000000
--- a/surfsense_web/atoms/image-gen-config/image-gen-config-query.atoms.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-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
new file mode 100644
index 000000000..f00bf76f9
--- /dev/null
+++ b/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts
@@ -0,0 +1,214 @@
+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
new file mode 100644
index 000000000..87f31ce9b
--- /dev/null
+++ b/surfsense_web/atoms/model-connections/model-connections-query.atoms.ts
@@ -0,0 +1,39 @@
+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 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
deleted file mode 100644
index 476d89d4c..000000000
--- a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-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
deleted file mode 100644
index 410d061e5..000000000
--- a/surfsense_web/atoms/new-llm-config/new-llm-config-query.atoms.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-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
deleted file mode 100644
index f46b977d5..000000000
--- a/surfsense_web/atoms/vision-llm-config/vision-llm-config-mutation.atoms.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-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
deleted file mode 100644
index 906ce638f..000000000
--- a/surfsense_web/atoms/vision-llm-config/vision-llm-config-query.atoms.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-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 1d0eefc17..5f3b83db1 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 { RefreshCcw, Workflow } from "lucide-react";
+import { RefreshCw, 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 d084ac0fd..59006b26e 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 {
- globalNewLLMConfigsAtom,
- newLLMConfigsAtom,
-} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
+ globalModelConnectionsAtom,
+ modelConnectionsAtom,
+} from "@/atoms/model-connections/model-connections-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import {
CitationMetadataProvider,
@@ -37,7 +37,10 @@ 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 { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
+import {
+ type TokenUsageModelBreakdown,
+ 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";
@@ -268,29 +271,81 @@ 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: localConfigs } = useAtomValue(newLLMConfigsAtom);
- const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
+ const { data: globalConnections = [] } = useAtomValue(globalModelConnectionsAtom);
+ const { data: localConnections = [] } = useAtomValue(modelConnectionsAtom);
- const configByModel = useMemo(() => {
- const map = new Map
();
- for (const c of [...(globalConfigs ?? []), ...(localConfigs ?? [])]) {
- map.set(c.model_name, { name: c.name, provider: c.provider });
+ 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);
+ }
}
return map;
- }, [localConfigs, globalConfigs]);
+ }, [globalConnections, localConnections]);
- 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 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 modelBreakdown = usage ? (usage.usage ?? usage.model_breakdown) : undefined;
@@ -319,12 +374,12 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch
{models.length > 0 ? (
models.map(([model, counts]) => {
- const { name, icon } = resolveModel(model);
+ const { name, icon } = resolveModel(model, counts);
const costMicros = counts.cost_micros;
return (
e.preventDefault()}
>
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 95f118835..583814acb 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -48,10 +48,10 @@ import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dial
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import {
- globalNewLLMConfigsAtom,
- llmPreferencesAtom,
- newLLMConfigsAtom,
-} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
+ globalModelConnectionsAtom,
+ modelConnectionsAtom,
+ modelRolesAtom,
+} from "@/atoms/model-connections/model-connections-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
@@ -68,6 +68,7 @@ import {
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message";
import { ChatExamplePrompts } from "@/components/new-chat/chat-example-prompts";
+import { ChatHeader } from "@/components/new-chat/chat-header";
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
@@ -931,7 +932,10 @@ const Composer: FC = () => {
className="min-h-[48px] sm:min-h-[24px] **:data-slate-placeholder:font-normal"
/>
-
+
{
interface ComposerActionProps {
isBlockedByOtherUser?: boolean;
+ searchSpaceId: number;
}
-const ComposerAction: FC = ({ isBlockedByOtherUser = false }) => {
+const ComposerAction: FC = ({
+ isBlockedByOtherUser = false,
+ searchSpaceId,
+}) => {
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
@@ -980,9 +988,9 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false
if (url) setPendingScreenImages((prev) => [...prev, url]);
}, [electronAPI, setPendingScreenImages]);
- const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
- const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
- const { data: preferences } = useAtomValue(llmPreferencesAtom);
+ const { data: globalModelConnections } = useAtomValue(globalModelConnectionsAtom);
+ const { data: modelConnections } = useAtomValue(modelConnectionsAtom);
+ const { data: modelRoles } = useAtomValue(modelRolesAtom);
const { data: agentTools } = useAtomValue(agentToolsAtom);
const disabledTools = useAtomValue(disabledToolsAtom);
@@ -1069,15 +1077,18 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false
}, [hydrateDisabled]);
const hasModelConfigured = useMemo(() => {
- 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;
+ 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))
+ );
}
- return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
- }, [preferences, globalConfigs, userConfigs]);
+ return [...(globalModelConnections ?? []), ...(modelConnections ?? [])].some((connection) =>
+ connection.models.some(
+ (model) => model.id === chatModelId && model.enabled && Boolean(model.supports_chat)
+ )
+ );
+ }, [modelRoles?.chat_model_id, globalModelConnections, modelConnections]);
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
@@ -1559,6 +1570,10 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false
)}
+
!thread.isRunning}>
= ({ isBlockedByOtherUser = false
isBlockedByOtherUser
? "Wait for AI to finish responding"
: !hasModelConfigured
- ? "Please select a model from the header to start chatting"
+ ? "Please select a model 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 dd80bcac3..8db8c2b50 100644
--- a/surfsense_web/components/assistant-ui/token-usage-context.tsx
+++ b/surfsense_web/components/assistant-ui/token-usage-context.tsx
@@ -9,6 +9,18 @@ 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;
@@ -20,24 +32,8 @@ export interface TokenUsageData {
* before the migration won't have it.
*/
cost_micros?: number;
- 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;
- }
- >;
+ usage?: Record;
+ model_breakdown?: Record;
}
type Listener = () => void;
diff --git a/surfsense_web/components/free-chat/anonymous-chat.tsx b/surfsense_web/components/free-chat/anonymous-chat.tsx
index aff58f7bc..0829efba2 100644
--- a/surfsense_web/components/free-chat/anonymous-chat.tsx
+++ b/surfsense_web/components/free-chat/anonymous-chat.tsx
@@ -188,9 +188,6 @@ 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 b28b1e0a1..8508b3d0c 100644
--- a/surfsense_web/components/free-chat/free-chat-page.tsx
+++ b/surfsense_web/components/free-chat/free-chat-page.tsx
@@ -35,7 +35,6 @@ import {
} from "@/lib/chat/streaming-state";
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";
@@ -63,6 +62,21 @@ 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";
}
@@ -154,7 +168,7 @@ export function FreeChatPage() {
assistantMsgId: string,
signal: AbortSignal,
turnstileToken: string | null
- ): Promise<"captcha" | void> => {
+ ): Promise<"captcha" | undefined> => {
const reqBody: Record = {
model_slug: modelSlug,
messages: messageHistory,
@@ -484,10 +498,6 @@ 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 46d9e0259..162b906ad 100644
--- a/surfsense_web/components/free-chat/free-composer.tsx
+++ b/surfsense_web/components/free-chat/free-composer.tsx
@@ -13,6 +13,7 @@ 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",
@@ -227,7 +228,8 @@ 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 9bf4ecee5..d04bca8a2 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 { Bot, Check, ChevronDown } from "lucide-react";
+import { Check, ChevronDown, Cpu } 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/icons/providers/azure.svg b/surfsense_web/components/icons/providers/azure.svg
new file mode 100644
index 000000000..ba80f55ca
--- /dev/null
+++ b/surfsense_web/components/icons/providers/azure.svg
@@ -0,0 +1 @@
+
\ 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 195aa6594..cde500c0d 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
new file mode 100644
index 000000000..8d732d5b0
--- /dev/null
+++ b/surfsense_web/components/icons/providers/claude.svg
@@ -0,0 +1 @@
+
\ 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 aefa2a053..5c8276e62 100644
--- a/surfsense_web/components/icons/providers/index.ts
+++ b/surfsense_web/components/icons/providers/index.ts
@@ -1,8 +1,10 @@
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";
@@ -13,6 +15,7 @@ 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
new file mode 100644
index 000000000..b6ae7db3e
--- /dev/null
+++ b/surfsense_web/components/icons/providers/lm-studio.svg
@@ -0,0 +1,21 @@
+
diff --git a/surfsense_web/components/icons/providers/vertexai.svg b/surfsense_web/components/icons/providers/vertexai.svg
index 45adce83b..e46a3ca0f 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 549e6e7d7..429a1fde8 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 { AlertTriangle, Inbox, LibraryBig, Workflow } from "lucide-react";
+import { AlarmClock, AlertTriangle, Inbox, LibraryBig } 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: Workflow,
+ icon: AlarmClock,
isActive: isAutomationsActive,
},
isMobile
diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx
index 79839622d..ea700391a 100644
--- a/surfsense_web/components/layout/ui/header/Header.tsx
+++ b/surfsense_web/components/layout/ui/header/Header.tsx
@@ -6,7 +6,6 @@ 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";
@@ -66,13 +65,8 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
return (
- {/* Left side - Mobile menu trigger + Model selector */}
-
- {mobileMenuTrigger}
- {isChatPage && !isDocumentTab && searchSpaceId && (
-
- )}
-
+ {/* Left side - Mobile menu trigger */}
+ {mobileMenuTrigger}
{/* Right side - Actions */}
diff --git a/surfsense_web/components/new-chat/chat-example-prompts.tsx b/surfsense_web/components/new-chat/chat-example-prompts.tsx
index 61041cc29..344176629 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: Workflow,
+ automate: AlarmClock,
tools: Settings2,
};
diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx
index 4716418ee..d65dc93a7 100644
--- a/surfsense_web/components/new-chat/chat-header.tsx
+++ b/surfsense_web/components/new-chat/chat-header.tsx
@@ -1,17 +1,5 @@
"use client";
-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 {
@@ -20,148 +8,9 @@ interface 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/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx
index 0a096f5f8..a28f49e15 100644
--- a/surfsense_web/components/new-chat/model-selector.tsx
+++ b/surfsense_web/components/new-chat/model-selector.tsx
@@ -1,40 +1,16 @@
"use client";
-import { useAtomValue } from "jotai";
+import { useAtom, useAtomValue } from "jotai";
+import { Check, ChevronDown, Search, Settings2 } 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 {
- 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";
+ globalModelConnectionsAtom,
+ modelConnectionsAtom,
+ modelRolesAtom,
+} from "@/atoms/model-connections/model-connections-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -45,1404 +21,262 @@ 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import type {
- GlobalImageGenConfig,
- GlobalNewLLMConfig,
- GlobalVisionLLMConfig,
- ImageGenerationConfig,
- NewLLMConfigPublic,
- VisionLLMConfig,
-} from "@/contracts/types/new-llm-config.types";
+import type { ConnectionRead, ModelRead } from "@/contracts/types/model-connections.types";
import { useIsMobile } from "@/hooks/use-mobile";
-import { getProviderIcon } from "@/lib/provider-icons";
+import { AUTO_PROVIDER_ICON_KEY, getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
-
-// ─── Helpers ────────────────────────────────────────────────────────
-
-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",
-};
-
-// 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",
-];
-
-const IMAGE_PROVIDER_KEYS: string[] = [
- "OPENAI",
- "AZURE_OPENAI",
- "GOOGLE",
- "VERTEX_AI",
- "BEDROCK",
- "RECRAFT",
- "OPENROUTER",
- "XINFERENCE",
- "NSCALE",
-];
-
-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 normalizeText(input: string): string {
- return input
- .normalize("NFD")
- .replace(/\p{Diacritic}/gu, "")
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, " ")
- .trim();
-}
-
-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);
- }
-
- 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);
-}
-
-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);
- },
- []
- );
-
- 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 ──────────────────────────────────────────────────────
+import { providerDisplay } from "../settings/model-connections/provider-metadata";
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;
+ searchSpaceId: number;
className?: string;
}
-export function ModelSelector({
- onEditLLM,
- onAddNewLLM,
- onEditImage,
- onAddNewImage,
- onEditVision,
- onAddNewVision,
- className,
-}: ModelSelectorProps) {
- const [open, setOpen] = useState(false);
- 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);
+type ChatModel = ModelRead & {
+ connectionId: number;
+ connectionLabel: string;
+ connectionScope: string;
+ provider: string;
+};
+
+function connectionLabel(connection: ConnectionRead) {
+ if (connection.scope === "GLOBAL") return "Global";
+ return providerDisplay(connection.provider).name;
+}
+
+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,
+ }))
+ );
+}
+
+function isFreeGlobalModel(model: ChatModel) {
+ return model.connectionScope === "GLOBAL" && model.billing_tier?.toLowerCase() === "free";
+}
+
+function modelName(model: ChatModel) {
+ const name = model.display_name || model.model_id;
+ if (model.connectionScope === "GLOBAL") {
+ return name.replace(/\s+\(free\)$/i, "");
+ }
+ return name;
+}
+
+function groupedModels(models: ChatModel[]) {
+ return models.reduce>((groups, model) => {
+ const key = model.connectionLabel;
+ if (!groups[key]) groups[key] = [];
+ groups[key].push(model);
+ return groups;
+ }, {});
+}
+
+export function ModelSelector({ searchSpaceId, className }: ModelSelectorProps) {
+ const router = useRouter();
const isMobile = useIsMobile();
-
- const handleOpenChange = useCallback(
- (next: boolean) => {
- if (next) {
- setSearchQuery("");
- setSelectedProvider("all");
- if (!isMobile) {
- requestAnimationFrame(() => searchInputRef.current?.focus());
- }
- }
- setOpen(next);
- },
- [isMobile]
+ 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 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 chatModels = useMemo(() => {
+ const normalized = search.trim().toLowerCase();
+ const models = flattenChatModels([...globalConnections, ...connections]);
+ if (!normalized) return models;
+ return models.filter((model) =>
+ [modelName(model), model.model_id, model.connectionLabel]
+ .join(" ")
+ .toLowerCase()
+ .includes(normalized)
+ );
+ }, [globalConnections, connections, search]);
- const handleModelListScroll = useCallback((e: React.UIEvent) => {
- const el = e.currentTarget;
+ const selected = chatModels.find((model) => model.id === roles?.chat_model_id);
+ const groups = groupedModels(chatModels);
+ const loading = globalLoading || connectionsLoading;
+
+ function selectModel(modelId: number) {
+ updateRoles.mutate({ chat_model_id: modelId });
+ 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;
- setModelScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
+ setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
- 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 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 (
+ const content = (
+
+
+
+
+ setSearch(event.target.value)}
+ placeholder="Search chat models"
+ className="h-8 border-0 bg-transparent pl-6 text-sm shadow-none"
+ />
+
+
- {!isMobile && (
-
-
-
- )}
- {isMobile && (
-
-
-
- )}
-
selectModel(0)}
>
- {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
+
+
+ {getProviderIcon(AUTO_PROVIDER_ICON_KEY, { className: "size-4 shrink-0" })}
+ Auto
- ) : (
- (hasPremiumStatus || hasCitations) && (
-
- {hasPremiumStatus && (
-
- {isPremium ? "Premium" : "Free"}
-
- )}
- {hasCitations && (
-
- Citations
-
- )}
+
+ {(roles?.chat_model_id ?? 0) === 0 ?
: null}
+
+ {loading ? (
+
+
+
+ ) : Object.keys(groups).length === 0 ? (
+
+ No enabled chat models. Add or enable models in Settings.
+
+ ) : (
+ Object.entries(groups).map(([connection, models]) => (
+
+
+ {connection}
- )
- )}
-
-
- {/* 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 && (
-
-
- )}
-
-
-
- );
- };
+
+
+ {getProviderIcon(model.provider, { className: "size-4 shrink-0" })}
+ {modelName(model)}
+
+ {/* {model.max_input_tokens ? (
+
+ {model.max_input_tokens.toLocaleString()} context
+
+ ) : null} */}
+
+
+ {isFreeGlobalModel(model) ? (
+
+ Free
+
+ ) : null}
+ {/*
+ Re-enable this once the chat composer supports image input.
+ For now, surfacing `supports_image_input` in the chat model
+ selector is misleading because users cannot attach images.
- // ─── Trigger button ───
- const triggerButton = (
+ {!model.supports_image_input ? (
+
+ No image
+
+ ) : null}
+ */}
+ {roles?.chat_model_id === model.id ? : null}
+
+
+ ))}
+
+ ))
+ )}
+
+
+
+ Manage models
+
+
+
+ );
+
+ const trigger = (
- {isLoading ? (
- <>
-
- Loading
- >
- ) : (
- <>
- {/* LLM */}
- {currentLLMConfig ? (
- <>
- {getProviderIcon(currentLLMConfig.provider, {
- isAutoMode: isLLMAutoMode ?? false,
- })}
-
- {currentLLMConfig.name}
-
- >
- ) : (
- <>
-
- Select Model
- >
- )}
-
- {/* Image */}
- {currentImageConfig ? (
- <>
- {getProviderIcon(currentImageConfig.provider, {
- isAutoMode: isImageAutoMode ?? false,
- })}
-
- {currentImageConfig.name}
-
- >
- ) : (
-
- )}
-
- {/* Vision */}
- {currentVisionConfig ? (
- <>
- {getProviderIcon(currentVisionConfig.provider, {
- isAutoMode: isVisionAutoMode ?? false,
- })}
-
- {currentVisionConfig.name}
-
- >
- ) : (
-
- )}
- >
- )}
-
+ {selected
+ ? getProviderIcon(selected.provider, { className: "size-4 shrink-0" })
+ : getProviderIcon(AUTO_PROVIDER_ICON_KEY, { className: "size-4 shrink-0" })}
+
+ {selected ? modelName(selected) : "Auto"}
+
+
);
- // ─── Shell: Drawer on mobile, Popover on desktop ───
if (isMobile) {
return (
-
- {triggerButton}
+
+ {trigger}
-
- Select Model
+
+ Select Chat Model
- {renderContent()}
+ {content}
);
}
return (
-
- {triggerButton}
- e.preventDefault()}
- >
- {renderContent()}
+
+ {trigger}
+
+ {content}
);
diff --git a/surfsense_web/components/settings/agent-model-manager.tsx b/surfsense_web/components/settings/agent-model-manager.tsx
deleted file mode 100644
index 507a263e0..000000000
--- a/surfsense_web/components/settings/agent-model-manager.tsx
+++ /dev/null
@@ -1,423 +0,0 @@
-"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 */}
-
- refreshConfigs()}
- disabled={isLoading}
- className="gap-2"
- >
-
- Refresh
-
- {canCreate && (
-
- Add Model
-
- )}
-
-
- {/* 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 && (
-
-
-
- openEditDialog(config)}
- className="h-7 w-7 rounded-lg text-muted-foreground hover:text-accent-foreground"
- >
-
-
-
- Edit
-
-
- )}
- {canDelete && (
-
-
-
- setConfigToDelete(config)}
- className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
- >
-
-
-
- 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/image-model-manager.tsx b/surfsense_web/components/settings/image-model-manager.tsx
deleted file mode 100644
index 494f7aae9..000000000
--- a/surfsense_web/components/settings/image-model-manager.tsx
+++ /dev/null
@@ -1,489 +0,0 @@
-"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 */}
-
- refreshConfigs()}
- disabled={isLoading}
- className="gap-2"
- >
-
- Refresh
-
- {canCreate && (
-
- Add Image Model
-
- )}
-
-
- {/* 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 && (
-
-
-
- openEditDialog(config)}
- className="h-7 w-7 rounded-lg text-muted-foreground hover:text-accent-foreground"
- >
-
-
-
- Edit
-
-
- )}
- {canDelete && (
-
-
-
- setConfigToDelete(config)}
- className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
- >
-
-
-
- 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
deleted file mode 100644
index c32e79a8e..000000000
--- a/surfsense_web/components/settings/llm-role-manager.tsx
+++ /dev/null
@@ -1,443 +0,0 @@
-"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 */}
-
- refreshConfigs()}
- disabled={isLoading}
- className="gap-2"
- >
-
- Refresh
-
-
-
- {/* 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
new file mode 100644
index 000000000..3b30b1558
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections-settings.tsx
@@ -0,0 +1,153 @@
+"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
new file mode 100644
index 000000000..451f053db
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/azure-connect-form.tsx
@@ -0,0 +1,54 @@
+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
new file mode 100644
index 000000000..f76308421
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/bedrock-connect-form.tsx
@@ -0,0 +1,120 @@
+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
new file mode 100644
index 000000000..584fb98b0
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/connect-fields.tsx
@@ -0,0 +1,105 @@
+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"
+ />
+ setShowApiKey((current) => !current)}
+ disabled={!value}
+ aria-label={showApiKey ? "Hide API key" : "Show API key"}
+ >
+ {showApiKey ? : }
+
+
+
+ );
+}
+
+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 (
+
+
+ Cancel
+
+
+ Connect
+ {isPending ? : null}
+
+
+ );
+}
diff --git a/surfsense_web/components/settings/model-connections/connection-card.tsx b/surfsense_web/components/settings/model-connections/connection-card.tsx
new file mode 100644
index 000000000..b482cac9f
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/connection-card.tsx
@@ -0,0 +1,88 @@
+"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
new file mode 100644
index 000000000..1f16c3bd0
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/connection-settings-dialog.tsx
@@ -0,0 +1,333 @@
+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
new file mode 100644
index 000000000..e3111202d
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/default-connect-form.tsx
@@ -0,0 +1,62 @@
+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
new file mode 100644
index 000000000..a703ab1c8
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx
@@ -0,0 +1,299 @@
+"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 (
+ openProviderDialog(item.provider)}
+ >
+
+ {providerIcon(item.provider, "size-5")}
+
+ {meta.name}
+
+ {meta.subtitle}
+
+
+
+ Connect
+
+ );
+ })}
+
+
+
+
+
+ {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
new file mode 100644
index 000000000..2887f2179
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/model-utils.ts
@@ -0,0 +1,30 @@
+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
new file mode 100644
index 000000000..3c6990afb
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/models-selection-panel.tsx
@@ -0,0 +1,198 @@
+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}
+
+
+
+ {allFilteredModelsEnabled ? "Deselect All" : "Select All"}
+
+ {onRefresh ? (
+
+
+
+ ) : null}
+
+
+
+ {onAddManual ? (
+
+ setManualModelId(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ addModel();
+ }
+ }}
+ placeholder={manualInputPlaceholder}
+ />
+
+ Add model
+ {isAddingManual ? : null}
+
+
+ ) : 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 (
+ setModelFilter(isActive ? null : filter.key)}
+ >
+ {filter.label}
+ {count}
+
+ );
+ })}
+
+ ) : 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
new file mode 100644
index 000000000..2eee2cf8c
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx
@@ -0,0 +1,155 @@
+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
new file mode 100644
index 000000000..8b8a877b9
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/provider-metadata.tsx
@@ -0,0 +1,137 @@
+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
new file mode 100644
index 000000000..1027742bc
--- /dev/null
+++ b/surfsense_web/components/settings/model-connections/vertex-connect-form.tsx
@@ -0,0 +1,118 @@
+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
deleted file mode 100644
index 31578b4f1..000000000
--- a/surfsense_web/components/settings/vision-model-manager.tsx
+++ /dev/null
@@ -1,486 +0,0 @@
-"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 (
-
-
- refreshConfigs()}
- disabled={isLoading}
- className="gap-2"
- >
-
- Refresh
-
- {canCreate && (
-
- Add Vision Model
-
- )}
-
-
- {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 && (
-
-
-
- openEditDialog(config)}
- className="h-6 w-6 text-muted-foreground hover:text-accent-foreground"
- >
-
-
-
- Edit
-
-
- )}
- {canDelete && (
-
-
-
- setConfigToDelete(config)}
- className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
- >
-
-
-
- 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
deleted file mode 100644
index 36d16081a..000000000
--- a/surfsense_web/components/shared/image-config-dialog.tsx
+++ /dev/null
@@ -1,456 +0,0 @@
-"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
deleted file mode 100644
index 06de4129b..000000000
--- a/surfsense_web/components/shared/llm-config-form.tsx
+++ /dev/null
@@ -1,527 +0,0 @@
-"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
deleted file mode 100644
index d4f57ff7d..000000000
--- a/surfsense_web/components/shared/model-config-dialog.tsx
+++ /dev/null
@@ -1,339 +0,0 @@
-"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
deleted file mode 100644
index 2646f3842..000000000
--- a/surfsense_web/components/shared/vision-config-dialog.tsx
+++ /dev/null
@@ -1,478 +0,0 @@
-"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/tool-ui/automation/create-automation.tsx b/surfsense_web/components/tool-ui/automation/create-automation.tsx
index 24e9d66bd..8775b275b 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 { AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil, Workflow } from "lucide-react";
+import { AlarmClock, AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil } 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({
- agentLlmId: 0,
+ chatModelId: 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(
() => ({
- agentLlmId: modelSelection.agentLlmId || eligibleModels.llm.defaultId || 0,
+ chatModelId: modelSelection.chatModelId || 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.agentLlmId !== 0 &&
+ resolvedModels.chatModelId !== 0 &&
resolvedModels.imageConfigId !== 0 &&
resolvedModels.visionConfigId !== 0;
@@ -147,9 +147,9 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
definition: {
...baseDefinition,
models: {
- agent_llm_id: resolvedModels.agentLlmId,
- image_generation_config_id: resolvedModels.imageConfigId,
- vision_llm_config_id: resolvedModels.visionConfigId,
+ chat_model_id: resolvedModels.chatModelId,
+ image_gen_model_id: resolvedModels.imageConfigId,
+ vision_model_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"),
- agent_llm_id: resolvedModels.agentLlmId,
- image_generation_config_id: resolvedModels.imageConfigId,
- vision_llm_config_id: resolvedModels.visionConfigId,
+ chat_model_id: resolvedModels.chatModelId,
+ image_gen_model_id: resolvedModels.imageConfigId,
+ vision_model_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/content/docs/how-to/meta.json b/surfsense_web/content/docs/how-to/meta.json
index 329b7172e..477fcafc4 100644
--- a/surfsense_web/content/docs/how-to/meta.json
+++ b/surfsense_web/content/docs/how-to/meta.json
@@ -1,6 +1,6 @@
{
"title": "How to",
- "pages": ["zero-sync", "realtime-collaboration", "web-search", "ollama"],
+ "pages": ["zero-sync", "realtime-collaboration", "web-search"],
"icon": "Compass",
"defaultOpen": false
}
diff --git a/surfsense_web/content/docs/how-to/ollama.mdx b/surfsense_web/content/docs/how-to/ollama.mdx
deleted file mode 100644
index 48b231705..000000000
--- a/surfsense_web/content/docs/how-to/ollama.mdx
+++ /dev/null
@@ -1,90 +0,0 @@
----
-title: Connect Ollama
-description: Simple setup guide for using Ollama with SurfSense across local, Docker, remote, and cloud setups
----
-
-# Connect Ollama
-
-Use this page to choose the correct **API Base URL** when adding an Ollama provider in SurfSense.
-
-## 1) Pick your API Base URL
-
-| Ollama location | SurfSense location | API Base URL |
-|---|---|---|
-| Same machine | No Docker | `http://localhost:11434` |
-| Host machine (macOS/Windows) | Docker Desktop | `http://host.docker.internal:11434` |
-| Host machine (Linux) | Docker Compose | `http://host.docker.internal:11434` |
-| Same Docker Compose stack | Docker Compose | `http://ollama:11434` |
-| Another machine in your network | Any | `http://
:11434` |
-| Public Ollama endpoint / proxy / cloud | Any | `http(s)://` |
-
-If SurfSense runs in Docker, do not use `localhost` unless Ollama is in the same container.
-
-## 2) Add Ollama in SurfSense
-
-Go to **Search Space Settings -> Agent Models -> Add Model** and set:
-
-- Provider: `OLLAMA`
-- Model name: your model tag, for example `llama3.2` or `qwen3:8b`
-- API Base URL: from the table above
-- API key:
- - local/self-hosted Ollama: any non-empty value
- - Ollama cloud/proxied auth: real key or token required by that endpoint
-
-Save. SurfSense validates the connection immediately.
-
-## 3) Common setups
-
-### A) SurfSense in Docker Desktop, Ollama on your host
-
-Use:
-
-```text
-http://host.docker.internal:11434
-```
-
-### B) Ollama as a service in the same Compose
-
-Use API Base URL:
-
-```text
-http://ollama:11434
-```
-
-Minimal service example:
-
-```yaml
-ollama:
- image: ollama/ollama:latest
- volumes:
- - ollama_data:/root/.ollama
- ports:
- - "11434:11434"
-```
-
-### C) Ollama on another machine
-
-Ollama binds to `127.0.0.1` by default. Make it reachable on the network:
-
-- Set `OLLAMA_HOST=0.0.0.0:11434` on the machine/service running Ollama
-- Open firewall port `11434`
-- Use `http://:11434` in SurfSense's API Base URL
-
-## 4) Quick troubleshooting
-
-| Error | Cause | Fix |
-|---|---|---|
-| `Cannot connect to host localhost:11434` | Wrong URL from Dockerized backend | Use `host.docker.internal` or `ollama` |
-| `Cannot connect to host :11434` | Ollama not exposed on network or firewall blocked | Set `OLLAMA_HOST=0.0.0.0:11434`, allow port 11434 |
-| URL starts with `/%20http://...` | Leading space in URL | Re-enter API Base URL without spaces |
-| `model not found` | Model not pulled on Ollama | Run `ollama pull ` |
-
-If needed, test from the backend container using the same host you put in **API Base URL**:
-
-```bash
-docker compose exec backend curl -v /api/tags
-```
-
-## See also
-
-- [Docker Installation](/docs/docker-installation/docker-compose)
\ No newline at end of file
diff --git a/surfsense_web/content/docs/index.mdx b/surfsense_web/content/docs/index.mdx
index 4a321b376..c8540fed0 100644
--- a/surfsense_web/content/docs/index.mdx
+++ b/surfsense_web/content/docs/index.mdx
@@ -5,7 +5,7 @@ icon: BookOpen
---
import { Card, Cards } from 'fumadocs-ui/components/card';
-import { ClipboardCheck, Download, Container, Wrench, Cable, BookOpen, FlaskConical, Heart, MessageCircle } from 'lucide-react';
+import { ClipboardCheck, Download, Container, Wrench, Cable, BookOpen, FlaskConical, Heart, MessageCircle, Cpu } from 'lucide-react';
Welcome to **SurfSense's Documentation!** Here, you'll find everything you need to get the most out of SurfSense. Dive in to explore how SurfSense can be your AI-powered research companion.
@@ -34,6 +34,12 @@ Welcome to **SurfSense's Documentation!** Here, you'll find everything you need
description="Set up SurfSense manually from source"
href="/docs/manual-installation"
/>
+ }
+ title="Local Models"
+ description="Connect local model servers"
+ href="/docs/local-models"
+ />
}
title="Connectors"
diff --git a/surfsense_web/content/docs/local-models/index.mdx b/surfsense_web/content/docs/local-models/index.mdx
new file mode 100644
index 000000000..e1839a4f0
--- /dev/null
+++ b/surfsense_web/content/docs/local-models/index.mdx
@@ -0,0 +1,30 @@
+---
+title: Local Models
+description: Connect local model servers to SurfSense
+---
+
+import { Card, Cards } from 'fumadocs-ui/components/card';
+
+# Local Models
+
+SurfSense can use local model servers such as Ollama and LM Studio.
+
+The API Base URL is read by the SurfSense backend. If SurfSense runs in Docker, use an address the backend container can reach.
+
+
+
+
+
+
diff --git a/surfsense_web/content/docs/local-models/lm-studio.mdx b/surfsense_web/content/docs/local-models/lm-studio.mdx
new file mode 100644
index 000000000..6877786e5
--- /dev/null
+++ b/surfsense_web/content/docs/local-models/lm-studio.mdx
@@ -0,0 +1,92 @@
+---
+title: LM Studio
+description: Connect LM Studio to SurfSense
+---
+
+# Connect LM Studio
+
+Connect to your LM Studio local server. Add it from
+**Search Space Settings > Models**.
+
+## Base URL
+
+LM Studio uses an OpenAI compatible server.
+
+### SurfSense Runs in Docker
+
+Use this when SurfSense is running from Docker and LM Studio is running on your computer.
+
+```text
+http://host.docker.internal:1234/v1
+```
+
+
+This is the default in SurfSense.
+
+
+### SurfSense Runs Without Docker
+
+Use this when SurfSense and LM Studio both run directly on the same computer.
+
+```text
+http://localhost:1234/v1
+```
+
+### LM Studio Runs on Another Computer
+
+Use this when LM Studio is running on another machine in your network.
+
+```text
+http://:1234/v1
+```
+
+Replace `` with the LAN IP or domain for that machine.
+
+## LM Studio Setup
+
+1. Open LM Studio.
+2. Load a model.
+3. Start the local server.
+4. Confirm the server listens on port `1234`.
+
+## Add the Connection
+
+1. Open Search Space Settings.
+2. Go to Models.
+3. Select LM Studio.
+4. Set API Base URL.
+5. Leave API Key empty unless your server requires one.
+6. Select the models you want to enable.
+7. Save the connection.
+
+SurfSense discovers models from `/v1/models`. If you enter the URL without `/v1`, SurfSense adds it for requests.
+
+## Verify
+
+From the host:
+
+```bash
+curl http://localhost:1234/v1/models
+```
+
+From the SurfSense backend container:
+
+```bash
+docker compose exec backend curl http://host.docker.internal:1234/v1/models
+```
+
+## Troubleshooting
+
+### Connection refused
+
+LM Studio is not reachable from the backend.
+
+Start the LM Studio server and confirm that port `1234` is open.
+
+### No models found
+
+Load a model in LM Studio, then refresh model discovery in SurfSense.
+
+### Endpoint returned 404
+
+Use an OpenAI compatible server URL. The models endpoint must be available at `/v1/models`.
diff --git a/surfsense_web/content/docs/local-models/meta.json b/surfsense_web/content/docs/local-models/meta.json
new file mode 100644
index 000000000..d904a8d5c
--- /dev/null
+++ b/surfsense_web/content/docs/local-models/meta.json
@@ -0,0 +1,6 @@
+{
+ "title": "Local Models",
+ "pages": ["ollama", "lm-studio", "other-local-servers"],
+ "icon": "Cpu",
+ "defaultOpen": false
+}
diff --git a/surfsense_web/content/docs/local-models/ollama.mdx b/surfsense_web/content/docs/local-models/ollama.mdx
new file mode 100644
index 000000000..b062d98b0
--- /dev/null
+++ b/surfsense_web/content/docs/local-models/ollama.mdx
@@ -0,0 +1,102 @@
+---
+title: Ollama
+description: Connect Ollama to SurfSense
+---
+
+# Connect Ollama
+
+Connect to your Ollama local server. Add it from
+**Search Space Settings > Models**.
+
+## Base URL
+
+Choose the URL from where the SurfSense backend runs.
+
+### SurfSense Runs in Docker
+
+Use this when SurfSense is running from Docker and Ollama is running on your computer.
+
+```text
+http://host.docker.internal:11434
+```
+
+
+This is the default in SurfSense.
+
+
+### Ollama Runs in Docker
+
+Use this only when Ollama is a service in the same Compose stack as SurfSense.
+
+```text
+http://ollama:11434
+```
+
+### SurfSense Runs Without Docker
+
+Use this when SurfSense and Ollama both run directly on the same computer.
+
+```text
+http://localhost:11434
+```
+
+### Ollama Runs on Another Computer
+
+Use this when Ollama is running on another machine in your network.
+
+```text
+http://:11434
+```
+
+Replace `` with the LAN IP or domain for that machine.
+
+## Add the Connection
+
+1. Open Search Space Settings.
+2. Go to Models.
+3. Select Ollama.
+4. Set API Base URL.
+5. Leave API Key empty unless your endpoint needs one.
+6. Select the models you want to enable.
+7. Save the connection.
+
+Do not add `/v1` to the URL. SurfSense uses Ollama native routes such as `/api/version` and `/api/tags`.
+
+## Verify
+
+From the host:
+
+```bash
+curl http://localhost:11434/api/version
+```
+
+From the SurfSense backend container:
+
+```bash
+docker compose exec backend curl http://host.docker.internal:11434/api/version
+docker compose exec backend curl http://host.docker.internal:11434/api/tags
+```
+
+## Troubleshooting
+
+### Name or service not known
+
+The backend cannot resolve the host name.
+
+Use `http://host.docker.internal:11434` unless you run Ollama as a Compose service named `ollama`.
+
+### Connection refused
+
+Ollama is not reachable from the backend.
+
+Start Ollama and confirm that port `11434` is open.
+
+### No models found
+
+Pull at least one model:
+
+```bash
+ollama pull llama3.2
+```
+
+Then refresh model discovery in SurfSense.
diff --git a/surfsense_web/content/docs/local-models/other-local-servers.mdx b/surfsense_web/content/docs/local-models/other-local-servers.mdx
new file mode 100644
index 000000000..669684929
--- /dev/null
+++ b/surfsense_web/content/docs/local-models/other-local-servers.mdx
@@ -0,0 +1,109 @@
+---
+title: Other Local Servers
+description: Connect local OpenAI compatible model servers
+---
+
+# Connect Other Local Servers
+
+Connect to llama.cpp, vLLM, LocalAI, LiteLLM Proxy, and other servers
+that expose OpenAI compatible routes.
+
+SurfSense discovers models from:
+
+```text
+/v1/models
+```
+
+Chat requests use the same `/v1` base URL.
+
+## Pick Your Setup
+
+Use one of these URL patterns.
+
+### SurfSense Runs in Docker
+
+Use this when SurfSense is running from Docker and the model server is running on your computer.
+
+```text
+http://host.docker.internal:/v1
+```
+
+Common ports:
+
+| Server | Port |
+|---|---|
+| llama.cpp | `10000` |
+| vLLM | `8000` |
+| LocalAI | `8080` |
+| LiteLLM Proxy | `4000` |
+| text-generation-webui | `5000` |
+
+### SurfSense Runs Without Docker
+
+Use this when SurfSense and the model server both run directly on the same computer.
+
+```text
+http://localhost:/v1
+```
+
+### Model Server Runs on Another Computer
+
+Use this when the model server is running on another machine in your network.
+
+```text
+http://:/v1
+```
+
+## Add the Connection
+
+1. Open Search Space Settings.
+2. Go to Models.
+3. Select OpenAI Compatible.
+4. Set API Base URL.
+5. Add an API Key only if your server requires one.
+6. Select the models you want to enable.
+7. Save the connection.
+
+If you enter the URL without `/v1`, SurfSense adds `/v1` for requests.
+
+## Verify
+
+From the host:
+
+```bash
+curl http://localhost:/v1/models
+```
+
+From the SurfSense backend container:
+
+```bash
+docker compose exec backend curl http://host.docker.internal:/v1/models
+```
+
+A working server returns JSON with a `data` array.
+
+## When Not to Use This
+
+Use the Ollama provider for Ollama. It uses native routes such as `/api/tags`.
+
+Use the LM Studio provider for LM Studio. Its default URL is already set.
+
+## Troubleshooting
+
+### Endpoint returned 404
+
+The server does not expose `/v1/models`.
+
+Enable the server's OpenAI compatible mode.
+
+### Connection refused
+
+The backend cannot reach the server.
+
+Check that the server is running and that the port is open.
+
+### No models found
+
+The server returned an empty model list.
+
+Load or serve a model, then refresh model discovery in SurfSense.
diff --git a/surfsense_web/content/docs/meta.json b/surfsense_web/content/docs/meta.json
index 74be10600..435e49f9f 100644
--- a/surfsense_web/content/docs/meta.json
+++ b/surfsense_web/content/docs/meta.json
@@ -9,6 +9,7 @@
"installation",
"manual-installation",
"docker-installation",
+ "local-models",
"messaging-channels",
"connectors",
"how-to",
diff --git a/surfsense_web/contracts/enums/image-gen-providers.ts b/surfsense_web/contracts/enums/image-gen-providers.ts
deleted file mode 100644
index 8410aeb4b..000000000
--- a/surfsense_web/contracts/enums/image-gen-providers.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-export interface ImageGenProvider {
- value: string;
- label: string;
- example: string;
- description: string;
- apiBase?: string;
-}
-
-/**
- * Image generation providers supported by LiteLLM.
- * See: https://docs.litellm.ai/docs/image_generation#supported-providers
- */
-export const IMAGE_GEN_PROVIDERS: ImageGenProvider[] = [
- {
- value: "OPENAI",
- label: "OpenAI",
- example: "dall-e-3, gpt-image-1, dall-e-2",
- description: "DALL-E and GPT Image models",
- },
- {
- value: "AZURE_OPENAI",
- label: "Azure OpenAI",
- example: "azure/dall-e-3, azure/gpt-image-1",
- description: "OpenAI image models on Azure",
- },
- {
- value: "GOOGLE",
- label: "Google AI Studio",
- example: "gemini/imagen-3.0-generate-002",
- description: "Google AI Studio image generation",
- },
- {
- value: "VERTEX_AI",
- label: "Google Vertex AI",
- example: "vertex_ai/imagegeneration@006",
- description: "Vertex AI image generation models",
- },
- {
- value: "BEDROCK",
- label: "AWS Bedrock",
- example: "bedrock/stability.stable-diffusion-xl-v0",
- description: "Stable Diffusion on AWS Bedrock",
- },
- {
- value: "RECRAFT",
- label: "Recraft",
- example: "recraft/recraftv3",
- description: "AI-powered design and image generation",
- },
- {
- value: "OPENROUTER",
- label: "OpenRouter",
- example: "openrouter/google/gemini-2.5-flash-image",
- description: "Image generation via OpenRouter",
- },
- {
- value: "XINFERENCE",
- label: "Xinference",
- example: "xinference/stable-diffusion-xl",
- description: "Self-hosted Stable Diffusion models",
- },
- {
- value: "NSCALE",
- label: "Nscale",
- example: "nscale/flux.1-schnell",
- description: "Nscale image generation",
- },
-];
-
-/**
- * Image generation models organized by provider.
- */
-export interface ImageGenModel {
- value: string;
- label: string;
- provider: string;
-}
-
-export const IMAGE_GEN_MODELS: ImageGenModel[] = [
- // OpenAI
- { value: "gpt-image-1", label: "GPT Image 1", provider: "OPENAI" },
- { value: "dall-e-3", label: "DALL-E 3", provider: "OPENAI" },
- { value: "dall-e-2", label: "DALL-E 2", provider: "OPENAI" },
- // Azure OpenAI
- { value: "azure/dall-e-3", label: "DALL-E 3 (Azure)", provider: "AZURE_OPENAI" },
- { value: "azure/gpt-image-1", label: "GPT Image 1 (Azure)", provider: "AZURE_OPENAI" },
- // Recraft
- { value: "recraft/recraftv3", label: "Recraft V3", provider: "RECRAFT" },
- // Bedrock
- {
- value: "bedrock/stability.stable-diffusion-xl-v0",
- label: "Stable Diffusion XL",
- provider: "BEDROCK",
- },
- // Vertex AI
- {
- value: "vertex_ai/imagegeneration@006",
- label: "Imagen 3",
- provider: "VERTEX_AI",
- },
-];
-
-export function getImageGenModelsByProvider(provider: string): ImageGenModel[] {
- return IMAGE_GEN_MODELS.filter((m) => m.provider === provider);
-}
diff --git a/surfsense_web/contracts/enums/llm-models.ts b/surfsense_web/contracts/enums/llm-models.ts
deleted file mode 100644
index 9647c9d31..000000000
--- a/surfsense_web/contracts/enums/llm-models.ts
+++ /dev/null
@@ -1,1558 +0,0 @@
-export interface LLMModel {
- value: string;
- label: string;
- provider: string;
- contextWindow?: string;
-}
-
-// Comprehensive models database organized by provider
-export const LLM_MODELS: LLMModel[] = [
- // OpenAI
- {
- value: "gpt-4o",
- label: "GPT-4o",
- provider: "OPENAI",
- contextWindow: "128K",
- },
- {
- value: "gpt-4o-mini",
- label: "GPT-4o Mini",
- provider: "OPENAI",
- contextWindow: "128K",
- },
- {
- value: "gpt-4o-2024-11-20",
- label: "GPT-4o (Nov 2024)",
- provider: "OPENAI",
- contextWindow: "128K",
- },
- {
- value: "gpt-4o-2024-08-06",
- label: "GPT-4o (Aug 2024)",
- provider: "OPENAI",
- contextWindow: "128K",
- },
- {
- value: "gpt-4o-2024-05-13",
- label: "GPT-4o (May 2024)",
- provider: "OPENAI",
- contextWindow: "128K",
- },
- {
- value: "gpt-4-turbo",
- label: "GPT-4 Turbo",
- provider: "OPENAI",
- contextWindow: "128K",
- },
- { value: "gpt-4", label: "GPT-4", provider: "OPENAI", contextWindow: "8K" },
- {
- value: "gpt-3.5-turbo",
- label: "GPT-3.5 Turbo",
- provider: "OPENAI",
- contextWindow: "16K",
- },
- { value: "o1", label: "O1", provider: "OPENAI", contextWindow: "200K" },
- {
- value: "o1-mini",
- label: "O1 Mini",
- provider: "OPENAI",
- contextWindow: "128K",
- },
- {
- value: "o1-preview",
- label: "O1 Preview",
- provider: "OPENAI",
- contextWindow: "128K",
- },
- { value: "o3", label: "O3", provider: "OPENAI", contextWindow: "200K" },
- {
- value: "o3-mini",
- label: "O3 Mini",
- provider: "OPENAI",
- contextWindow: "200K",
- },
- {
- value: "o4-mini",
- label: "O4 Mini",
- provider: "OPENAI",
- contextWindow: "200K",
- },
- {
- value: "gpt-4.1",
- label: "GPT-4.1",
- provider: "OPENAI",
- contextWindow: "1M",
- },
- {
- value: "gpt-4.1-mini",
- label: "GPT-4.1 Mini",
- provider: "OPENAI",
- contextWindow: "1M",
- },
- {
- value: "gpt-4.1-nano",
- label: "GPT-4.1 Nano",
- provider: "OPENAI",
- contextWindow: "1M",
- },
- { value: "gpt-5", label: "GPT-5", provider: "OPENAI", contextWindow: "272K" },
- {
- value: "gpt-5-mini",
- label: "GPT-5 Mini",
- provider: "OPENAI",
- contextWindow: "272K",
- },
- {
- value: "gpt-5-nano",
- label: "GPT-5 Nano",
- provider: "OPENAI",
- contextWindow: "272K",
- },
- {
- value: "chatgpt-4o-latest",
- label: "ChatGPT-4o Latest",
- provider: "OPENAI",
- contextWindow: "128K",
- },
-
- // Anthropic
- {
- value: "claude-3-5-sonnet-20241022",
- label: "Claude 3.5 Sonnet",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-3-7-sonnet-20250219",
- label: "Claude 3.7 Sonnet",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-4-sonnet-20250514",
- label: "Claude 4 Sonnet",
- provider: "ANTHROPIC",
- contextWindow: "1M",
- },
- {
- value: "claude-4-opus-20250514",
- label: "Claude 4 Opus",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-3-5-haiku-20241022",
- label: "Claude 3.5 Haiku",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-haiku-4-5-20251001",
- label: "Claude Haiku 4.5",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-3-opus-20240229",
- label: "Claude 3 Opus",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-3-haiku-20240307",
- label: "Claude 3 Haiku",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-sonnet-4-5-20250929",
- label: "Claude Sonnet 4.5",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-opus-4-1-20250805",
- label: "Claude Opus 4.1",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
-
- // Google (Gemini)
- {
- value: "gemini-3-flash-preview",
- label: "Gemini 3 Flash",
- provider: "GOOGLE",
- contextWindow: "1M",
- },
- {
- value: "gemini-3-pro-preview",
- label: "Gemini 3 Pro",
- provider: "GOOGLE",
- contextWindow: "1M",
- },
- {
- value: "gemini-2.5-flash",
- label: "Gemini 2.5 Flash",
- provider: "GOOGLE",
- contextWindow: "1M",
- },
- {
- value: "gemini-2.5-pro",
- label: "Gemini 2.5 Pro",
- provider: "GOOGLE",
- contextWindow: "1M",
- },
- {
- value: "gemini-2.0-flash",
- label: "Gemini 2.0 Flash",
- provider: "GOOGLE",
- contextWindow: "1M",
- },
- {
- value: "gemini-2.0-flash-lite",
- label: "Gemini 2.0 Flash Lite",
- provider: "GOOGLE",
- contextWindow: "1M",
- },
- {
- value: "gemini-1.5-flash",
- label: "Gemini 1.5 Flash",
- provider: "GOOGLE",
- contextWindow: "1M",
- },
- {
- value: "gemini-1.5-pro",
- label: "Gemini 1.5 Pro",
- provider: "GOOGLE",
- contextWindow: "2M",
- },
- {
- value: "gemini-pro",
- label: "Gemini Pro",
- provider: "GOOGLE",
- contextWindow: "33K",
- },
- {
- value: "gemini-pro-vision",
- label: "Gemini Pro Vision",
- provider: "GOOGLE",
- contextWindow: "16K",
- },
-
- // DeepSeek
- {
- value: "deepseek-chat",
- label: "DeepSeek Chat",
- provider: "DEEPSEEK",
- contextWindow: "131K",
- },
- {
- value: "deepseek-reasoner",
- label: "DeepSeek Reasoner",
- provider: "DEEPSEEK",
- contextWindow: "131K",
- },
- {
- value: "deepseek-coder",
- label: "DeepSeek Coder",
- provider: "DEEPSEEK",
- contextWindow: "128K",
- },
-
- // xAI (Grok)
- { value: "grok-4", label: "Grok 4", provider: "XAI", contextWindow: "256K" },
- { value: "grok-3", label: "Grok 3", provider: "XAI", contextWindow: "131K" },
- {
- value: "grok-3-mini",
- label: "Grok 3 Mini",
- provider: "XAI",
- contextWindow: "131K",
- },
- {
- value: "grok-3-fast-beta",
- label: "Grok 3 Fast",
- provider: "XAI",
- contextWindow: "131K",
- },
- {
- value: "grok-3-mini-fast",
- label: "Grok 3 Mini Fast",
- provider: "XAI",
- contextWindow: "131K",
- },
- { value: "grok-2", label: "Grok 2", provider: "XAI", contextWindow: "131K" },
- {
- value: "grok-2-vision",
- label: "Grok 2 Vision",
- provider: "XAI",
- contextWindow: "33K",
- },
-
- // Azure OpenAI
- {
- value: "gpt-4o",
- label: "Azure GPT-4o",
- provider: "AZURE_OPENAI",
- contextWindow: "128K",
- },
- {
- value: "gpt-4o-mini",
- label: "Azure GPT-4o Mini",
- provider: "AZURE_OPENAI",
- contextWindow: "128K",
- },
- {
- value: "gpt-4o-2024-11-20",
- label: "Azure GPT-4o (Nov 2024)",
- provider: "AZURE_OPENAI",
- contextWindow: "128K",
- },
- {
- value: "gpt-4-turbo",
- label: "Azure GPT-4 Turbo",
- provider: "AZURE_OPENAI",
- contextWindow: "128K",
- },
- {
- value: "gpt-4",
- label: "Azure GPT-4",
- provider: "AZURE_OPENAI",
- contextWindow: "8K",
- },
- {
- value: "gpt-35-turbo",
- label: "Azure GPT-3.5 Turbo",
- provider: "AZURE_OPENAI",
- contextWindow: "4K",
- },
- {
- value: "o1",
- label: "Azure O1",
- provider: "AZURE_OPENAI",
- contextWindow: "200K",
- },
- {
- value: "o1-mini",
- label: "Azure O1 Mini",
- provider: "AZURE_OPENAI",
- contextWindow: "128K",
- },
- {
- value: "o3-mini",
- label: "Azure O3 Mini",
- provider: "AZURE_OPENAI",
- contextWindow: "200K",
- },
- {
- value: "gpt-4.1",
- label: "Azure GPT-4.1",
- provider: "AZURE_OPENAI",
- contextWindow: "1M",
- },
- {
- value: "gpt-4.1-mini",
- label: "Azure GPT-4.1 Mini",
- provider: "AZURE_OPENAI",
- contextWindow: "1M",
- },
- {
- value: "gpt-5",
- label: "Azure GPT-5",
- provider: "AZURE_OPENAI",
- contextWindow: "272K",
- },
-
- // AWS Bedrock
- {
- value: "anthropic.claude-3-5-sonnet-20241022-v2:0",
- label: "Bedrock Claude 3.5 Sonnet",
- provider: "BEDROCK",
- contextWindow: "200K",
- },
- {
- value: "anthropic.claude-3-7-sonnet-20250219-v1:0",
- label: "Bedrock Claude 3.7 Sonnet",
- provider: "BEDROCK",
- contextWindow: "200K",
- },
- {
- value: "anthropic.claude-4-sonnet-20250514-v1:0",
- label: "Bedrock Claude 4 Sonnet",
- provider: "BEDROCK",
- contextWindow: "1M",
- },
- {
- value: "anthropic.claude-3-opus-20240229-v1:0",
- label: "Bedrock Claude 3 Opus",
- provider: "BEDROCK",
- contextWindow: "200K",
- },
- {
- value: "anthropic.claude-3-haiku-20240307-v1:0",
- label: "Bedrock Claude 3 Haiku",
- provider: "BEDROCK",
- contextWindow: "200K",
- },
- {
- value: "anthropic.claude-haiku-4-5-20251001-v1:0",
- label: "Bedrock Claude Haiku 4.5",
- provider: "BEDROCK",
- contextWindow: "200K",
- },
- {
- value: "amazon.nova-pro-v1:0",
- label: "Amazon Nova Pro",
- provider: "BEDROCK",
- contextWindow: "300K",
- },
- {
- value: "amazon.nova-lite-v1:0",
- label: "Amazon Nova Lite",
- provider: "BEDROCK",
- contextWindow: "300K",
- },
- {
- value: "amazon.nova-micro-v1:0",
- label: "Amazon Nova Micro",
- provider: "BEDROCK",
- contextWindow: "128K",
- },
- {
- value: "meta.llama3-3-70b-instruct-v1:0",
- label: "Bedrock Llama 3.3 70B",
- provider: "BEDROCK",
- contextWindow: "128K",
- },
- {
- value: "meta.llama3-1-405b-instruct-v1:0",
- label: "Bedrock Llama 3.1 405B",
- provider: "BEDROCK",
- contextWindow: "128K",
- },
- {
- value: "meta.llama3-1-70b-instruct-v1:0",
- label: "Bedrock Llama 3.1 70B",
- provider: "BEDROCK",
- contextWindow: "128K",
- },
- {
- value: "meta.llama3-1-8b-instruct-v1:0",
- label: "Bedrock Llama 3.1 8B",
- provider: "BEDROCK",
- contextWindow: "128K",
- },
- {
- value: "meta.llama4-maverick-17b-instruct-v1:0",
- label: "Bedrock Llama 4 Maverick 17B",
- provider: "BEDROCK",
- contextWindow: "128K",
- },
- {
- value: "meta.llama4-scout-17b-instruct-v1:0",
- label: "Bedrock Llama 4 Scout 17B",
- provider: "BEDROCK",
- contextWindow: "128K",
- },
- {
- value: "mistral.mistral-large-2407-v1:0",
- label: "Bedrock Mistral Large",
- provider: "BEDROCK",
- contextWindow: "128K",
- },
- {
- value: "mistral.mixtral-8x7b-instruct-v0:1",
- label: "Bedrock Mixtral 8x7B",
- provider: "BEDROCK",
- contextWindow: "32K",
- },
- {
- value: "cohere.command-r-plus-v1:0",
- label: "Bedrock Cohere Command R+",
- provider: "BEDROCK",
- contextWindow: "128K",
- },
- {
- value: "cohere.command-r-v1:0",
- label: "Bedrock Cohere Command R",
- provider: "BEDROCK",
- contextWindow: "128K",
- },
- {
- value: "ai21.jamba-1-5-large-v1:0",
- label: "Bedrock Jamba 1.5 Large",
- provider: "BEDROCK",
- contextWindow: "256K",
- },
- {
- value: "ai21.jamba-1-5-mini-v1:0",
- label: "Bedrock Jamba 1.5 Mini",
- provider: "BEDROCK",
- contextWindow: "256K",
- },
- {
- value: "deepseek.v3-v1:0",
- label: "Bedrock DeepSeek V3",
- provider: "BEDROCK",
- contextWindow: "164K",
- },
-
- // Vertex AI
- {
- value: "gemini-2.5-flash",
- label: "Vertex Gemini 2.5 Flash",
- provider: "VERTEX_AI",
- contextWindow: "1M",
- },
- {
- value: "gemini-2.5-pro",
- label: "Vertex Gemini 2.5 Pro",
- provider: "VERTEX_AI",
- contextWindow: "1M",
- },
- {
- value: "gemini-2.0-flash",
- label: "Vertex Gemini 2.0 Flash",
- provider: "VERTEX_AI",
- contextWindow: "1M",
- },
- {
- value: "gemini-1.5-flash",
- label: "Vertex Gemini 1.5 Flash",
- provider: "VERTEX_AI",
- contextWindow: "1M",
- },
- {
- value: "gemini-1.5-pro",
- label: "Vertex Gemini 1.5 Pro",
- provider: "VERTEX_AI",
- contextWindow: "2M",
- },
- {
- value: "claude-3-5-sonnet-v2@20241022",
- label: "Vertex Claude 3.5 Sonnet",
- provider: "VERTEX_AI",
- contextWindow: "200K",
- },
- {
- value: "claude-3-7-sonnet@20250219",
- label: "Vertex Claude 3.7 Sonnet",
- provider: "VERTEX_AI",
- contextWindow: "200K",
- },
- {
- value: "claude-sonnet-4@20250514",
- label: "Vertex Claude Sonnet 4",
- provider: "VERTEX_AI",
- contextWindow: "1M",
- },
- {
- value: "claude-3-opus@20240229",
- label: "Vertex Claude 3 Opus",
- provider: "VERTEX_AI",
- contextWindow: "200K",
- },
- {
- value: "claude-3-haiku@20240307",
- label: "Vertex Claude 3 Haiku",
- provider: "VERTEX_AI",
- contextWindow: "200K",
- },
- {
- value: "claude-haiku-4-5@20251001",
- label: "Vertex Claude Haiku 4.5",
- provider: "VERTEX_AI",
- contextWindow: "200K",
- },
- {
- value: "meta/llama-3.1-405b-instruct-maas",
- label: "Vertex Llama 3.1 405B",
- provider: "VERTEX_AI",
- contextWindow: "128K",
- },
- {
- value: "mistral-large@2411-001",
- label: "Vertex Mistral Large",
- provider: "VERTEX_AI",
- contextWindow: "128K",
- },
-
- // Groq
- {
- value: "llama-3.3-70b-versatile",
- label: "Groq Llama 3.3 70B",
- provider: "GROQ",
- contextWindow: "128K",
- },
- {
- value: "llama-3.3-70b-specdec",
- label: "Groq Llama 3.3 70B Specdec",
- provider: "GROQ",
- contextWindow: "8K",
- },
- {
- value: "llama-3.1-70b-versatile",
- label: "Groq Llama 3.1 70B",
- provider: "GROQ",
- contextWindow: "8K",
- },
- {
- value: "llama-3.1-8b-instant",
- label: "Groq Llama 3.1 8B",
- provider: "GROQ",
- contextWindow: "128K",
- },
- {
- value: "llama-3.2-90b-vision-preview",
- label: "Groq Llama 3.2 90B Vision",
- provider: "GROQ",
- contextWindow: "8K",
- },
- {
- value: "llama-3.2-11b-vision-preview",
- label: "Groq Llama 3.2 11B Vision",
- provider: "GROQ",
- contextWindow: "8K",
- },
- {
- value: "llama-3.2-3b-preview",
- label: "Groq Llama 3.2 3B",
- provider: "GROQ",
- contextWindow: "8K",
- },
- {
- value: "llama-3.2-1b-preview",
- label: "Groq Llama 3.2 1B",
- provider: "GROQ",
- contextWindow: "8K",
- },
- {
- value: "mixtral-8x7b-32768",
- label: "Groq Mixtral 8x7B",
- provider: "GROQ",
- contextWindow: "33K",
- },
- {
- value: "gemma2-9b-it",
- label: "Groq Gemma 2 9B",
- provider: "GROQ",
- contextWindow: "8K",
- },
- {
- value: "deepseek-r1-distill-llama-70b",
- label: "Groq DeepSeek R1 Distill",
- provider: "GROQ",
- contextWindow: "128K",
- },
- {
- value: "meta-llama/llama-4-maverick-17b-128e-instruct",
- label: "Groq Llama 4 Maverick",
- provider: "GROQ",
- contextWindow: "131K",
- },
- {
- value: "meta-llama/llama-4-scout-17b-16e-instruct",
- label: "Groq Llama 4 Scout",
- provider: "GROQ",
- contextWindow: "131K",
- },
- {
- value: "openai/gpt-oss-120b",
- label: "Groq GPT-OSS-120B",
- provider: "GROQ",
- contextWindow: "131K",
- },
- {
- value: "openai/gpt-oss-20b",
- label: "Groq GPT-OSS-20B",
- provider: "GROQ",
- contextWindow: "131K",
- },
- {
- value: "moonshotai/kimi-k2-instruct",
- label: "Groq Kimi K2",
- provider: "GROQ",
- contextWindow: "131K",
- },
-
- // Cohere
- {
- value: "command-a-03-2025",
- label: "Command A (03-2025)",
- provider: "COHERE",
- contextWindow: "256K",
- },
- {
- value: "command-r-plus",
- label: "Command R+",
- provider: "COHERE",
- contextWindow: "128K",
- },
- {
- value: "command-r",
- label: "Command R",
- provider: "COHERE",
- contextWindow: "128K",
- },
- {
- value: "command-r-plus-08-2024",
- label: "Command R+ (08-2024)",
- provider: "COHERE",
- contextWindow: "128K",
- },
- {
- value: "command-r-08-2024",
- label: "Command R (08-2024)",
- provider: "COHERE",
- contextWindow: "128K",
- },
- {
- value: "command",
- label: "Command",
- provider: "COHERE",
- contextWindow: "4K",
- },
-
- // Mistral
- {
- value: "mistral-large-latest",
- label: "Mistral Large Latest",
- provider: "MISTRAL",
- contextWindow: "128K",
- },
- {
- value: "mistral-large-2411",
- label: "Mistral Large 2411",
- provider: "MISTRAL",
- contextWindow: "128K",
- },
- {
- value: "mistral-medium-latest",
- label: "Mistral Medium Latest",
- provider: "MISTRAL",
- contextWindow: "131K",
- },
- {
- value: "mistral-medium-2505",
- label: "Mistral Medium 2505",
- provider: "MISTRAL",
- contextWindow: "131K",
- },
- {
- value: "mistral-small-latest",
- label: "Mistral Small Latest",
- provider: "MISTRAL",
- contextWindow: "32K",
- },
- {
- value: "open-mistral-nemo",
- label: "Mistral Nemo",
- provider: "MISTRAL",
- contextWindow: "128K",
- },
- {
- value: "open-mixtral-8x7b",
- label: "Mixtral 8x7B",
- provider: "MISTRAL",
- contextWindow: "32K",
- },
- {
- value: "open-mixtral-8x22b",
- label: "Mixtral 8x22B",
- provider: "MISTRAL",
- contextWindow: "65K",
- },
- {
- value: "codestral-latest",
- label: "Codestral Latest",
- provider: "MISTRAL",
- contextWindow: "32K",
- },
- {
- value: "pixtral-large-latest",
- label: "Pixtral Large Latest",
- provider: "MISTRAL",
- contextWindow: "128K",
- },
- {
- value: "magistral-medium-latest",
- label: "Magistral Medium Latest",
- provider: "MISTRAL",
- contextWindow: "40K",
- },
-
- // Together AI
- {
- value: "meta-llama/Meta-Llama-3.3-70B-Instruct-Turbo",
- label: "Together Llama 3.3 70B Turbo",
- provider: "TOGETHER_AI",
- contextWindow: "128K",
- },
- {
- value: "meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo",
- label: "Together Llama 3.1 405B Turbo",
- provider: "TOGETHER_AI",
- contextWindow: "128K",
- },
- {
- value: "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo",
- label: "Together Llama 3.1 70B Turbo",
- provider: "TOGETHER_AI",
- contextWindow: "128K",
- },
- {
- value: "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
- label: "Together Llama 3.1 8B Turbo",
- provider: "TOGETHER_AI",
- contextWindow: "128K",
- },
- {
- value: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
- label: "Together Llama 4 Maverick",
- provider: "TOGETHER_AI",
- contextWindow: "131K",
- },
- {
- value: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
- label: "Together Llama 4 Scout",
- provider: "TOGETHER_AI",
- contextWindow: "131K",
- },
- {
- value: "deepseek-ai/DeepSeek-V3.1",
- label: "Together DeepSeek V3.1",
- provider: "TOGETHER_AI",
- contextWindow: "128K",
- },
- {
- value: "deepseek-ai/DeepSeek-V3",
- label: "Together DeepSeek V3",
- provider: "TOGETHER_AI",
- contextWindow: "66K",
- },
- {
- value: "deepseek-ai/DeepSeek-R1",
- label: "Together DeepSeek R1",
- provider: "TOGETHER_AI",
- contextWindow: "128K",
- },
- {
- value: "mistralai/Mixtral-8x7B-Instruct-v0.1",
- label: "Together Mixtral 8x7B",
- provider: "TOGETHER_AI",
- contextWindow: "32K",
- },
- {
- value: "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8",
- label: "Together Qwen3 Coder 480B",
- provider: "TOGETHER_AI",
- contextWindow: "256K",
- },
- {
- value: "Qwen/Qwen3-235B-A22B-Instruct-2507-tput",
- label: "Together Qwen3 235B",
- provider: "TOGETHER_AI",
- contextWindow: "262K",
- },
- {
- value: "moonshotai/Kimi-K2-Instruct",
- label: "Together Kimi K2",
- provider: "TOGETHER_AI",
- contextWindow: "131K",
- },
- {
- value: "openai/gpt-oss-120b",
- label: "Together GPT-OSS-120B",
- provider: "TOGETHER_AI",
- contextWindow: "128K",
- },
- {
- value: "openai/gpt-oss-20b",
- label: "Together GPT-OSS-20B",
- provider: "TOGETHER_AI",
- contextWindow: "128K",
- },
-
- // Fireworks AI
- {
- value: "accounts/fireworks/models/llama-v3p3-70b-instruct",
- label: "Fireworks Llama 3.3 70B",
- provider: "FIREWORKS_AI",
- contextWindow: "131K",
- },
- {
- value: "accounts/fireworks/models/llama-v3p1-405b-instruct",
- label: "Fireworks Llama 3.1 405B",
- provider: "FIREWORKS_AI",
- contextWindow: "128K",
- },
- {
- value: "accounts/fireworks/models/llama4-maverick-instruct-basic",
- label: "Fireworks Llama 4 Maverick",
- provider: "FIREWORKS_AI",
- contextWindow: "131K",
- },
- {
- value: "accounts/fireworks/models/llama4-scout-instruct-basic",
- label: "Fireworks Llama 4 Scout",
- provider: "FIREWORKS_AI",
- contextWindow: "131K",
- },
- {
- value: "accounts/fireworks/models/deepseek-v3p1",
- label: "Fireworks DeepSeek V3.1",
- provider: "FIREWORKS_AI",
- contextWindow: "128K",
- },
- {
- value: "accounts/fireworks/models/deepseek-v3",
- label: "Fireworks DeepSeek V3",
- provider: "FIREWORKS_AI",
- contextWindow: "128K",
- },
- {
- value: "accounts/fireworks/models/deepseek-r1",
- label: "Fireworks DeepSeek R1",
- provider: "FIREWORKS_AI",
- contextWindow: "128K",
- },
- {
- value: "accounts/fireworks/models/mixtral-8x22b-instruct-hf",
- label: "Fireworks Mixtral 8x22B",
- provider: "FIREWORKS_AI",
- contextWindow: "66K",
- },
- {
- value: "accounts/fireworks/models/qwen2p5-coder-32b-instruct",
- label: "Fireworks Qwen2.5 Coder 32B",
- provider: "FIREWORKS_AI",
- contextWindow: "4K",
- },
- {
- value: "accounts/fireworks/models/kimi-k2-instruct",
- label: "Fireworks Kimi K2",
- provider: "FIREWORKS_AI",
- contextWindow: "131K",
- },
-
- // Replicate
- {
- value: "meta/llama-3-70b-instruct",
- label: "Replicate Llama 3 70B",
- provider: "REPLICATE",
- contextWindow: "8K",
- },
- {
- value: "meta/llama-3-8b-instruct",
- label: "Replicate Llama 3 8B",
- provider: "REPLICATE",
- contextWindow: "8K",
- },
- {
- value: "meta/llama-2-70b-chat",
- label: "Replicate Llama 2 70B",
- provider: "REPLICATE",
- contextWindow: "4K",
- },
- {
- value: "mistralai/mixtral-8x7b-instruct-v0.1",
- label: "Replicate Mixtral 8x7B",
- provider: "REPLICATE",
- contextWindow: "4K",
- },
-
- // Perplexity
- {
- value: "sonar-pro",
- label: "Sonar Pro",
- provider: "PERPLEXITY",
- contextWindow: "200K",
- },
- {
- value: "sonar",
- label: "Sonar",
- provider: "PERPLEXITY",
- contextWindow: "128K",
- },
- {
- value: "sonar-reasoning-pro",
- label: "Sonar Reasoning Pro",
- provider: "PERPLEXITY",
- contextWindow: "128K",
- },
- {
- value: "sonar-reasoning",
- label: "Sonar Reasoning",
- provider: "PERPLEXITY",
- contextWindow: "128K",
- },
- {
- value: "llama-3.1-sonar-large-128k-online",
- label: "Llama 3.1 Sonar Large Online",
- provider: "PERPLEXITY",
- contextWindow: "127K",
- },
- {
- value: "llama-3.1-sonar-small-128k-online",
- label: "Llama 3.1 Sonar Small Online",
- provider: "PERPLEXITY",
- contextWindow: "127K",
- },
-
- // OpenRouter
- {
- value: "anthropic/claude-4-opus",
- label: "OpenRouter Claude 4 Opus",
- provider: "OPENROUTER",
- contextWindow: "200K",
- },
- {
- value: "anthropic/claude-sonnet-4",
- label: "OpenRouter Claude Sonnet 4",
- provider: "OPENROUTER",
- contextWindow: "1M",
- },
- {
- value: "anthropic/claude-3.7-sonnet",
- label: "OpenRouter Claude 3.7 Sonnet",
- provider: "OPENROUTER",
- contextWindow: "200K",
- },
- {
- value: "anthropic/claude-3.5-sonnet",
- label: "OpenRouter Claude 3.5 Sonnet",
- provider: "OPENROUTER",
- contextWindow: "200K",
- },
- {
- value: "openai/gpt-5",
- label: "OpenRouter GPT-5",
- provider: "OPENROUTER",
- contextWindow: "272K",
- },
- {
- value: "openai/gpt-4.1",
- label: "OpenRouter GPT-4.1",
- provider: "OPENROUTER",
- contextWindow: "1M",
- },
- {
- value: "openai/gpt-4o",
- label: "OpenRouter GPT-4o",
- provider: "OPENROUTER",
- contextWindow: "128K",
- },
- {
- value: "openai/o3-mini",
- label: "OpenRouter O3 Mini",
- provider: "OPENROUTER",
- contextWindow: "128K",
- },
- {
- value: "x-ai/grok-4",
- label: "OpenRouter Grok 4",
- provider: "OPENROUTER",
- contextWindow: "256K",
- },
- {
- value: "deepseek/deepseek-chat-v3.1",
- label: "OpenRouter DeepSeek Chat V3.1",
- provider: "OPENROUTER",
- contextWindow: "164K",
- },
- {
- value: "deepseek/deepseek-r1",
- label: "OpenRouter DeepSeek R1",
- provider: "OPENROUTER",
- contextWindow: "65K",
- },
- {
- value: "google/gemini-2.5-flash",
- label: "OpenRouter Gemini 2.5 Flash",
- provider: "OPENROUTER",
- contextWindow: "1M",
- },
- {
- value: "google/gemini-2.5-pro",
- label: "OpenRouter Gemini 2.5 Pro",
- provider: "OPENROUTER",
- contextWindow: "1M",
- },
-
- // Ollama (Local)
- {
- value: "llama3.3",
- label: "Ollama Llama 3.3",
- provider: "OLLAMA",
- contextWindow: "128K",
- },
- {
- value: "llama3.1",
- label: "Ollama Llama 3.1",
- provider: "OLLAMA",
- contextWindow: "8K",
- },
- {
- value: "llama3",
- label: "Ollama Llama 3",
- provider: "OLLAMA",
- contextWindow: "8K",
- },
- {
- value: "llama2",
- label: "Ollama Llama 2",
- provider: "OLLAMA",
- contextWindow: "4K",
- },
- {
- value: "mistral",
- label: "Ollama Mistral",
- provider: "OLLAMA",
- contextWindow: "8K",
- },
- {
- value: "mixtral",
- label: "Ollama Mixtral 8x7B",
- provider: "OLLAMA",
- contextWindow: "33K",
- },
- {
- value: "codellama",
- label: "Ollama CodeLlama",
- provider: "OLLAMA",
- contextWindow: "4K",
- },
- {
- value: "deepseek-coder-v2-instruct",
- label: "Ollama DeepSeek Coder V2",
- provider: "OLLAMA",
- contextWindow: "33K",
- },
-
- // Alibaba Qwen
- {
- value: "qwen-plus",
- label: "Qwen Plus",
- provider: "ALIBABA_QWEN",
- contextWindow: "129K",
- },
- {
- value: "qwen-turbo",
- label: "Qwen Turbo",
- provider: "ALIBABA_QWEN",
- contextWindow: "129K",
- },
- {
- value: "qwen-max",
- label: "Qwen Max",
- provider: "ALIBABA_QWEN",
- contextWindow: "31K",
- },
- {
- value: "qwen-coder",
- label: "Qwen Coder",
- provider: "ALIBABA_QWEN",
- contextWindow: "1M",
- },
- {
- value: "qwen3-32b",
- label: "Qwen3 32B",
- provider: "ALIBABA_QWEN",
- contextWindow: "131K",
- },
- {
- value: "qwen3-30b-a3b",
- label: "Qwen3 30B-A3B",
- provider: "ALIBABA_QWEN",
- contextWindow: "129K",
- },
- {
- value: "qwen3-coder-plus",
- label: "Qwen3 Coder Plus",
- provider: "ALIBABA_QWEN",
- contextWindow: "998K",
- },
- {
- value: "qwq-plus",
- label: "QwQ Plus",
- provider: "ALIBABA_QWEN",
- contextWindow: "98K",
- },
-
- // Moonshot (Kimi)
- {
- value: "kimi-latest",
- label: "Kimi Latest",
- provider: "MOONSHOT",
- contextWindow: "131K",
- },
- {
- value: "kimi-k2-thinking",
- label: "Kimi K2 Thinking",
- provider: "MOONSHOT",
- contextWindow: "262K",
- },
- {
- value: "moonshot-v1-128k",
- label: "Moonshot V1 128K",
- provider: "MOONSHOT",
- contextWindow: "131K",
- },
- {
- value: "moonshot-v1-32k",
- label: "Moonshot V1 32K",
- provider: "MOONSHOT",
- contextWindow: "33K",
- },
- {
- value: "moonshot-v1-8k",
- label: "Moonshot V1 8K",
- provider: "MOONSHOT",
- contextWindow: "8K",
- },
-
- // Zhipu (GLM)
- {
- value: "glm-4.6",
- label: "GLM 4.6",
- provider: "ZHIPU",
- contextWindow: "203K",
- },
- {
- value: "glm-4.6:exacto",
- label: "GLM 4.6 Exacto",
- provider: "ZHIPU",
- contextWindow: "203K",
- },
-
- // Anyscale
- {
- value: "meta-llama/Meta-Llama-3-70B-Instruct",
- label: "Anyscale Llama 3 70B",
- provider: "ANYSCALE",
- contextWindow: "8K",
- },
- {
- value: "meta-llama/Meta-Llama-3-8B-Instruct",
- label: "Anyscale Llama 3 8B",
- provider: "ANYSCALE",
- contextWindow: "8K",
- },
- {
- value: "mistralai/Mixtral-8x7B-Instruct-v0.1",
- label: "Anyscale Mixtral 8x7B",
- provider: "ANYSCALE",
- contextWindow: "16K",
- },
-
- // DeepInfra
- {
- value: "meta-llama/Meta-Llama-3.3-70B-Instruct",
- label: "DeepInfra Llama 3.3 70B",
- provider: "DEEPINFRA",
- contextWindow: "131K",
- },
- {
- value: "meta-llama/Meta-Llama-3.1-405B-Instruct",
- label: "DeepInfra Llama 3.1 405B",
- provider: "DEEPINFRA",
- contextWindow: "33K",
- },
- {
- value: "meta-llama/Meta-Llama-3.1-70B-Instruct",
- label: "DeepInfra Llama 3.1 70B",
- provider: "DEEPINFRA",
- contextWindow: "131K",
- },
- {
- value: "deepseek-ai/DeepSeek-V3",
- label: "DeepInfra DeepSeek V3",
- provider: "DEEPINFRA",
- contextWindow: "164K",
- },
- {
- value: "deepseek-ai/DeepSeek-R1",
- label: "DeepInfra DeepSeek R1",
- provider: "DEEPINFRA",
- contextWindow: "164K",
- },
- {
- value: "Qwen/Qwen2.5-72B-Instruct",
- label: "DeepInfra Qwen 2.5 72B",
- provider: "DEEPINFRA",
- contextWindow: "33K",
- },
- {
- value: "Qwen/Qwen3-235B-A22B",
- label: "DeepInfra Qwen3 235B",
- provider: "DEEPINFRA",
- contextWindow: "131K",
- },
- {
- value: "google/gemini-2.5-flash",
- label: "DeepInfra Gemini 2.5 Flash",
- provider: "DEEPINFRA",
- contextWindow: "1M",
- },
- {
- value: "anthropic/claude-3-7-sonnet-latest",
- label: "DeepInfra Claude 3.7 Sonnet",
- provider: "DEEPINFRA",
- contextWindow: "200K",
- },
-
- // Cerebras
- {
- value: "llama-3.3-70b",
- label: "Cerebras Llama 3.3 70B",
- provider: "CEREBRAS",
- contextWindow: "128K",
- },
- {
- value: "llama3.1-70b",
- label: "Cerebras Llama 3.1 70B",
- provider: "CEREBRAS",
- contextWindow: "128K",
- },
- {
- value: "llama3.1-8b",
- label: "Cerebras Llama 3.1 8B",
- provider: "CEREBRAS",
- contextWindow: "128K",
- },
- {
- value: "qwen-3-32b",
- label: "Cerebras Qwen 3 32B",
- provider: "CEREBRAS",
- contextWindow: "128K",
- },
- {
- value: "gpt-oss-120b",
- label: "Cerebras GPT-OSS-120B",
- provider: "CEREBRAS",
- contextWindow: "131K",
- },
-
- // SambaNova
- {
- value: "Meta-Llama-3.3-70B-Instruct",
- label: "SambaNova Llama 3.3 70B",
- provider: "SAMBANOVA",
- contextWindow: "131K",
- },
- {
- value: "Meta-Llama-3.1-405B-Instruct",
- label: "SambaNova Llama 3.1 405B",
- provider: "SAMBANOVA",
- contextWindow: "16K",
- },
- {
- value: "Meta-Llama-3.1-8B-Instruct",
- label: "SambaNova Llama 3.1 8B",
- provider: "SAMBANOVA",
- contextWindow: "16K",
- },
- {
- value: "DeepSeek-R1",
- label: "SambaNova DeepSeek R1",
- provider: "SAMBANOVA",
- contextWindow: "33K",
- },
- {
- value: "DeepSeek-V3-0324",
- label: "SambaNova DeepSeek V3",
- provider: "SAMBANOVA",
- contextWindow: "33K",
- },
- {
- value: "Llama-4-Maverick-17B-128E-Instruct",
- label: "SambaNova Llama 4 Maverick",
- provider: "SAMBANOVA",
- contextWindow: "131K",
- },
- {
- value: "Llama-4-Scout-17B-16E-Instruct",
- label: "SambaNova Llama 4 Scout",
- provider: "SAMBANOVA",
- contextWindow: "8K",
- },
- {
- value: "QwQ-32B",
- label: "SambaNova QwQ 32B",
- provider: "SAMBANOVA",
- contextWindow: "16K",
- },
- {
- value: "Qwen3-32B",
- label: "SambaNova Qwen3 32B",
- provider: "SAMBANOVA",
- contextWindow: "8K",
- },
-
- // AI21 Labs
- {
- value: "jamba-1.5-large",
- label: "Jamba 1.5 Large",
- provider: "AI21",
- contextWindow: "256K",
- },
- {
- value: "jamba-1.5-mini",
- label: "Jamba 1.5 Mini",
- provider: "AI21",
- contextWindow: "256K",
- },
- {
- value: "jamba-large-1.6",
- label: "Jamba Large 1.6",
- provider: "AI21",
- contextWindow: "256K",
- },
- {
- value: "jamba-mini-1.6",
- label: "Jamba Mini 1.6",
- provider: "AI21",
- contextWindow: "256K",
- },
-
- // Cloudflare
- {
- value: "@cf/meta/llama-2-7b-chat-fp16",
- label: "Cloudflare Llama 2 7B",
- provider: "CLOUDFLARE",
- contextWindow: "3K",
- },
- {
- value: "@cf/mistral/mistral-7b-instruct-v0.1",
- label: "Cloudflare Mistral 7B",
- provider: "CLOUDFLARE",
- contextWindow: "8K",
- },
-
- // Databricks
- {
- value: "databricks-meta-llama-3-3-70b-instruct",
- label: "Databricks Llama 3.3 70B",
- provider: "DATABRICKS",
- contextWindow: "128K",
- },
- {
- value: "databricks-meta-llama-3-1-405b-instruct",
- label: "Databricks Llama 3.1 405B",
- provider: "DATABRICKS",
- contextWindow: "128K",
- },
- {
- value: "databricks-claude-3-7-sonnet",
- label: "Databricks Claude 3.7 Sonnet",
- provider: "DATABRICKS",
- contextWindow: "200K",
- },
- {
- value: "databricks-llama-4-maverick",
- label: "Databricks Llama 4 Maverick",
- provider: "DATABRICKS",
- contextWindow: "128K",
- },
-
- // GitHub Models
- {
- value: "openai/gpt-5",
- label: "GitHub GPT-5",
- provider: "GITHUB_MODELS",
- },
- {
- value: "openai/gpt-4.1",
- label: "GitHub GPT-4.1",
- provider: "GITHUB_MODELS",
- contextWindow: "1048K",
- },
- {
- value: "openai/gpt-4o",
- label: "GitHub GPT-4o",
- provider: "GITHUB_MODELS",
- contextWindow: "128K",
- },
- {
- value: "deepseek/DeepSeek-V3-0324",
- label: "GitHub DeepSeek V3",
- provider: "GITHUB_MODELS",
- contextWindow: "64K",
- },
- {
- value: "xai/grok-3",
- label: "GitHub Grok 3",
- provider: "GITHUB_MODELS",
- contextWindow: "131K",
- },
- {
- value: "openai/gpt-5-mini",
- label: "GitHub GPT-5 Mini",
- provider: "GITHUB_MODELS",
- },
- {
- value: "openai/gpt-4.1-mini",
- label: "GitHub GPT-4.1 Mini",
- provider: "GITHUB_MODELS",
- contextWindow: "1048K",
- },
- {
- value: "meta/Llama-4-Scout-17B-16E-Instruct",
- label: "GitHub Llama 4 Scout",
- provider: "GITHUB_MODELS",
- contextWindow: "512K",
- },
- {
- value: "openai/gpt-4.1-nano",
- label: "GitHub GPT-4.1 Nano",
- provider: "GITHUB_MODELS",
- contextWindow: "1048K",
- },
- {
- value: "openai/gpt-4o-mini",
- label: "GitHub GPT-4o Mini",
- provider: "GITHUB_MODELS",
- contextWindow: "128K",
- },
- {
- value: "openai/o4-mini",
- label: "GitHub O4 Mini",
- provider: "GITHUB_MODELS",
- contextWindow: "200K",
- },
- {
- value: "deepseek/DeepSeek-R1",
- label: "GitHub DeepSeek R1",
- provider: "GITHUB_MODELS",
- contextWindow: "64K",
- },
-
- // MiniMax
- {
- value: "MiniMax-M3",
- label: "MiniMax M3",
- provider: "MINIMAX",
- contextWindow: "512K",
- },
- {
- value: "MiniMax-M2.7",
- label: "MiniMax M2.7",
- provider: "MINIMAX",
- contextWindow: "204K",
- },
- {
- value: "MiniMax-M2.7-highspeed",
- label: "MiniMax M2.7 Highspeed",
- provider: "MINIMAX",
- contextWindow: "204K",
- },
-];
-
-// Helper function to get models by provider
-export function getModelsByProvider(provider: string): LLMModel[] {
- return LLM_MODELS.filter((model) => model.provider === provider);
-}
-
-// Helper function to get all providers that have models
-export function getProvidersWithModels(): string[] {
- return Array.from(new Set(LLM_MODELS.map((model) => model.provider)));
-}
diff --git a/surfsense_web/contracts/enums/llm-providers.ts b/surfsense_web/contracts/enums/llm-providers.ts
deleted file mode 100644
index c04a44923..000000000
--- a/surfsense_web/contracts/enums/llm-providers.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-export interface LLMProvider {
- value: string;
- label: string;
- example: string;
- description: string;
- apiBase?: string;
-}
-
-export const LLM_PROVIDERS: LLMProvider[] = [
- {
- value: "OPENAI",
- label: "OpenAI",
- example: "gpt-4o, gpt-4o-mini, o1, o3-mini",
- description: "Industry-leading GPT models",
- },
- {
- value: "ANTHROPIC",
- label: "Anthropic",
- example: "claude-3-5-sonnet, claude-3-opus, claude-4-sonnet",
- description: "Claude models with strong reasoning",
- },
- {
- value: "GOOGLE",
- label: "Google (Gemini)",
- example: "gemini-2.5-flash, gemini-2.5-pro, gemini-1.5-pro",
- description: "Gemini models with multimodal capabilities",
- },
- {
- value: "AZURE_OPENAI",
- label: "Azure OpenAI",
- example: "azure/gpt-4o, azure/gpt-4o-mini",
- description: "OpenAI models on Azure",
- },
- {
- value: "BEDROCK",
- label: "AWS Bedrock",
- example: "anthropic.claude-3-5-sonnet, meta.llama3-70b",
- description: "Foundation models on AWS",
- },
- {
- value: "VERTEX_AI",
- label: "Google Vertex AI",
- example: "vertex_ai/claude-3-5-sonnet, vertex_ai/gemini-2.5-pro",
- description: "Models on Google Cloud Vertex AI",
- },
- {
- value: "GROQ",
- label: "Groq",
- example: "groq/llama-3.3-70b-versatile, groq/mixtral-8x7b",
- description: "Ultra-fast inference",
- },
- {
- value: "COHERE",
- label: "Cohere",
- example: "command-a-03-2025, command-r-plus",
- description: "Enterprise NLP models",
- },
- {
- value: "MISTRAL",
- label: "Mistral AI",
- example: "mistral-large-latest, mistral-medium-latest",
- description: "European open-source models",
- },
- {
- value: "DEEPSEEK",
- label: "DeepSeek",
- example: "deepseek-chat, deepseek-reasoner",
- description: "High-performance reasoning models",
- apiBase: "https://api.deepseek.com",
- },
- {
- value: "XAI",
- label: "xAI (Grok)",
- example: "grok-4, grok-3, grok-3-mini",
- description: "Grok models from xAI",
- },
- {
- value: "OPENROUTER",
- label: "OpenRouter",
- example: "openrouter/anthropic/claude-4-opus",
- description: "Unified API for multiple providers",
- },
- {
- value: "TOGETHER_AI",
- label: "Together AI",
- example: "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo",
- description: "Fast open-source models",
- },
- {
- value: "FIREWORKS_AI",
- label: "Fireworks AI",
- example: "fireworks_ai/accounts/fireworks/models/llama-v3p3-70b-instruct",
- description: "Scalable inference platform",
- },
- {
- value: "REPLICATE",
- label: "Replicate",
- example: "replicate/meta/llama-3-70b-instruct",
- description: "ML model hosting platform",
- },
- {
- value: "PERPLEXITY",
- label: "Perplexity",
- example: "perplexity/sonar-pro, perplexity/sonar-reasoning",
- description: "Search-augmented models",
- },
- {
- value: "OLLAMA",
- label: "Ollama",
- example: "ollama/llama3.1, ollama/mistral",
- description: "Run models locally",
- apiBase: "http://localhost:11434",
- },
- {
- value: "ALIBABA_QWEN",
- label: "Alibaba Qwen",
- example: "dashscope/qwen-plus, dashscope/qwen-turbo",
- description: "Qwen series models",
- apiBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
- },
- {
- value: "MOONSHOT",
- label: "Moonshot (Kimi)",
- example: "moonshot/kimi-latest, moonshot/kimi-k2-thinking",
- description: "Kimi AI models",
- apiBase: "https://api.moonshot.cn/v1",
- },
- {
- value: "ZHIPU",
- label: "Zhipu (GLM)",
- example: "glm-4.6, glm-4.6:exacto",
- description: "GLM series models",
- apiBase: "https://open.bigmodel.cn/api/paas/v4",
- },
- {
- value: "ANYSCALE",
- label: "Anyscale",
- example: "anyscale/meta-llama/Meta-Llama-3-70B-Instruct",
- description: "Ray-based inference platform",
- },
- {
- value: "DEEPINFRA",
- label: "DeepInfra",
- example: "deepinfra/meta-llama/Meta-Llama-3.3-70B-Instruct",
- description: "Serverless GPU inference",
- },
- {
- value: "CEREBRAS",
- label: "Cerebras",
- example: "cerebras/llama-3.3-70b, cerebras/qwen-3-32b",
- description: "Fastest inference with Wafer-Scale Engine",
- },
- {
- value: "SAMBANOVA",
- label: "SambaNova",
- example: "sambanova/Meta-Llama-3.3-70B-Instruct",
- description: "AI inference platform",
- },
- {
- value: "AI21",
- label: "AI21 Labs",
- example: "jamba-1.5-large, jamba-1.5-mini",
- description: "Jamba series models",
- },
- {
- value: "CLOUDFLARE",
- label: "Cloudflare Workers AI",
- example: "cloudflare/@cf/meta/llama-2-7b-chat",
- description: "AI on Cloudflare edge network",
- },
- {
- value: "DATABRICKS",
- label: "Databricks",
- example: "databricks/databricks-meta-llama-3-3-70b-instruct",
- description: "Databricks Model Serving",
- },
- {
- value: "GITHUB_MODELS",
- label: "GitHub Models",
- example: "openai/gpt-5, meta/llama-3.1-405b-instruct",
- description: "AI models from GitHub Marketplace",
- apiBase: "https://models.github.ai/inference",
- },
- {
- value: "MINIMAX",
- label: "MiniMax",
- example: "MiniMax-M3, MiniMax-M2.7",
- description: "High-performance models with up to 512K context",
- apiBase: "https://api.minimax.io/v1",
- },
- {
- value: "CUSTOM",
- label: "Custom Provider",
- example: "your-custom-model",
- description: "Custom OpenAI-compatible endpoint",
- },
-];
diff --git a/surfsense_web/contracts/enums/toolIcons.tsx b/surfsense_web/contracts/enums/toolIcons.tsx
index 494c0eaee..8b2f08cd1 100644
--- a/surfsense_web/contracts/enums/toolIcons.tsx
+++ b/surfsense_web/contracts/enums/toolIcons.tsx
@@ -1,4 +1,5 @@
import {
+ AlarmClock,
Brain,
Calendar,
FileEdit,
@@ -24,7 +25,6 @@ import {
SearchCheck,
Send,
Trash2,
- Workflow,
Wrench,
} from "lucide-react";
@@ -47,7 +47,7 @@ const TOOL_ICONS: Record = {
scrape_webpage: ScanLine,
web_search: Globe,
// Automations
- create_automation: Workflow,
+ create_automation: AlarmClock,
// Memory
update_memory: Brain,
// Filesystem (built-in deepagent + middleware)
diff --git a/surfsense_web/contracts/enums/vision-providers.ts b/surfsense_web/contracts/enums/vision-providers.ts
deleted file mode 100644
index 477fd5c53..000000000
--- a/surfsense_web/contracts/enums/vision-providers.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import type { LLMModel } from "./llm-models";
-
-export interface VisionProviderInfo {
- value: string;
- label: string;
- example: string;
- description: string;
- apiBase?: string;
-}
-
-export const VISION_PROVIDERS: VisionProviderInfo[] = [
- {
- value: "OPENAI",
- label: "OpenAI",
- example: "gpt-4o, gpt-4o-mini",
- description: "GPT-4o vision models",
- },
- {
- value: "ANTHROPIC",
- label: "Anthropic",
- example: "claude-sonnet-4-20250514",
- description: "Claude vision models",
- },
- {
- value: "GOOGLE",
- label: "Google AI Studio",
- example: "gemini-2.5-flash, gemini-2.0-flash",
- description: "Gemini vision models",
- },
- {
- value: "AZURE_OPENAI",
- label: "Azure OpenAI",
- example: "azure/gpt-4o",
- description: "OpenAI vision models on Azure",
- },
- {
- value: "VERTEX_AI",
- label: "Google Vertex AI",
- example: "vertex_ai/gemini-2.5-flash",
- description: "Gemini vision models on Vertex AI",
- },
- {
- value: "BEDROCK",
- label: "AWS Bedrock",
- example: "bedrock/anthropic.claude-sonnet-4-20250514-v1:0",
- description: "Vision models on AWS Bedrock",
- },
- {
- value: "XAI",
- label: "xAI",
- example: "grok-2-vision",
- description: "Grok vision models",
- },
- {
- value: "OPENROUTER",
- label: "OpenRouter",
- example: "openrouter/openai/gpt-4o",
- description: "Vision models via OpenRouter",
- },
- {
- value: "OLLAMA",
- label: "Ollama",
- example: "llava, bakllava",
- description: "Local vision models via Ollama",
- apiBase: "http://localhost:11434",
- },
- {
- value: "GROQ",
- label: "Groq",
- example: "llama-4-scout-17b-16e-instruct",
- description: "Vision models on Groq",
- },
- {
- value: "TOGETHER_AI",
- label: "Together AI",
- example: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
- description: "Vision models on Together AI",
- },
- {
- value: "FIREWORKS_AI",
- label: "Fireworks AI",
- example: "fireworks_ai/phi-3-vision-128k-instruct",
- description: "Vision models on Fireworks AI",
- },
- {
- value: "DEEPSEEK",
- label: "DeepSeek",
- example: "deepseek-chat",
- description: "DeepSeek vision models",
- apiBase: "https://api.deepseek.com",
- },
- {
- value: "MISTRAL",
- label: "Mistral",
- example: "pixtral-large-latest",
- description: "Pixtral vision models",
- },
- {
- value: "CUSTOM",
- label: "Custom Provider",
- example: "custom/my-vision-model",
- description: "Custom OpenAI-compatible vision endpoint",
- },
-];
-
-export const VISION_MODELS: LLMModel[] = [
- { value: "gpt-4o", label: "GPT-4o", provider: "OPENAI", contextWindow: "128K" },
- { value: "gpt-4o-mini", label: "GPT-4o Mini", provider: "OPENAI", contextWindow: "128K" },
- { value: "gpt-4-turbo", label: "GPT-4 Turbo", provider: "OPENAI", contextWindow: "128K" },
- {
- value: "claude-sonnet-4-20250514",
- label: "Claude Sonnet 4",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-3-7-sonnet-20250219",
- label: "Claude 3.7 Sonnet",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-3-5-sonnet-20241022",
- label: "Claude 3.5 Sonnet",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-3-opus-20240229",
- label: "Claude 3 Opus",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- {
- value: "claude-3-haiku-20240307",
- label: "Claude 3 Haiku",
- provider: "ANTHROPIC",
- contextWindow: "200K",
- },
- { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash", provider: "GOOGLE", contextWindow: "1M" },
- { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro", provider: "GOOGLE", contextWindow: "1M" },
- { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash", provider: "GOOGLE", contextWindow: "1M" },
- { value: "gemini-1.5-pro", label: "Gemini 1.5 Pro", provider: "GOOGLE", contextWindow: "1M" },
- { value: "gemini-1.5-flash", label: "Gemini 1.5 Flash", provider: "GOOGLE", contextWindow: "1M" },
- {
- value: "pixtral-large-latest",
- label: "Pixtral Large",
- provider: "MISTRAL",
- contextWindow: "128K",
- },
- { value: "pixtral-12b-2409", label: "Pixtral 12B", provider: "MISTRAL", contextWindow: "128K" },
- { value: "grok-2-vision-1212", label: "Grok 2 Vision", provider: "XAI", contextWindow: "32K" },
- { value: "llava", label: "LLaVA", provider: "OLLAMA" },
- { value: "bakllava", label: "BakLLaVA", provider: "OLLAMA" },
- { value: "llava-llama3", label: "LLaVA Llama 3", provider: "OLLAMA" },
- {
- value: "llama-4-scout-17b-16e-instruct",
- label: "Llama 4 Scout 17B",
- provider: "GROQ",
- contextWindow: "128K",
- },
- {
- value: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
- label: "Llama 4 Scout 17B",
- provider: "TOGETHER_AI",
- contextWindow: "128K",
- },
-];
diff --git a/surfsense_web/contracts/types/anonymous-chat.types.ts b/surfsense_web/contracts/types/anonymous-chat.types.ts
index 864810d8e..21284267c 100644
--- a/surfsense_web/contracts/types/anonymous-chat.types.ts
+++ b/surfsense_web/contracts/types/anonymous-chat.types.ts
@@ -3,7 +3,6 @@ import { z } from "zod";
export const anonModel = z.object({
id: z.number(),
name: z.string(),
- description: z.string().nullable().optional(),
provider: z.string(),
model_name: z.string(),
billing_tier: z.string().default("free"),
diff --git a/surfsense_web/contracts/types/automation.types.ts b/surfsense_web/contracts/types/automation.types.ts
index 45670d245..6331a663c 100644
--- a/surfsense_web/contracts/types/automation.types.ts
+++ b/surfsense_web/contracts/types/automation.types.ts
@@ -63,9 +63,9 @@ export type Inputs = z.infer;
// Captured model snapshot (server-managed). Set at create time and preserved
// across edits so runs are insulated from later chat/search-space model changes.
export const automationModels = z.object({
- agent_llm_id: z.number().int().default(0),
- image_generation_config_id: z.number().int().default(0),
- vision_llm_config_id: z.number().int().default(0),
+ chat_model_id: z.number().int().default(0),
+ image_gen_model_id: z.number().int().default(0),
+ vision_model_id: z.number().int().default(0),
});
export type AutomationModels = z.infer;
diff --git a/surfsense_web/contracts/types/model-connections.types.ts b/surfsense_web/contracts/types/model-connections.types.ts
new file mode 100644
index 000000000..6d713329f
--- /dev/null
+++ b/surfsense_web/contracts/types/model-connections.types.ts
@@ -0,0 +1,139 @@
+import { z } from "zod";
+
+export const connectionScopeEnum = z.enum(["GLOBAL", "SEARCH_SPACE", "USER"]);
+export const modelSourceEnum = z.enum(["DISCOVERED", "MANUAL"]);
+
+export const modelRead = z.object({
+ id: z.number(),
+ connection_id: z.number(),
+ model_id: z.string(),
+ display_name: z.string().nullable().optional(),
+ source: z.union([modelSourceEnum, z.string()]),
+ supports_chat: z.boolean().nullable().optional(),
+ max_input_tokens: z.number().nullable().optional(),
+ supports_image_input: z.boolean().nullable().optional(),
+ supports_tools: z.boolean().nullable().optional(),
+ supports_image_generation: z.boolean().nullable().optional(),
+ capabilities_override: z.record(z.string(), z.any()).default({}),
+ enabled: z.boolean(),
+ billing_tier: z.string().nullable().optional(),
+ catalog: z.record(z.string(), z.any()).default({}),
+ created_at: z.string().nullable().optional(),
+});
+
+export const connectionRead = z.object({
+ id: z.number(),
+ provider: z.string(),
+ base_url: z.string().nullable().optional(),
+ api_key: z.string().nullable().optional(),
+ extra: z.record(z.string(), z.any()).default({}),
+ scope: z.union([connectionScopeEnum, z.string()]),
+ search_space_id: z.number().nullable().optional(),
+ user_id: z.string().nullable().optional(),
+ enabled: z.boolean(),
+ has_api_key: z.boolean(),
+ models: z.array(modelRead).default([]),
+ created_at: z.string().nullable().optional(),
+});
+
+export const modelSelection = z.object({
+ model_id: z.string().min(1),
+ display_name: z.string().nullable().optional(),
+ source: z.union([modelSourceEnum, z.string()]).default("DISCOVERED"),
+ supports_chat: z.boolean().nullable().optional(),
+ max_input_tokens: z.number().nullable().optional(),
+ supports_image_input: z.boolean().nullable().optional(),
+ supports_tools: z.boolean().nullable().optional(),
+ supports_image_generation: z.boolean().nullable().optional(),
+ enabled: z.boolean().default(false),
+ metadata: z.record(z.string(), z.any()).default({}),
+});
+
+export const modelPreviewRead = modelSelection;
+
+export const connectionCreateRequest = z.object({
+ provider: z.string().min(1),
+ base_url: z.string().nullable().optional(),
+ api_key: z.string().nullable().optional(),
+ extra: z.record(z.string(), z.any()).default({}),
+ scope: connectionScopeEnum.default("SEARCH_SPACE"),
+ search_space_id: z.number().nullable().optional(),
+ enabled: z.boolean().default(true),
+ models: z.array(modelSelection).default([]),
+});
+
+export const modelTestPreviewRequest = connectionCreateRequest.extend({
+ model_id: z.string().min(1),
+});
+
+export const connectionUpdateRequest = z.object({
+ provider: z.string().nullable().optional(),
+ base_url: z.string().nullable().optional(),
+ api_key: z.string().nullable().optional(),
+ extra: z.record(z.string(), z.any()).optional(),
+ enabled: z.boolean().optional(),
+});
+
+export const modelCreateRequest = z.object({
+ model_id: z.string().min(1),
+ display_name: z.string().nullable().optional(),
+});
+
+export const modelUpdateRequest = z.object({
+ display_name: z.string().nullable().optional(),
+ enabled: z.boolean().optional(),
+ supports_chat: z.boolean().nullable().optional(),
+ max_input_tokens: z.number().nullable().optional(),
+ supports_image_input: z.boolean().nullable().optional(),
+ supports_tools: z.boolean().nullable().optional(),
+ supports_image_generation: z.boolean().nullable().optional(),
+ capabilities_override: z.record(z.string(), z.any()).optional(),
+});
+
+export const modelsBulkUpdateRequest = z.object({
+ model_ids: z.array(z.number()).min(1).max(1000),
+ enabled: z.boolean(),
+});
+
+export const verifyConnectionResponse = z.object({
+ status: z.string(),
+ ok: z.boolean(),
+ message: z.string().default(""),
+});
+
+export const modelRoles = z.object({
+ chat_model_id: z.number().nullable().optional(),
+ vision_model_id: z.number().nullable().optional(),
+ image_gen_model_id: z.number().nullable().optional(),
+});
+
+export const modelProviderRead = z.object({
+ provider: z.string(),
+ transport: z.string(),
+ discovery: z.string(),
+ default_base_url: z.string().nullable().optional(),
+ base_url_required: z.boolean(),
+ auth_style: z.string(),
+ local_only: z.boolean().default(false),
+});
+
+export const modelProviderListResponse = z.array(modelProviderRead);
+
+export const connectionListResponse = z.array(connectionRead);
+export const modelListResponse = z.array(modelRead);
+export const modelPreviewListResponse = z.array(modelPreviewRead);
+
+export type ConnectionScope = z.infer;
+export type ModelRead = z.infer;
+export type ModelPreviewRead = z.infer;
+export type ModelSelection = z.infer;
+export type ConnectionRead = z.infer;
+export type ConnectionCreateRequest = z.infer;
+export type ModelTestPreviewRequest = z.infer;
+export type ConnectionUpdateRequest = z.infer;
+export type ModelCreateRequest = z.infer;
+export type ModelUpdateRequest = z.infer;
+export type ModelsBulkUpdateRequest = z.infer;
+export type ModelRoles = z.infer;
+export type VerifyConnectionResponse = z.infer;
+export type ModelProviderRead = z.infer;
diff --git a/surfsense_web/contracts/types/new-llm-config.types.ts b/surfsense_web/contracts/types/new-llm-config.types.ts
deleted file mode 100644
index 2fa7a37be..000000000
--- a/surfsense_web/contracts/types/new-llm-config.types.ts
+++ /dev/null
@@ -1,476 +0,0 @@
-import { z } from "zod";
-
-/**
- * LiteLLM Provider enum - all supported LLM providers
- */
-export const liteLLMProviderEnum = z.enum([
- "OPENAI",
- "ANTHROPIC",
- "GOOGLE",
- "AZURE_OPENAI",
- "BEDROCK",
- "VERTEX_AI",
- "GROQ",
- "COHERE",
- "MISTRAL",
- "DEEPSEEK",
- "XAI",
- "OPENROUTER",
- "TOGETHER_AI",
- "FIREWORKS_AI",
- "REPLICATE",
- "PERPLEXITY",
- "OLLAMA",
- "ALIBABA_QWEN",
- "MOONSHOT",
- "ZHIPU",
- "ANYSCALE",
- "DEEPINFRA",
- "CEREBRAS",
- "SAMBANOVA",
- "AI21",
- "CLOUDFLARE",
- "DATABRICKS",
- "COMETAPI",
- "HUGGINGFACE",
- "GITHUB_MODELS",
- "MINIMAX",
- "CUSTOM",
-]);
-
-export type LiteLLMProvider = z.infer;
-
-/**
- * NewLLMConfig - combines model settings with prompt configuration
- */
-export const newLLMConfig = z.object({
- id: z.number(),
- name: z.string().max(100),
- description: z.string().max(500).nullable().optional(),
-
- // Model Configuration
- provider: liteLLMProviderEnum,
- custom_provider: z.string().max(100).nullable().optional(),
- model_name: z.string().max(100),
- api_key: z.string(),
- api_base: z.string().max(500).nullable().optional(),
- litellm_params: z.record(z.string(), z.any()).nullable().optional(),
-
- // Prompt Configuration
- system_instructions: z.string().default(""),
- use_default_system_instructions: z.boolean().default(true),
- citations_enabled: z.boolean().default(true),
-
- // Metadata
- created_at: z.string(),
- search_space_id: z.number(),
- user_id: z.string(),
-
- // Capability flag — derived server-side at the route boundary from
- // LiteLLM's authoritative model map. There is no DB column. Default
- // `true` is the conservative-allow stance for unknown / unmapped
- // BYOK rows; the streaming-task safety net is the only place a
- // `false` actually blocks a request.
- supports_image_input: z.boolean().default(true),
-});
-
-/**
- * Public version without api_key (for list views)
- */
-export const newLLMConfigPublic = newLLMConfig.omit({ api_key: true });
-
-/**
- * Create NewLLMConfig
- *
- * `supports_image_input` is omitted because it is derived server-side
- * from LiteLLM's model map at read time — there is no DB column to
- * persist a client-supplied value into.
- */
-export const createNewLLMConfigRequest = newLLMConfig.omit({
- id: true,
- created_at: true,
- user_id: true,
- supports_image_input: true,
-});
-
-export const createNewLLMConfigResponse = newLLMConfig;
-
-/**
- * Get NewLLMConfigs list
- */
-export const getNewLLMConfigsRequest = z.object({
- search_space_id: z.number(),
- skip: z.number().optional(),
- limit: z.number().optional(),
-});
-
-export const getNewLLMConfigsResponse = z.array(newLLMConfig);
-
-/**
- * Get single NewLLMConfig
- */
-export const getNewLLMConfigRequest = z.object({
- id: z.number(),
-});
-
-export const getNewLLMConfigResponse = newLLMConfig;
-
-/**
- * Update NewLLMConfig
- */
-export const updateNewLLMConfigRequest = z.object({
- id: z.number(),
- data: newLLMConfig
- .omit({
- id: true,
- created_at: true,
- search_space_id: true,
- user_id: true,
- // Derived server-side; not part of the writable surface.
- supports_image_input: true,
- })
- .partial(),
-});
-
-export const updateNewLLMConfigResponse = newLLMConfig;
-
-/**
- * Delete NewLLMConfig
- */
-export const deleteNewLLMConfigRequest = z.object({
- id: z.number(),
-});
-
-export const deleteNewLLMConfigResponse = z.object({
- message: z.string(),
- id: z.number(),
-});
-
-/**
- * Get default system instructions
- */
-export const getDefaultSystemInstructionsResponse = z.object({
- default_system_instructions: z.string(),
-});
-
-/**
- * Global NewLLMConfig - from YAML, has negative IDs
- * ID 0 is reserved for "Auto" mode which uses LiteLLM Router for load balancing
- */
-export const globalNewLLMConfig = z.object({
- id: z.number(), // 0 for Auto mode, negative IDs for global configs
- name: z.string(),
- description: z.string().nullable().optional(),
-
- // Model Configuration (no api_key)
- provider: z.string(), // String because YAML doesn't enforce enum, "AUTO" for Auto mode
- custom_provider: z.string().nullable().optional(),
- model_name: z.string(),
- api_base: z.string().nullable().optional(),
- litellm_params: z.record(z.string(), z.any()).nullable().optional(),
-
- // Prompt Configuration
- system_instructions: z.string().default(""),
- use_default_system_instructions: z.boolean().default(true),
- citations_enabled: z.boolean().default(true),
-
- is_global: z.literal(true),
- is_auto_mode: z.boolean().optional().default(false), // True only for Auto mode (ID 0)
-
- // Token quota and billing policy
- billing_tier: z.string().default("free"),
- is_premium: z.boolean().default(false),
- anonymous_enabled: z.boolean().default(false),
- seo_enabled: z.boolean().default(false),
- seo_slug: z.string().nullable().optional(),
- seo_title: z.string().nullable().optional(),
- seo_description: z.string().nullable().optional(),
- quota_reserve_tokens: z.number().nullable().optional(),
- // Capability flag — true when the model can accept image inputs.
- // Resolved server-side (OpenRouter dynamic configs use the OR
- // `architecture.input_modalities` field; YAML / BYOK use LiteLLM's
- // authoritative `supports_vision` map). The chat selector renders
- // an amber "No image" hint when this is false and there are
- // pending image attachments, but does not block selection — the
- // backend safety net only rejects when LiteLLM *explicitly* marks
- // the model as text-only, so unknown / new models still flow
- // through. Default `true` matches that conservative-allow stance.
- supports_image_input: z.boolean().default(true),
-});
-
-export const getGlobalNewLLMConfigsResponse = z.array(globalNewLLMConfig);
-
-// =============================================================================
-// Image Generation Config (separate table from NewLLMConfig)
-// =============================================================================
-
-/**
- * ImageGenProvider enum - only providers that support image generation
- * See: https://docs.litellm.ai/docs/image_generation#supported-providers
- */
-export const imageGenProviderEnum = z.enum([
- "OPENAI",
- "AZURE_OPENAI",
- "GOOGLE",
- "VERTEX_AI",
- "BEDROCK",
- "RECRAFT",
- "OPENROUTER",
- "XINFERENCE",
- "NSCALE",
-]);
-
-export type ImageGenProvider = z.infer;
-
-/**
- * ImageGenerationConfig - user-created image gen model configs
- * Separate from NewLLMConfig: no system_instructions, no citations_enabled.
- */
-export const imageGenerationConfig = z.object({
- id: z.number(),
- name: z.string().max(100),
- description: z.string().max(500).nullable().optional(),
- provider: imageGenProviderEnum,
- custom_provider: z.string().max(100).nullable().optional(),
- model_name: z.string().max(100),
- api_key: z.string(),
- api_base: z.string().max(500).nullable().optional(),
- api_version: z.string().max(50).nullable().optional(),
- litellm_params: z.record(z.string(), z.any()).nullable().optional(),
- created_at: z.string(),
- search_space_id: z.number(),
- user_id: z.string(),
-});
-
-export const createImageGenConfigRequest = imageGenerationConfig.omit({
- id: true,
- created_at: true,
- user_id: true,
-});
-
-export const createImageGenConfigResponse = imageGenerationConfig;
-
-export const getImageGenConfigsResponse = z.array(imageGenerationConfig);
-
-export const updateImageGenConfigRequest = z.object({
- id: z.number(),
- data: imageGenerationConfig
- .omit({ id: true, created_at: true, search_space_id: true, user_id: true })
- .partial(),
-});
-
-export const updateImageGenConfigResponse = imageGenerationConfig;
-
-export const deleteImageGenConfigResponse = z.object({
- message: z.string(),
- id: z.number(),
-});
-
-/**
- * Global Image Generation Config - from YAML, has negative IDs
- * ID 0 is reserved for "Auto" mode (LiteLLM Router load balancing)
- */
-export const globalImageGenConfig = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string().nullable().optional(),
- provider: z.string(),
- custom_provider: z.string().nullable().optional(),
- model_name: z.string(),
- api_base: z.string().nullable().optional(),
- api_version: z.string().nullable().optional(),
- litellm_params: z.record(z.string(), z.any()).nullable().optional(),
- is_global: z.literal(true),
- is_auto_mode: z.boolean().optional().default(false),
- billing_tier: z.string().default("free"),
- // Mirrors `globalNewLLMConfig.is_premium` so the new-chat selector's
- // Free/Premium badge logic lights up automatically for image-gen too.
- is_premium: z.boolean().default(false),
- quota_reserve_micros: z.number().nullable().optional(),
-});
-
-export const getGlobalImageGenConfigsResponse = z.array(globalImageGenConfig);
-
-// =============================================================================
-// Vision LLM Config (separate table for vision-capable models)
-// =============================================================================
-
-export const visionProviderEnum = z.enum([
- "OPENAI",
- "ANTHROPIC",
- "GOOGLE",
- "AZURE_OPENAI",
- "VERTEX_AI",
- "BEDROCK",
- "XAI",
- "OPENROUTER",
- "OLLAMA",
- "GROQ",
- "TOGETHER_AI",
- "FIREWORKS_AI",
- "DEEPSEEK",
- "MISTRAL",
- "CUSTOM",
-]);
-
-export type VisionProvider = z.infer;
-
-export const visionLLMConfig = z.object({
- id: z.number(),
- name: z.string().max(100),
- description: z.string().max(500).nullable().optional(),
- provider: visionProviderEnum,
- custom_provider: z.string().max(100).nullable().optional(),
- model_name: z.string().max(100),
- api_key: z.string(),
- api_base: z.string().max(500).nullable().optional(),
- api_version: z.string().max(50).nullable().optional(),
- litellm_params: z.record(z.string(), z.any()).nullable().optional(),
- created_at: z.string(),
- search_space_id: z.number(),
- user_id: z.string(),
-});
-
-export const createVisionLLMConfigRequest = visionLLMConfig.omit({
- id: true,
- created_at: true,
- user_id: true,
-});
-
-export const createVisionLLMConfigResponse = visionLLMConfig;
-
-export const getVisionLLMConfigsResponse = z.array(visionLLMConfig);
-
-export const updateVisionLLMConfigRequest = z.object({
- id: z.number(),
- data: visionLLMConfig
- .omit({ id: true, created_at: true, search_space_id: true, user_id: true })
- .partial(),
-});
-
-export const updateVisionLLMConfigResponse = visionLLMConfig;
-
-export const deleteVisionLLMConfigResponse = z.object({
- message: z.string(),
- id: z.number(),
-});
-
-export const globalVisionLLMConfig = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string().nullable().optional(),
- provider: z.string(),
- custom_provider: z.string().nullable().optional(),
- model_name: z.string(),
- api_base: z.string().nullable().optional(),
- api_version: z.string().nullable().optional(),
- litellm_params: z.record(z.string(), z.any()).nullable().optional(),
- is_global: z.literal(true),
- is_auto_mode: z.boolean().optional().default(false),
- billing_tier: z.string().default("free"),
- // Mirrors `globalNewLLMConfig.is_premium` so the new-chat selector's
- // Free/Premium badge logic lights up automatically for vision too.
- is_premium: z.boolean().default(false),
- quota_reserve_tokens: z.number().nullable().optional(),
- input_cost_per_token: z.number().nullable().optional(),
- output_cost_per_token: z.number().nullable().optional(),
-});
-
-export const getGlobalVisionLLMConfigsResponse = z.array(globalVisionLLMConfig);
-
-// =============================================================================
-// LLM Preferences (Role Assignments)
-// =============================================================================
-
-export const llmPreferences = z.object({
- agent_llm_id: z.union([z.number(), z.null()]).optional(),
- image_generation_config_id: z.union([z.number(), z.null()]).optional(),
- vision_llm_config_id: z.union([z.number(), z.null()]).optional(),
- agent_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
- image_generation_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
- vision_llm_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
-});
-
-/**
- * Get LLM preferences
- */
-export const getLLMPreferencesRequest = z.object({
- search_space_id: z.number(),
-});
-
-export const getLLMPreferencesResponse = llmPreferences;
-
-/**
- * Update LLM preferences
- */
-export const updateLLMPreferencesRequest = z.object({
- search_space_id: z.number(),
- data: llmPreferences.pick({
- agent_llm_id: true,
- image_generation_config_id: true,
- vision_llm_config_id: true,
- }),
-});
-
-export const updateLLMPreferencesResponse = llmPreferences;
-
-// =============================================================================
-// Model List (dynamic catalogue from OpenRouter API)
-// =============================================================================
-
-export const modelListItem = z.object({
- value: z.string(),
- label: z.string(),
- provider: z.string(),
- context_window: z.string().nullable().optional(),
-});
-
-export const getModelListResponse = z.array(modelListItem);
-
-export type ModelListItem = z.infer;
-export type GetModelListResponse = z.infer;
-
-// =============================================================================
-// Type Exports
-// =============================================================================
-
-export type NewLLMConfig = z.infer;
-export type NewLLMConfigPublic = z.infer;
-export type CreateNewLLMConfigRequest = z.infer;
-export type CreateNewLLMConfigResponse = z.infer;
-export type GetNewLLMConfigsRequest = z.infer;
-export type GetNewLLMConfigsResponse = z.infer;
-export type GetNewLLMConfigRequest = z.infer;
-export type GetNewLLMConfigResponse = z.infer;
-export type UpdateNewLLMConfigRequest = z.infer;
-export type UpdateNewLLMConfigResponse = z.infer;
-export type DeleteNewLLMConfigRequest = z.infer;
-export type DeleteNewLLMConfigResponse = z.infer;
-export type GetDefaultSystemInstructionsResponse = z.infer<
- typeof getDefaultSystemInstructionsResponse
->;
-export type GlobalNewLLMConfig = z.infer;
-export type GetGlobalNewLLMConfigsResponse = z.infer;
-export type ImageGenerationConfig = z.infer;
-export type CreateImageGenConfigRequest = z.infer;
-export type CreateImageGenConfigResponse = z.infer;
-export type GetImageGenConfigsResponse = z.infer;
-export type UpdateImageGenConfigRequest = z.infer;
-export type UpdateImageGenConfigResponse = z.infer;
-export type DeleteImageGenConfigResponse = z.infer;
-export type GlobalImageGenConfig = z.infer;
-export type GetGlobalImageGenConfigsResponse = z.infer;
-export type VisionLLMConfig = z.infer;
-export type CreateVisionLLMConfigRequest = z.infer;
-export type CreateVisionLLMConfigResponse = z.infer;
-export type GetVisionLLMConfigsResponse = z.infer;
-export type UpdateVisionLLMConfigRequest = z.infer;
-export type UpdateVisionLLMConfigResponse = z.infer;
-export type DeleteVisionLLMConfigResponse = z.infer;
-export type GlobalVisionLLMConfig = z.infer;
-export type GetGlobalVisionLLMConfigsResponse = z.infer;
-export type LLMPreferences = z.infer;
-export type GetLLMPreferencesRequest = z.infer;
-export type GetLLMPreferencesResponse = z.infer;
-export type UpdateLLMPreferencesRequest = z.infer;
-export type UpdateLLMPreferencesResponse = z.infer;
diff --git a/surfsense_web/hooks/use-automation-eligible-models.ts b/surfsense_web/hooks/use-automation-eligible-models.ts
index e74994221..fd3ad3a6a 100644
--- a/surfsense_web/hooks/use-automation-eligible-models.ts
+++ b/surfsense_web/hooks/use-automation-eligible-models.ts
@@ -3,18 +3,11 @@
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import {
- globalImageGenConfigsAtom,
- imageGenConfigsAtom,
-} from "@/atoms/image-gen-config/image-gen-config-query.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";
+ globalModelConnectionsAtom,
+ modelConnectionsAtom,
+ modelRolesAtom,
+} from "@/atoms/model-connections/model-connections-query.atoms";
+import type { ConnectionRead, ModelRead } from "@/contracts/types/model-connections.types";
/**
* A single model the user may pick for an automation slot.
@@ -44,48 +37,45 @@ export interface AutomationEligibleModels {
isLoading: boolean;
}
-interface GlobalConfigLike {
- id: number;
- name: string;
- model_name: string;
- provider: string;
- is_premium?: boolean;
- is_auto_mode?: boolean;
-}
-
-interface UserConfigLike {
- id: number;
- name: string;
- model_name: string;
- provider: string;
-}
-
/**
* Build the eligible option list for one model kind: premium globals
- * (`is_premium === true`, never Auto mode) followed by all BYOK configs.
+ * followed by all BYOK/search-space models.
*/
function buildKind(
- globals: GlobalConfigLike[] | undefined,
- byok: UserConfigLike[] | undefined,
+ globals: ConnectionRead[] | undefined,
+ byok: ConnectionRead[] | undefined,
+ capability: "chat" | "image_gen" | "vision",
prefId: number | null | undefined
): EligibleModelKind {
- const premiumGlobals: EligibleModelOption[] = (globals ?? [])
- .filter((c) => c.is_premium === true && !c.is_auto_mode)
- .map((c) => ({
- id: c.id,
- name: c.name,
- modelName: c.model_name,
- provider: c.provider,
- isBYOK: false,
- }));
+ const supportsCapability = (model: ModelRead) => {
+ if (capability === "chat") return Boolean(model.supports_chat);
+ if (capability === "vision") return Boolean(model.supports_image_input);
+ return Boolean(model.supports_image_generation);
+ };
+ const toOption = (connection: ConnectionRead, model: ModelRead, isBYOK: boolean) => ({
+ id: model.id,
+ name: model.display_name || model.model_id,
+ modelName: model.model_id,
+ provider: connection.provider,
+ isBYOK,
+ });
- const byokOptions: EligibleModelOption[] = (byok ?? []).map((c) => ({
- id: c.id,
- name: c.name,
- modelName: c.model_name,
- provider: c.provider,
- isBYOK: true,
- }));
+ const premiumGlobals: EligibleModelOption[] = (globals ?? []).flatMap((connection) =>
+ connection.models
+ .filter(
+ (model) =>
+ model.enabled &&
+ supportsCapability(model) &&
+ String(model.billing_tier ?? "").toLowerCase() === "premium"
+ )
+ .map((model) => toOption(connection, model, false))
+ );
+
+ const byokOptions: EligibleModelOption[] = (byok ?? []).flatMap((connection) =>
+ connection.models
+ .filter((model) => model.enabled && supportsCapability(model))
+ .map((model) => toOption(connection, model, true))
+ );
const options = [...premiumGlobals, ...byokOptions];
const byId = new Map(options.map((o) => [o.id, o]));
@@ -105,46 +95,32 @@ function buildKind(
* (premium globals + user BYOK — never free globals or Auto mode), with a
* default selection seeded from the search space's role preferences.
*
- * Everything is derived during render from the existing config query atoms;
+ * Everything is derived during render from the connection/model query atoms;
* there are no effects, so option lists/maps keep stable references.
*/
export function useAutomationEligibleModels(): AutomationEligibleModels {
- const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
- const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } =
- useAtomValue(globalNewLLMConfigsAtom);
- const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
- const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } =
- useAtomValue(globalImageGenConfigsAtom);
- const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
- const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue(
- globalVisionLLMConfigsAtom
+ const { data: byokConnections, isLoading: byokLoading } = useAtomValue(modelConnectionsAtom);
+ const { data: globalConnections, isLoading: globalLoading } = useAtomValue(
+ globalModelConnectionsAtom
);
- const { data: visionUserConfigs, isLoading: visionUserLoading } =
- useAtomValue(visionLLMConfigsAtom);
+ const { data: roles, isLoading: rolesLoading } = useAtomValue(modelRolesAtom);
const llm = useMemo(
- () => buildKind(llmGlobalConfigs, llmUserConfigs, preferences?.agent_llm_id),
- [llmGlobalConfigs, llmUserConfigs, preferences?.agent_llm_id]
+ () => buildKind(globalConnections, byokConnections, "chat", roles?.chat_model_id),
+ [globalConnections, byokConnections, roles?.chat_model_id]
);
const image = useMemo(
- () => buildKind(imageGlobalConfigs, imageUserConfigs, preferences?.image_generation_config_id),
- [imageGlobalConfigs, imageUserConfigs, preferences?.image_generation_config_id]
+ () => buildKind(globalConnections, byokConnections, "image_gen", roles?.image_gen_model_id),
+ [globalConnections, byokConnections, roles?.image_gen_model_id]
);
const vision = useMemo(
- () => buildKind(visionGlobalConfigs, visionUserConfigs, preferences?.vision_llm_config_id),
- [visionGlobalConfigs, visionUserConfigs, preferences?.vision_llm_config_id]
+ () => buildKind(globalConnections, byokConnections, "vision", roles?.vision_model_id),
+ [globalConnections, byokConnections, roles?.vision_model_id]
);
- const isLoading =
- llmUserLoading ||
- llmGlobalLoading ||
- prefsLoading ||
- imageGlobalLoading ||
- imageUserLoading ||
- visionGlobalLoading ||
- visionUserLoading;
+ const isLoading = byokLoading || globalLoading || rolesLoading;
return useMemo(() => ({ llm, image, vision, isLoading }), [llm, image, vision, isLoading]);
}
diff --git a/surfsense_web/lib/apis/image-gen-config-api.service.ts b/surfsense_web/lib/apis/image-gen-config-api.service.ts
deleted file mode 100644
index a9d444d21..000000000
--- a/surfsense_web/lib/apis/image-gen-config-api.service.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import {
- type CreateImageGenConfigRequest,
- createImageGenConfigRequest,
- createImageGenConfigResponse,
- deleteImageGenConfigResponse,
- getGlobalImageGenConfigsResponse,
- getImageGenConfigsResponse,
- type UpdateImageGenConfigRequest,
- updateImageGenConfigRequest,
- updateImageGenConfigResponse,
-} from "@/contracts/types/new-llm-config.types";
-import { ValidationError } from "../error";
-import { baseApiService } from "./base-api.service";
-
-class ImageGenConfigApiService {
- /**
- * Get all global image generation configs (from YAML, negative IDs)
- */
- getGlobalConfigs = async () => {
- return baseApiService.get(
- `/api/v1/global-image-generation-configs`,
- getGlobalImageGenConfigsResponse
- );
- };
-
- /**
- * Create a new image generation config for a search space
- */
- createConfig = async (request: CreateImageGenConfigRequest) => {
- const parsed = createImageGenConfigRequest.safeParse(request);
- if (!parsed.success) {
- const msg = parsed.error.issues.map((i) => i.message).join(", ");
- throw new ValidationError(`Invalid request: ${msg}`);
- }
- return baseApiService.post(`/api/v1/image-generation-configs`, createImageGenConfigResponse, {
- body: parsed.data,
- });
- };
-
- /**
- * Get image generation configs for a search space
- */
- getConfigs = async (searchSpaceId: number) => {
- const params = new URLSearchParams({
- search_space_id: String(searchSpaceId),
- }).toString();
- return baseApiService.get(
- `/api/v1/image-generation-configs?${params}`,
- getImageGenConfigsResponse
- );
- };
-
- /**
- * Update an existing image generation config
- */
- updateConfig = async (request: UpdateImageGenConfigRequest) => {
- const parsed = updateImageGenConfigRequest.safeParse(request);
- if (!parsed.success) {
- const msg = parsed.error.issues.map((i) => i.message).join(", ");
- throw new ValidationError(`Invalid request: ${msg}`);
- }
- const { id, data } = parsed.data;
- return baseApiService.put(
- `/api/v1/image-generation-configs/${id}`,
- updateImageGenConfigResponse,
- { body: data }
- );
- };
-
- /**
- * Delete an image generation config
- */
- deleteConfig = async (id: number) => {
- return baseApiService.delete(
- `/api/v1/image-generation-configs/${id}`,
- deleteImageGenConfigResponse
- );
- };
-}
-
-export const imageGenConfigApiService = new ImageGenConfigApiService();
diff --git a/surfsense_web/lib/apis/model-connections-api.service.ts b/surfsense_web/lib/apis/model-connections-api.service.ts
new file mode 100644
index 000000000..3321f7dd7
--- /dev/null
+++ b/surfsense_web/lib/apis/model-connections-api.service.ts
@@ -0,0 +1,166 @@
+import {
+ type ConnectionCreateRequest,
+ type ConnectionRead,
+ type ConnectionUpdateRequest,
+ connectionCreateRequest,
+ connectionListResponse,
+ connectionRead,
+ connectionUpdateRequest,
+ type ModelCreateRequest,
+ type ModelPreviewRead,
+ type ModelProviderRead,
+ type ModelRead,
+ type ModelRoles,
+ type ModelsBulkUpdateRequest,
+ type ModelTestPreviewRequest,
+ type ModelUpdateRequest,
+ modelCreateRequest,
+ modelListResponse,
+ modelPreviewListResponse,
+ modelProviderListResponse,
+ modelRead,
+ modelRoles,
+ modelsBulkUpdateRequest,
+ modelTestPreviewRequest,
+ modelUpdateRequest,
+ type VerifyConnectionResponse,
+ verifyConnectionResponse,
+} from "@/contracts/types/model-connections.types";
+import { ValidationError } from "../error";
+import { baseApiService } from "./base-api.service";
+
+class ModelConnectionsApiService {
+ getGlobalConnections = async (): Promise => {
+ return baseApiService.get(`/api/v1/global-model-connections`, connectionListResponse);
+ };
+
+ getModelProviders = async (): Promise => {
+ return baseApiService.get(`/api/v1/model-providers`, modelProviderListResponse);
+ };
+
+ getConnections = async (searchSpaceId: number): Promise => {
+ return baseApiService.get(
+ `/api/v1/model-connections?search_space_id=${searchSpaceId}`,
+ connectionListResponse
+ );
+ };
+
+ createConnection = async (request: ConnectionCreateRequest): Promise => {
+ const parsed = connectionCreateRequest.safeParse(request);
+ if (!parsed.success) {
+ throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", "));
+ }
+ return baseApiService.post(`/api/v1/model-connections`, connectionRead, {
+ body: parsed.data,
+ });
+ };
+
+ updateConnection = async (
+ id: number,
+ request: ConnectionUpdateRequest
+ ): Promise => {
+ const parsed = connectionUpdateRequest.safeParse(request);
+ if (!parsed.success) {
+ throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", "));
+ }
+ return baseApiService.put(`/api/v1/model-connections/${id}`, connectionRead, {
+ body: parsed.data,
+ });
+ };
+
+ deleteConnection = async (id: number) => {
+ return baseApiService.delete(`/api/v1/model-connections/${id}`, undefined);
+ };
+
+ verifyConnection = async (id: number): Promise => {
+ return baseApiService.post(`/api/v1/model-connections/${id}/verify`, verifyConnectionResponse);
+ };
+
+ discoverModels = async (id: number): Promise => {
+ return baseApiService.post(`/api/v1/model-connections/${id}/discover`, modelListResponse);
+ };
+
+ previewModels = async (request: ConnectionCreateRequest): Promise => {
+ const parsed = connectionCreateRequest.safeParse(request);
+ if (!parsed.success) {
+ throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", "));
+ }
+ return baseApiService.post(
+ `/api/v1/model-connections/discover-preview`,
+ modelPreviewListResponse,
+ {
+ body: parsed.data,
+ }
+ );
+ };
+
+ testPreviewModel = async (
+ request: ModelTestPreviewRequest
+ ): Promise => {
+ const parsed = modelTestPreviewRequest.safeParse(request);
+ if (!parsed.success) {
+ throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", "));
+ }
+ return baseApiService.post(`/api/v1/model-connections/test-preview`, verifyConnectionResponse, {
+ body: parsed.data,
+ });
+ };
+
+ addManualModel = async (
+ connectionId: number,
+ request: ModelCreateRequest
+ ): Promise => {
+ const parsed = modelCreateRequest.safeParse(request);
+ if (!parsed.success) {
+ throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", "));
+ }
+ return baseApiService.post(`/api/v1/model-connections/${connectionId}/models`, modelRead, {
+ body: parsed.data,
+ });
+ };
+
+ updateModel = async (id: number, request: ModelUpdateRequest): Promise => {
+ const parsed = modelUpdateRequest.safeParse(request);
+ if (!parsed.success) {
+ throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", "));
+ }
+ return baseApiService.put(`/api/v1/models/${id}`, modelRead, {
+ body: parsed.data,
+ });
+ };
+
+ bulkUpdateModels = async (
+ connectionId: number,
+ request: ModelsBulkUpdateRequest
+ ): Promise => {
+ const parsed = modelsBulkUpdateRequest.safeParse(request);
+ if (!parsed.success) {
+ throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", "));
+ }
+ return baseApiService.request(
+ `/api/v1/model-connections/${connectionId}/models`,
+ modelListResponse,
+ {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: parsed.data,
+ }
+ );
+ };
+
+ testModel = async (id: number): Promise => {
+ return baseApiService.post(`/api/v1/models/${id}/test`, verifyConnectionResponse);
+ };
+
+ getModelRoles = async (searchSpaceId: number): Promise => {
+ return baseApiService.get(`/api/v1/search-spaces/${searchSpaceId}/model-roles`, modelRoles);
+ };
+
+ updateModelRoles = async (searchSpaceId: number, roles: ModelRoles): Promise => {
+ return baseApiService.put(`/api/v1/search-spaces/${searchSpaceId}/model-roles`, modelRoles, {
+ body: roles,
+ });
+ };
+}
+
+export const modelConnectionsApiService = new ModelConnectionsApiService();
diff --git a/surfsense_web/lib/apis/new-llm-config-api.service.ts b/surfsense_web/lib/apis/new-llm-config-api.service.ts
deleted file mode 100644
index a1040a9bc..000000000
--- a/surfsense_web/lib/apis/new-llm-config-api.service.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import {
- type CreateNewLLMConfigRequest,
- createNewLLMConfigRequest,
- createNewLLMConfigResponse,
- type DeleteNewLLMConfigRequest,
- deleteNewLLMConfigRequest,
- deleteNewLLMConfigResponse,
- type GetNewLLMConfigRequest,
- type GetNewLLMConfigsRequest,
- getDefaultSystemInstructionsResponse,
- getGlobalNewLLMConfigsResponse,
- getLLMPreferencesResponse,
- getModelListResponse,
- getNewLLMConfigRequest,
- getNewLLMConfigResponse,
- getNewLLMConfigsRequest,
- getNewLLMConfigsResponse,
- type UpdateLLMPreferencesRequest,
- type UpdateNewLLMConfigRequest,
- updateLLMPreferencesRequest,
- updateLLMPreferencesResponse,
- updateNewLLMConfigRequest,
- updateNewLLMConfigResponse,
-} from "@/contracts/types/new-llm-config.types";
-import { ValidationError } from "../error";
-import { baseApiService } from "./base-api.service";
-
-class NewLLMConfigApiService {
- /**
- * Get all global NewLLMConfigs available to all users
- */
- getGlobalConfigs = async () => {
- return baseApiService.get(`/api/v1/global-new-llm-configs`, getGlobalNewLLMConfigsResponse);
- };
-
- /**
- * Get default system instructions template
- */
- getDefaultSystemInstructions = async () => {
- return baseApiService.get(
- `/api/v1/new-llm-configs/default-system-instructions`,
- getDefaultSystemInstructionsResponse
- );
- };
-
- /**
- * Create a new NewLLMConfig for a search space
- */
- createConfig = async (request: CreateNewLLMConfigRequest) => {
- const parsedRequest = createNewLLMConfigRequest.safeParse(request);
-
- if (!parsedRequest.success) {
- console.error("Invalid request:", parsedRequest.error);
- const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
- throw new ValidationError(`Invalid request: ${errorMessage}`);
- }
-
- return baseApiService.post(`/api/v1/new-llm-configs`, createNewLLMConfigResponse, {
- body: parsedRequest.data,
- });
- };
-
- /**
- * Get a list of NewLLMConfigs for a search space
- */
- getConfigs = async (request: GetNewLLMConfigsRequest) => {
- const parsedRequest = getNewLLMConfigsRequest.safeParse(request);
-
- if (!parsedRequest.success) {
- console.error("Invalid request:", parsedRequest.error);
- const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
- throw new ValidationError(`Invalid request: ${errorMessage}`);
- }
-
- const queryParams = new URLSearchParams({
- search_space_id: String(parsedRequest.data.search_space_id),
- ...(parsedRequest.data.skip !== undefined && { skip: String(parsedRequest.data.skip) }),
- ...(parsedRequest.data.limit !== undefined && { limit: String(parsedRequest.data.limit) }),
- }).toString();
-
- return baseApiService.get(`/api/v1/new-llm-configs?${queryParams}`, getNewLLMConfigsResponse);
- };
-
- /**
- * Get a single NewLLMConfig by ID
- */
- getConfig = async (request: GetNewLLMConfigRequest) => {
- const parsedRequest = getNewLLMConfigRequest.safeParse(request);
-
- if (!parsedRequest.success) {
- console.error("Invalid request:", parsedRequest.error);
- const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
- throw new ValidationError(`Invalid request: ${errorMessage}`);
- }
-
- return baseApiService.get(
- `/api/v1/new-llm-configs/${parsedRequest.data.id}`,
- getNewLLMConfigResponse
- );
- };
-
- /**
- * Update an existing NewLLMConfig
- */
- updateConfig = async (request: UpdateNewLLMConfigRequest) => {
- const parsedRequest = updateNewLLMConfigRequest.safeParse(request);
-
- if (!parsedRequest.success) {
- console.error("Invalid request:", parsedRequest.error);
- const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
- throw new ValidationError(`Invalid request: ${errorMessage}`);
- }
-
- const { id, data } = parsedRequest.data;
-
- return baseApiService.put(`/api/v1/new-llm-configs/${id}`, updateNewLLMConfigResponse, {
- body: data,
- });
- };
-
- /**
- * Delete a NewLLMConfig
- */
- deleteConfig = async (request: DeleteNewLLMConfigRequest) => {
- const parsedRequest = deleteNewLLMConfigRequest.safeParse(request);
-
- if (!parsedRequest.success) {
- console.error("Invalid request:", parsedRequest.error);
- const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
- throw new ValidationError(`Invalid request: ${errorMessage}`);
- }
-
- return baseApiService.delete(
- `/api/v1/new-llm-configs/${parsedRequest.data.id}`,
- deleteNewLLMConfigResponse
- );
- };
-
- /**
- * Get LLM preferences for a search space
- */
- getLLMPreferences = async (searchSpaceId: number) => {
- return baseApiService.get(
- `/api/v1/search-spaces/${searchSpaceId}/llm-preferences`,
- getLLMPreferencesResponse
- );
- };
-
- /**
- * Get the dynamic model catalogue (sourced from OpenRouter API)
- */
- getModels = async () => {
- return baseApiService.get(`/api/v1/models`, getModelListResponse);
- };
-
- /**
- * Update LLM preferences for a search space
- */
- updateLLMPreferences = async (request: UpdateLLMPreferencesRequest) => {
- const parsedRequest = updateLLMPreferencesRequest.safeParse(request);
-
- if (!parsedRequest.success) {
- console.error("Invalid request:", parsedRequest.error);
- const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
- throw new ValidationError(`Invalid request: ${errorMessage}`);
- }
-
- const { search_space_id, data } = parsedRequest.data;
-
- return baseApiService.put(
- `/api/v1/search-spaces/${search_space_id}/llm-preferences`,
- updateLLMPreferencesResponse,
- { body: data }
- );
- };
-}
-
-export const newLLMConfigApiService = new NewLLMConfigApiService();
diff --git a/surfsense_web/lib/apis/vision-llm-config-api.service.ts b/surfsense_web/lib/apis/vision-llm-config-api.service.ts
deleted file mode 100644
index 537cecbd1..000000000
--- a/surfsense_web/lib/apis/vision-llm-config-api.service.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import {
- type CreateVisionLLMConfigRequest,
- createVisionLLMConfigRequest,
- createVisionLLMConfigResponse,
- deleteVisionLLMConfigResponse,
- getGlobalVisionLLMConfigsResponse,
- getModelListResponse,
- getVisionLLMConfigsResponse,
- type UpdateVisionLLMConfigRequest,
- updateVisionLLMConfigRequest,
- updateVisionLLMConfigResponse,
-} from "@/contracts/types/new-llm-config.types";
-import { ValidationError } from "../error";
-import { baseApiService } from "./base-api.service";
-
-class VisionLLMConfigApiService {
- getModels = async () => {
- return baseApiService.get(`/api/v1/vision-models`, getModelListResponse);
- };
-
- getGlobalConfigs = async () => {
- return baseApiService.get(
- `/api/v1/global-vision-llm-configs`,
- getGlobalVisionLLMConfigsResponse
- );
- };
-
- createConfig = async (request: CreateVisionLLMConfigRequest) => {
- const parsed = createVisionLLMConfigRequest.safeParse(request);
- if (!parsed.success) {
- const msg = parsed.error.issues.map((i) => i.message).join(", ");
- throw new ValidationError(`Invalid request: ${msg}`);
- }
- return baseApiService.post(`/api/v1/vision-llm-configs`, createVisionLLMConfigResponse, {
- body: parsed.data,
- });
- };
-
- getConfigs = async (searchSpaceId: number) => {
- const params = new URLSearchParams({
- search_space_id: String(searchSpaceId),
- }).toString();
- return baseApiService.get(`/api/v1/vision-llm-configs?${params}`, getVisionLLMConfigsResponse);
- };
-
- updateConfig = async (request: UpdateVisionLLMConfigRequest) => {
- const parsed = updateVisionLLMConfigRequest.safeParse(request);
- if (!parsed.success) {
- const msg = parsed.error.issues.map((i) => i.message).join(", ");
- throw new ValidationError(`Invalid request: ${msg}`);
- }
- const { id, data } = parsed.data;
- return baseApiService.put(`/api/v1/vision-llm-configs/${id}`, updateVisionLLMConfigResponse, {
- body: data,
- });
- };
-
- deleteConfig = async (id: number) => {
- return baseApiService.delete(`/api/v1/vision-llm-configs/${id}`, deleteVisionLLMConfigResponse);
- };
-}
-
-export const visionLLMConfigApiService = new VisionLLMConfigApiService();
diff --git a/surfsense_web/lib/automations/builder-schema.ts b/surfsense_web/lib/automations/builder-schema.ts
index c2bd69209..5bb034bef 100644
--- a/surfsense_web/lib/automations/builder-schema.ts
+++ b/surfsense_web/lib/automations/builder-schema.ts
@@ -73,7 +73,7 @@ export type BuilderExecution = z.infer;
* later chat/search-space model changes.
*/
export const builderModelsSchema = z.object({
- agentLlmId: z.number().int(),
+ chatModelId: z.number().int(),
imageConfigId: z.number().int(),
visionConfigId: z.number().int(),
});
@@ -90,7 +90,7 @@ export const builderFormSchema = z.object({
tags: z.array(z.string()),
/** Carried through from an edited definition so we don't drop it. */
goal: z.string().nullable(),
- /** Selected agent/image/vision models (``0`` = use the eligible default). */
+ /** Selected chat/image/vision models (``0`` = use the eligible default). */
models: builderModelsSchema,
});
export type BuilderForm = z.infer;
@@ -147,7 +147,7 @@ export function createEmptyForm(): BuilderForm {
},
tags: [],
goal: null,
- models: { agentLlmId: 0, imageConfigId: 0, visionConfigId: 0 },
+ models: { chatModelId: 0, imageConfigId: 0, visionConfigId: 0 },
};
}
@@ -240,9 +240,9 @@ function buildDefinition(form: BuilderForm): AutomationDefinition {
...(hasResolvedModels(form.models)
? {
models: {
- agent_llm_id: form.models.agentLlmId,
- image_generation_config_id: form.models.imageConfigId,
- vision_llm_config_id: form.models.visionConfigId,
+ chat_model_id: form.models.chatModelId,
+ image_gen_model_id: form.models.imageConfigId,
+ vision_model_id: form.models.visionConfigId,
},
}
: {}),
@@ -251,7 +251,7 @@ function buildDefinition(form: BuilderForm): AutomationDefinition {
/** True once every model slot holds a concrete (non-zero) id. */
export function hasResolvedModels(models: BuilderModels): boolean {
- return models.agentLlmId !== 0 && models.imageConfigId !== 0 && models.visionConfigId !== 0;
+ return models.chatModelId !== 0 && models.imageConfigId !== 0 && models.visionConfigId !== 0;
}
/** The desired schedule trigger for this form, or ``null`` if none. */
@@ -500,9 +500,9 @@ function modelsFromDefinition(raw: unknown): BuilderModels {
const m = asRecord(raw);
const num = (value: unknown) => (typeof value === "number" ? value : 0);
return {
- agentLlmId: num(m.agent_llm_id),
- imageConfigId: num(m.image_generation_config_id),
- visionConfigId: num(m.vision_llm_config_id),
+ chatModelId: num(m.chat_model_id),
+ imageConfigId: num(m.image_gen_model_id),
+ visionConfigId: num(m.vision_model_id),
};
}
diff --git a/surfsense_web/lib/chat/chat-error-classifier.ts b/surfsense_web/lib/chat/chat-error-classifier.ts
index 1c67d59a1..92924f0f7 100644
--- a/surfsense_web/lib/chat/chat-error-classifier.ts
+++ b/surfsense_web/lib/chat/chat-error-classifier.ts
@@ -5,6 +5,10 @@ export type ChatErrorKind =
| "thread_busy"
| "send_failed_pre_accept"
| "auth_expired"
+ | "model_auth_failed"
+ | "model_not_found"
+ | "model_context_limit"
+ | "model_provider_unavailable"
| "rate_limited"
| "network_offline"
| "stream_interrupted"
@@ -14,7 +18,7 @@ export type ChatErrorKind =
| "server_error"
| "unknown";
-export type ChatErrorChannel = "pinned_inline" | "toast" | "silent";
+export type ChatErrorChannel = "pinned_inline" | "inline" | "toast" | "silent";
export type ChatTelemetryEvent = "chat_blocked" | "chat_error";
export type ChatErrorSeverity = "info" | "warn" | "error";
@@ -206,6 +210,66 @@ export function classifyChatError(input: RawChatErrorInput): NormalizedChatError
};
}
+ if (errorCode === "MODEL_AUTH_FAILED") {
+ return {
+ kind: "model_auth_failed",
+ channel: "toast",
+ severity: "warn",
+ telemetryEvent: "chat_blocked",
+ isExpected: true,
+ userMessage:
+ "This model’s API key is invalid or expired. Switch models, or update the API key.",
+ rawMessage,
+ errorCode: errorCode ?? "MODEL_AUTH_FAILED",
+ details: { flow: input.flow, providerErrorType },
+ };
+ }
+
+ if (errorCode === "MODEL_NOT_FOUND") {
+ return {
+ kind: "model_not_found",
+ channel: "toast",
+ severity: "warn",
+ telemetryEvent: "chat_blocked",
+ isExpected: true,
+ userMessage:
+ "This model is unavailable or no longer exists. Switch to another model and try again.",
+ rawMessage,
+ errorCode: errorCode ?? "MODEL_NOT_FOUND",
+ details: { flow: input.flow, providerErrorType },
+ };
+ }
+
+ if (errorCode === "MODEL_CONTEXT_LIMIT") {
+ return {
+ kind: "model_context_limit",
+ channel: "toast",
+ severity: "warn",
+ telemetryEvent: "chat_blocked",
+ isExpected: true,
+ userMessage:
+ "This request is too large for the selected model. Reduce the input or switch models.",
+ rawMessage,
+ errorCode: errorCode ?? "MODEL_CONTEXT_LIMIT",
+ details: { flow: input.flow, providerErrorType },
+ };
+ }
+
+ if (errorCode === "MODEL_PROVIDER_UNAVAILABLE") {
+ return {
+ kind: "model_provider_unavailable",
+ channel: "toast",
+ severity: "warn",
+ telemetryEvent: "chat_blocked",
+ isExpected: true,
+ userMessage:
+ "The selected model provider is temporarily unavailable. Please try again or switch models.",
+ rawMessage,
+ errorCode: errorCode ?? "MODEL_PROVIDER_UNAVAILABLE",
+ details: { flow: input.flow, providerErrorType },
+ };
+ }
+
if (errorCode === "RATE_LIMITED" || providerTypeNormalized === "rate_limit_error") {
return {
kind: "rate_limited",
diff --git a/surfsense_web/lib/chat/chat-request-errors.ts b/surfsense_web/lib/chat/chat-request-errors.ts
index e0dfb3cc4..c86c72d66 100644
--- a/surfsense_web/lib/chat/chat-request-errors.ts
+++ b/surfsense_web/lib/chat/chat-request-errors.ts
@@ -91,6 +91,10 @@ export function tagPreAcceptSendFailure(error: unknown): unknown {
"TURN_CANCELLING",
"AUTH_EXPIRED",
"UNAUTHORIZED",
+ "MODEL_AUTH_FAILED",
+ "MODEL_NOT_FOUND",
+ "MODEL_CONTEXT_LIMIT",
+ "MODEL_PROVIDER_UNAVAILABLE",
"RATE_LIMITED",
"NETWORK_ERROR",
"STREAM_PARSE_ERROR",
diff --git a/surfsense_web/lib/onboarding.ts b/surfsense_web/lib/onboarding.ts
index b87f822a0..5ca597137 100644
--- a/surfsense_web/lib/onboarding.ts
+++ b/surfsense_web/lib/onboarding.ts
@@ -1,8 +1,28 @@
-export function isLlmOnboardingComplete(
- agentLlmId: number | null | undefined,
- hasGlobalConfigs: boolean
-): boolean {
- if (agentLlmId === null || agentLlmId === undefined) return false;
- if (agentLlmId === 0) return hasGlobalConfigs;
- return true;
+import type { ConnectionRead } from "@/contracts/types/model-connections.types";
+
+export function hasEnabledChatModel(connections: ConnectionRead[]): boolean {
+ return connections.some(
+ (connection) =>
+ connection.enabled &&
+ connection.models.some((model) => model.enabled && Boolean(model.supports_chat))
+ );
+}
+
+export function isLlmOnboardingComplete(
+ chatModelId: number | null | undefined,
+ globalConnections: ConnectionRead[],
+ searchSpaceConnections: ConnectionRead[]
+): boolean {
+ const connections = [...globalConnections, ...searchSpaceConnections];
+ const resolvedChatModelId = chatModelId ?? 0;
+
+ if (resolvedChatModelId === 0) {
+ return hasEnabledChatModel(connections);
+ }
+
+ return connections.some((connection) =>
+ connection.models.some(
+ (model) => model.id === resolvedChatModelId && model.enabled && Boolean(model.supports_chat)
+ )
+ );
}
diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts
index 1f8875a6d..41ac7e7b2 100644
--- a/surfsense_web/lib/posthog/events.ts
+++ b/surfsense_web/lib/posthog/events.ts
@@ -609,9 +609,9 @@ interface AutomationCreatedProps {
task_count?: number;
trigger_type?: string;
has_schedule?: boolean;
- agent_llm_id?: number;
- image_generation_config_id?: number;
- vision_llm_config_id?: number;
+ chat_model_id?: number;
+ image_gen_model_id?: number;
+ vision_model_id?: number;
tags_count?: number;
}
@@ -705,9 +705,9 @@ interface AutomationChatDecisionProps {
edited?: boolean;
task_count?: number;
trigger_type?: string;
- agent_llm_id?: number;
- image_generation_config_id?: number;
- vision_llm_config_id?: number;
+ chat_model_id?: number;
+ image_gen_model_id?: number;
+ vision_model_id?: number;
}
export function trackAutomationChatApproved(props: AutomationChatDecisionProps) {
diff --git a/surfsense_web/lib/provider-icons.tsx b/surfsense_web/lib/provider-icons.tsx
index e63c5eb2f..d3e799720 100644
--- a/surfsense_web/lib/provider-icons.tsx
+++ b/surfsense_web/lib/provider-icons.tsx
@@ -1,10 +1,11 @@
-import { Bot, Shuffle } from "lucide-react";
+import { Cpu, Shuffle } from "lucide-react";
import {
Ai21Icon,
- AnthropicIcon,
AnyscaleIcon,
+ AzureIcon,
BedrockIcon,
CerebrasIcon,
+ ClaudeIcon,
CloudflareIcon,
CohereIcon,
CometApiIcon,
@@ -16,6 +17,7 @@ import {
GitHubModelsIcon,
GroqIcon,
HuggingFaceIcon,
+ LmStudioIcon,
MiniMaxIcon,
MistralIcon,
MoonshotIcon,
@@ -36,6 +38,8 @@ import {
} from "@/components/icons/providers";
import { cn } from "@/lib/utils";
+export const AUTO_PROVIDER_ICON_KEY = "AUTO";
+
/**
* Returns a Lucide icon element for the given LLM / image-gen provider.
* Accepts an optional `className` override for the icon size.
@@ -44,7 +48,7 @@ export function getProviderIcon(
provider: string,
{ isAutoMode, className = "size-4" }: { isAutoMode?: boolean; className?: string } = {}
) {
- if (isAutoMode || provider?.toUpperCase() === "AUTO") {
+ if (isAutoMode || provider?.toUpperCase() === AUTO_PROVIDER_ICON_KEY) {
return ;
}
@@ -54,12 +58,13 @@ export function getProviderIcon(
case "ALIBABA_QWEN":
return ;
case "ANTHROPIC":
- return ;
+ case "CLAUDE":
+ return ;
case "ANYSCALE":
return ;
case "AZURE":
case "AZURE_OPENAI":
- return ;
+ return ;
case "AWS_BEDROCK":
case "BEDROCK":
return ;
@@ -72,7 +77,7 @@ export function getProviderIcon(
case "COMETAPI":
return ;
case "CUSTOM":
- return ;
+ return ;
case "DATABRICKS":
return ;
case "DEEPINFRA":
@@ -89,6 +94,8 @@ export function getProviderIcon(
return ;
case "HUGGINGFACE":
return ;
+ case "LM_STUDIO":
+ return ;
case "MINIMAX":
return ;
case "MISTRAL":
@@ -98,6 +105,7 @@ export function getProviderIcon(
case "NSCALE":
return ;
case "OLLAMA":
+ case "OLLAMA_CHAT":
return ;
case "OPENAI":
return ;
@@ -122,6 +130,6 @@ export function getProviderIcon(
case "ZHIPU":
return ;
default:
- return ;
+ return ;
}
}
diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts
index 6f8885d7e..962b1c03f 100644
--- a/surfsense_web/lib/query-client/cache-keys.ts
+++ b/surfsense_web/lib/query-client/cache-keys.ts
@@ -36,24 +36,11 @@ export const cacheKeys = {
withQueryParams: (queries: GetLogsRequest["queryParams"]) =>
["logs", "with-query-params", ...stableEntries(queries)] as const,
},
- newLLMConfigs: {
- all: (searchSpaceId: number) => ["new-llm-configs", searchSpaceId] as const,
- byId: (configId: number) => ["new-llm-configs", "detail", configId] as const,
- preferences: (searchSpaceId: number) => ["llm-preferences", searchSpaceId] as const,
- defaultInstructions: () => ["new-llm-configs", "default-instructions"] as const,
- global: () => ["new-llm-configs", "global"] as const,
- modelList: () => ["models", "catalogue"] as const,
- },
- imageGenConfigs: {
- all: (searchSpaceId: number) => ["image-gen-configs", searchSpaceId] as const,
- byId: (configId: number) => ["image-gen-configs", "detail", configId] as const,
- global: () => ["image-gen-configs", "global"] as const,
- },
- visionLLMConfigs: {
- all: (searchSpaceId: number) => ["vision-llm-configs", searchSpaceId] as const,
- byId: (configId: number) => ["vision-llm-configs", "detail", configId] as const,
- global: () => ["vision-llm-configs", "global"] as const,
- modelList: () => ["vision-models", "catalogue"] as const,
+ modelConnections: {
+ all: (searchSpaceId: number) => ["model-connections", searchSpaceId] as const,
+ global: () => ["model-connections", "global"] as const,
+ providers: () => ["model-connections", "providers"] as const,
+ roles: (searchSpaceId: number) => ["model-roles", searchSpaceId] as const,
},
auth: {
user: ["auth", "user"] as const,
diff --git a/surfsense_web/lib/source.ts b/surfsense_web/lib/source.ts
index f71e8b688..13fb58f22 100644
--- a/surfsense_web/lib/source.ts
+++ b/surfsense_web/lib/source.ts
@@ -4,6 +4,7 @@ import {
ClipboardCheck,
Compass,
Container,
+ Cpu,
Download,
FlaskConical,
Heart,
@@ -25,6 +26,7 @@ const DOCS_ICONS: Record = {
ClipboardCheck,
Compass,
Container,
+ Cpu,
Download,
FlaskConical,
Heart,
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index a13942e64..866ba4844 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -476,9 +476,7 @@
"title": "Settings",
"subtitle": "Manage your LLM configurations and role assignments for this search space.",
"back_to_dashboard": "Back to Dashboard",
- "model_configs": "Model Configs",
"models": "Models",
- "llm_roles": "LLM Roles",
"roles": "Roles",
"llm_role_management": "LLM Role Management",
"llm_role_desc": "Assign your LLM configurations to specific roles for different purposes.",
@@ -743,14 +741,9 @@
"back_to_app": "Back to app",
"nav_general": "General",
"nav_general_desc": "Name, description & basic info",
- "nav_agent_models": "Agent Models",
+ "nav_models": "Models",
+ "nav_agent_models": "Chat Models",
"nav_agent_models_desc": "Models with prompts & citations",
- "nav_role_assignments": "Role Assignments",
- "nav_role_assignments_desc": "Assign configs to agent roles",
- "nav_image_models": "Image Models",
- "nav_image_models_desc": "Configure image generation models",
- "nav_vision_models": "Vision Models",
- "nav_vision_models_desc": "Configure vision-capable LLM models",
"nav_system_instructions": "System Instructions",
"nav_system_instructions_desc": "SearchSpace-wide AI instructions",
"nav_public_links": "Public Chat Links",
diff --git a/surfsense_web/messages/es.json b/surfsense_web/messages/es.json
index 33ae79c52..f7755b47e 100644
--- a/surfsense_web/messages/es.json
+++ b/surfsense_web/messages/es.json
@@ -476,9 +476,7 @@
"title": "Configuración",
"subtitle": "Administra tus configuraciones de LLM y asignaciones de roles para este espacio de búsqueda.",
"back_to_dashboard": "Volver al panel de control",
- "model_configs": "Configuraciones de modelos",
"models": "Modelos",
- "llm_roles": "Roles de LLM",
"roles": "Roles",
"llm_role_management": "Gestión de roles de LLM",
"llm_role_desc": "Asigna tus configuraciones de LLM a roles específicos para diferentes propósitos.",
@@ -743,14 +741,9 @@
"back_to_app": "Volver a la app",
"nav_general": "General",
"nav_general_desc": "Nombre, descripción e información básica",
- "nav_agent_models": "Modelos de agente",
+ "nav_models": "Modelos",
+ "nav_agent_models": "Modelos de chat",
"nav_agent_models_desc": "Modelos LLM con prompts y citas",
- "nav_role_assignments": "Asignaciones de roles",
- "nav_role_assignments_desc": "Asignar configuraciones a roles de agente",
- "nav_image_models": "Modelos de imagen",
- "nav_image_models_desc": "Configurar modelos de generación de imágenes",
- "nav_vision_models": "Modelos de visión",
- "nav_vision_models_desc": "Configurar modelos LLM con capacidad de visión",
"nav_system_instructions": "Instrucciones del sistema",
"nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda",
"nav_public_links": "Enlaces de chat públicos",
@@ -766,7 +759,27 @@
"general_reset": "Restablecer cambios",
"general_save": "Guardar cambios",
"general_saving": "Guardando",
- "general_unsaved_changes": "Tienes cambios sin guardar. Haz clic en \"Guardar cambios\" para aplicarlos."
+ "general_unsaved_changes": "Tienes cambios sin guardar. Haz clic en \"Guardar cambios\" para aplicarlos.",
+ "nav_web_search": "Búsqueda web",
+ "nav_web_search_desc": "Configuración de búsqueda web integrada",
+ "web_search_title": "Búsqueda web",
+ "web_search_description": "La búsqueda web funciona con una instancia SearXNG integrada. Todas las consultas se procesan a través de tu servidor; no se envían datos a terceros.",
+ "web_search_enabled_label": "Activar búsqueda web",
+ "web_search_enabled_description": "Cuando está activada, el agente de IA puede buscar en la web información en tiempo real como noticias, precios y eventos actuales.",
+ "web_search_status_healthy": "El servicio de búsqueda web está funcionando",
+ "web_search_status_unhealthy": "El servicio de búsqueda web no está disponible",
+ "web_search_status_not_configured": "El servicio de búsqueda web no está configurado",
+ "web_search_engines_label": "Motores de búsqueda",
+ "web_search_engines_placeholder": "google,brave,duckduckgo",
+ "web_search_engines_description": "Lista separada por comas de motores SearXNG a usar. Déjalo vacío para usar los valores predeterminados.",
+ "web_search_language_label": "Idioma preferido",
+ "web_search_language_placeholder": "es",
+ "web_search_language_description": "Etiqueta de idioma IETF (por ejemplo, es, es-ES). Déjalo vacío para detección automática.",
+ "web_search_safesearch_label": "Nivel de SafeSearch",
+ "web_search_safesearch_description": "0 = desactivado, 1 = moderado, 2 = estricto",
+ "web_search_save": "Guardar configuración de búsqueda web",
+ "web_search_saving": "Guardando...",
+ "web_search_saved": "Configuración de búsqueda web guardada"
},
"homepage": {
"hero_title_part1": "El espacio de trabajo con IA",
diff --git a/surfsense_web/messages/hi.json b/surfsense_web/messages/hi.json
index 7a26d0c1d..038555f1e 100644
--- a/surfsense_web/messages/hi.json
+++ b/surfsense_web/messages/hi.json
@@ -476,9 +476,7 @@
"title": "सेटिंग्स",
"subtitle": "इस सर्च स्पेस के लिए अपनी LLM कॉन्फ़िगरेशन और भूमिका असाइनमेंट प्रबंधित करें।",
"back_to_dashboard": "डैशबोर्ड पर वापस जाएं",
- "model_configs": "मॉडल कॉन्फ़िगरेशन",
"models": "मॉडल",
- "llm_roles": "LLM भूमिकाएं",
"roles": "भूमिकाएं",
"llm_role_management": "LLM भूमिका प्रबंधन",
"llm_role_desc": "विभिन्न उद्देश्यों के लिए अपनी LLM कॉन्फ़िगरेशन को विशिष्ट भूमिकाओं में असाइन करें।",
@@ -743,14 +741,9 @@
"back_to_app": "ऐप पर वापस जाएं",
"nav_general": "सामान्य",
"nav_general_desc": "नाम, विवरण और बुनियादी जानकारी",
- "nav_agent_models": "एजेंट मॉडल",
+ "nav_models": "मॉडल",
+ "nav_agent_models": "चैट मॉडल",
"nav_agent_models_desc": "प्रॉम्प्ट और उद्धरण के साथ LLM मॉडल",
- "nav_role_assignments": "भूमिका असाइनमेंट",
- "nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें",
- "nav_image_models": "इमेज मॉडल",
- "nav_image_models_desc": "इमेज जनरेशन मॉडल कॉन्फ़िगर करें",
- "nav_vision_models": "विज़न मॉडल",
- "nav_vision_models_desc": "विज़न-सक्षम LLM मॉडल कॉन्फ़िगर करें",
"nav_system_instructions": "सिस्टम निर्देश",
"nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश",
"nav_public_links": "सार्वजनिक चैट लिंक",
@@ -766,7 +759,27 @@
"general_reset": "परिवर्तन रीसेट करें",
"general_save": "परिवर्तन सहेजें",
"general_saving": "सहेजा जा रहा है",
- "general_unsaved_changes": "आपके पास सहेजे नहीं गए परिवर्तन हैं। उन्हें लागू करने के लिए \"परिवर्तन सहेजें\" पर क्लिक करें।"
+ "general_unsaved_changes": "आपके पास सहेजे नहीं गए परिवर्तन हैं। उन्हें लागू करने के लिए \"परिवर्तन सहेजें\" पर क्लिक करें।",
+ "nav_web_search": "वेब खोज",
+ "nav_web_search_desc": "बिल्ट-इन वेब खोज सेटिंग्स",
+ "web_search_title": "वेब खोज",
+ "web_search_description": "वेब खोज एक बिल्ट-इन SearXNG इंस्टेंस द्वारा संचालित है। सभी क्वेरी आपके सर्वर के माध्यम से प्रॉक्सी की जाती हैं; कोई डेटा तृतीय पक्षों को नहीं भेजा जाता।",
+ "web_search_enabled_label": "वेब खोज सक्षम करें",
+ "web_search_enabled_description": "सक्षम होने पर, AI एजेंट समाचार, कीमतों और वर्तमान घटनाओं जैसी वास्तविक समय की जानकारी के लिए वेब खोज सकता है।",
+ "web_search_status_healthy": "वेब खोज सेवा स्वस्थ है",
+ "web_search_status_unhealthy": "वेब खोज सेवा उपलब्ध नहीं है",
+ "web_search_status_not_configured": "वेब खोज सेवा कॉन्फ़िगर नहीं है",
+ "web_search_engines_label": "खोज इंजन",
+ "web_search_engines_placeholder": "google,brave,duckduckgo",
+ "web_search_engines_description": "उपयोग करने के लिए SearXNG इंजनों की कॉमा-सेपरेटेड सूची। डिफ़ॉल्ट के लिए खाली छोड़ें।",
+ "web_search_language_label": "पसंदीदा भाषा",
+ "web_search_language_placeholder": "hi",
+ "web_search_language_description": "IETF भाषा टैग (जैसे hi, hi-IN)। ऑटो-डिटेक्ट के लिए खाली छोड़ें।",
+ "web_search_safesearch_label": "SafeSearch स्तर",
+ "web_search_safesearch_description": "0 = बंद, 1 = मध्यम, 2 = सख्त",
+ "web_search_save": "वेब खोज सेटिंग्स सहेजें",
+ "web_search_saving": "सहेजा जा रहा है...",
+ "web_search_saved": "वेब खोज सेटिंग्स सहेजी गईं"
},
"homepage": {
"hero_title_part1": "AI कार्यक्षेत्र",
diff --git a/surfsense_web/messages/pt.json b/surfsense_web/messages/pt.json
index 61c22e086..bcba8f70c 100644
--- a/surfsense_web/messages/pt.json
+++ b/surfsense_web/messages/pt.json
@@ -476,9 +476,7 @@
"title": "Configurações",
"subtitle": "Gerencie suas configurações de LLM e atribuições de funções para este espaço de pesquisa.",
"back_to_dashboard": "Voltar ao painel",
- "model_configs": "Configurações de modelos",
"models": "Modelos",
- "llm_roles": "Funções de LLM",
"roles": "Funções",
"llm_role_management": "Gerenciamento de funções de LLM",
"llm_role_desc": "Atribua suas configurações de LLM a funções específicas para diferentes propósitos.",
@@ -743,14 +741,9 @@
"back_to_app": "Voltar ao app",
"nav_general": "Geral",
"nav_general_desc": "Nome, descrição e informações básicas",
- "nav_agent_models": "Modelos do agente",
+ "nav_models": "Modelos",
+ "nav_agent_models": "Modelos de chat",
"nav_agent_models_desc": "Modelos LLM com prompts e citações",
- "nav_role_assignments": "Atribuições de funções",
- "nav_role_assignments_desc": "Atribuir configurações a funções do agente",
- "nav_image_models": "Modelos de imagem",
- "nav_image_models_desc": "Configurar modelos de geração de imagens",
- "nav_vision_models": "Modelos de visão",
- "nav_vision_models_desc": "Configurar modelos LLM com capacidade de visão",
"nav_system_instructions": "Instruções do sistema",
"nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa",
"nav_public_links": "Links de chat públicos",
@@ -766,7 +759,27 @@
"general_reset": "Redefinir alterações",
"general_save": "Salvar alterações",
"general_saving": "Salvando",
- "general_unsaved_changes": "Você tem alterações não salvas. Clique em \"Salvar alterações\" para aplicá-las."
+ "general_unsaved_changes": "Você tem alterações não salvas. Clique em \"Salvar alterações\" para aplicá-las.",
+ "nav_web_search": "Pesquisa na web",
+ "nav_web_search_desc": "Configurações integradas de pesquisa na web",
+ "web_search_title": "Pesquisa na web",
+ "web_search_description": "A pesquisa na web é alimentada por uma instância SearXNG integrada. Todas as consultas passam pelo seu servidor; nenhum dado é enviado a terceiros.",
+ "web_search_enabled_label": "Ativar pesquisa na web",
+ "web_search_enabled_description": "Quando ativado, o agente de IA pode pesquisar na web informações em tempo real, como notícias, preços e eventos atuais.",
+ "web_search_status_healthy": "O serviço de pesquisa na web está saudável",
+ "web_search_status_unhealthy": "O serviço de pesquisa na web está indisponível",
+ "web_search_status_not_configured": "O serviço de pesquisa na web não está configurado",
+ "web_search_engines_label": "Mecanismos de pesquisa",
+ "web_search_engines_placeholder": "google,brave,duckduckgo",
+ "web_search_engines_description": "Lista separada por vírgulas de mecanismos SearXNG a usar. Deixe vazio para os padrões.",
+ "web_search_language_label": "Idioma preferido",
+ "web_search_language_placeholder": "pt",
+ "web_search_language_description": "Tag de idioma IETF (por exemplo, pt, pt-BR). Deixe vazio para detecção automática.",
+ "web_search_safesearch_label": "Nível de SafeSearch",
+ "web_search_safesearch_description": "0 = desativado, 1 = moderado, 2 = rigoroso",
+ "web_search_save": "Salvar configurações de pesquisa na web",
+ "web_search_saving": "Salvando...",
+ "web_search_saved": "Configurações de pesquisa na web salvas"
},
"homepage": {
"hero_title_part1": "O espaço de trabalho com IA",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index 7d0419cbd..5fea60eb8 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -96,6 +96,10 @@
"create_new_search_space": "创建新的搜索空间",
"delete_title": "删除搜索空间",
"delete_confirm": "您确定要删除「{name}」吗?此操作无法撤销,将永久删除所有数据。",
+ "leave": "退出",
+ "leave_title": "退出搜索空间",
+ "leave_confirm": "您确定要退出「{name}」吗?您将无法访问此搜索空间中的所有文档和对话。",
+ "leaving": "退出中...",
"welcome_title": "欢迎使用 SurfSense",
"welcome_description": "创建您的第一个搜索空间,开始组织知识、连接数据源并与AI对话。",
"create_first_button": "创建第一个搜索空间"
@@ -104,6 +108,17 @@
"title": "用户设置",
"description": "管理您的账户设置和API访问",
"back_to_app": "返回应用",
+ "profile_nav_label": "个人资料",
+ "profile_nav_description": "管理您的显示名称和头像",
+ "profile_title": "个人资料",
+ "profile_description": "更新您的个人信息",
+ "profile_avatar": "个人头像",
+ "profile_display_name": "显示名称",
+ "profile_display_name_hint": "这是您的名称在应用中的显示方式",
+ "profile_email": "电子邮件",
+ "profile_save": "保存更改",
+ "profile_saved": "个人资料已成功更新",
+ "profile_save_error": "无法更新个人资料",
"api_key_nav_label": "API密钥",
"api_key_nav_description": "管理您的API访问令牌",
"api_key_title": "API密钥",
@@ -460,9 +475,7 @@
"title": "设置",
"subtitle": "管理此搜索空间的 LLM 配置和角色分配。",
"back_to_dashboard": "返回仪表盘",
- "model_configs": "模型配置",
"models": "模型",
- "llm_roles": "LLM 角色",
"roles": "角色",
"llm_role_management": "LLM 角色管理",
"llm_role_desc": "为不同用途分配您的 LLM 配置到特定角色。",
@@ -727,14 +740,9 @@
"back_to_app": "返回应用",
"nav_general": "常规",
"nav_general_desc": "名称、描述和基本信息",
- "nav_agent_models": "代理模型",
+ "nav_models": "模型",
+ "nav_agent_models": "聊天模型",
"nav_agent_models_desc": "LLM 模型配置提示词和引用",
- "nav_role_assignments": "角色分配",
- "nav_role_assignments_desc": "为代理角色分配配置",
- "nav_image_models": "图像模型",
- "nav_image_models_desc": "配置图像生成模型",
- "nav_vision_models": "视觉模型",
- "nav_vision_models_desc": "配置具有视觉能力的LLM模型",
"nav_system_instructions": "系统指令",
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
"nav_public_links": "公开聊天链接",
@@ -750,7 +758,27 @@
"general_reset": "重置更改",
"general_save": "保存更改",
"general_saving": "保存中...",
- "general_unsaved_changes": "您有未保存的更改。点击\"保存更改\"以应用它们。"
+ "general_unsaved_changes": "您有未保存的更改。点击\"保存更改\"以应用它们。",
+ "nav_web_search": "网页搜索",
+ "nav_web_search_desc": "内置网页搜索设置",
+ "web_search_title": "网页搜索",
+ "web_search_description": "网页搜索由内置 SearXNG 实例提供支持。所有查询都通过您的服务器代理;不会向第三方发送数据。",
+ "web_search_enabled_label": "启用网页搜索",
+ "web_search_enabled_description": "启用后,AI 代理可以搜索网页以获取新闻、价格和当前事件等实时信息。",
+ "web_search_status_healthy": "网页搜索服务运行正常",
+ "web_search_status_unhealthy": "网页搜索服务不可用",
+ "web_search_status_not_configured": "网页搜索服务未配置",
+ "web_search_engines_label": "搜索引擎",
+ "web_search_engines_placeholder": "google,brave,duckduckgo",
+ "web_search_engines_description": "要使用的 SearXNG 引擎的逗号分隔列表。留空则使用默认值。",
+ "web_search_language_label": "首选语言",
+ "web_search_language_placeholder": "zh",
+ "web_search_language_description": "IETF 语言标签(例如 zh、zh-CN)。留空则自动检测。",
+ "web_search_safesearch_label": "SafeSearch 级别",
+ "web_search_safesearch_description": "0 = 关闭,1 = 中等,2 = 严格",
+ "web_search_save": "保存网页搜索设置",
+ "web_search_saving": "保存中...",
+ "web_search_saved": "网页搜索设置已保存"
},
"homepage": {
"hero_title_part1": "AI 工作空间",