From 32ab2b8713e6b67493ed6873e3e74f11e6013240 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:49:07 +0530 Subject: [PATCH] feat(web): expose model policies in automations --- .../builder/automation-builder-form.tsx | 2 +- .../builder/automation-model-fields.tsx | 14 +-- .../automations/automations-mutation.atoms.ts | 6 +- .../tool-ui/automation/create-automation.tsx | 18 +-- .../contracts/types/automation.types.ts | 6 +- .../hooks/use-automation-eligible-models.ts | 117 +++++++----------- .../lib/automations/builder-schema.ts | 20 +-- 7 files changed, 77 insertions(+), 106 deletions(-) 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 })} /> ({ 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/components/tool-ui/automation/create-automation.tsx b/surfsense_web/components/tool-ui/automation/create-automation.tsx index 24e9d66bd..2a7d09f53 100644 --- a/surfsense_web/components/tool-ui/automation/create-automation.tsx +++ b/surfsense_web/components/tool-ui/automation/create-automation.tsx @@ -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", 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/hooks/use-automation-eligible-models.ts b/surfsense_web/hooks/use-automation-eligible-models.ts index e74994221..e75235c56 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,40 @@ 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 toOption = (connection: ConnectionRead, model: ModelRead, isBYOK: boolean) => ({ + id: model.id, + name: model.display_name || model.model_id, + modelName: model.model_id, + provider: connection.native_provider || connection.protocol, + 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 && + Boolean(model.capabilities?.[capability]) && + String(model.billing_tier ?? "").toLowerCase() === "premium" + ) + .map((model) => toOption(connection, model, false)) + ); + + const byokOptions: EligibleModelOption[] = (byok ?? []).flatMap((connection) => + connection.models + .filter((model) => model.enabled && Boolean(model.capabilities?.[capability])) + .map((model) => toOption(connection, model, true)) + ); const options = [...premiumGlobals, ...byokOptions]; const byId = new Map(options.map((o) => [o.id, o])); @@ -105,46 +90,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/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), }; }