diff --git a/apps/web/src/domains/settings/ai/ai-types.ts b/apps/web/src/domains/settings/ai/ai-types.ts index f898894a69a..4f0c0ce9038 100644 --- a/apps/web/src/domains/settings/ai/ai-types.ts +++ b/apps/web/src/domains/settings/ai/ai-types.ts @@ -1,5 +1,14 @@ import { PROVIDER_DISPLAY_NAMES } from "@/assistant/llm-model-catalog"; -import type { CallSiteOverrideDraft } from "@/domains/settings/ai/call-site-overrides-modal"; + +// --------------------------------------------------------------------------- +// Call-site override draft +// --------------------------------------------------------------------------- + +export interface CallSiteOverrideDraft { + profile?: string | null; + provider?: string | null; + model?: string | null; +} // --------------------------------------------------------------------------- // Service mode @@ -37,6 +46,8 @@ export interface ProfileEntry { contextWindow?: { maxInputTokens?: number }; } +export type ProfileWithName = { name: string } & ProfileEntry; + export interface DaemonConfig { services?: { "web-search"?: { mode?: string; provider?: string }; @@ -59,8 +70,6 @@ export interface DaemonConfigReconciliation { imageGenMode?: ServiceMode; } -export type { CallSiteOverrideDraft }; - export interface InferenceTokenBudgetState { maxOutputTokens: number; maxOutputTouched: boolean; diff --git a/apps/web/src/domains/settings/ai/ai-utils.ts b/apps/web/src/domains/settings/ai/ai-utils.ts index 6f088387c71..69aa1bd21a8 100644 --- a/apps/web/src/domains/settings/ai/ai-utils.ts +++ b/apps/web/src/domains/settings/ai/ai-utils.ts @@ -7,10 +7,33 @@ import type { DaemonConfig, DaemonConfigReconciliation, InferenceTokenBudgetState, + ProfileEntry, + ProfileWithName, ServiceMode, } from "@/domains/settings/ai/ai-types"; import { TOKEN_SLIDER_MIN_TOKENS } from "@/domains/settings/ai/ai-types"; +/** + * Merges `profileOrder` with `profiles` to produce a stable ordered list. + * + * Entries appear in `profileOrder` sequence first, followed by any extras + * present in `profiles` but missing from `profileOrder` (e.g. newly seeded + * profiles that haven't been reordered yet). + */ +export function buildOrderedProfiles( + profiles: Record, + profileOrder: string[], +): ProfileWithName[] { + const ordered = profileOrder + .filter((name) => name in profiles) + .map((name) => ({ name, ...profiles[name]! })); + const inOrder = new Set(profileOrder); + const extras = Object.entries(profiles) + .filter(([name]) => !inOrder.has(name)) + .map(([name, entry]) => ({ name, ...entry })); + return [...ordered, ...extras]; +} + export function assertProvisionSuccess(result: unknown): void { if ( result && diff --git a/apps/web/src/domains/settings/ai/call-site-overrides-modal.tsx b/apps/web/src/domains/settings/ai/call-site-overrides-modal.tsx index 566d86f7ad2..cba39ae389f 100644 --- a/apps/web/src/domains/settings/ai/call-site-overrides-modal.tsx +++ b/apps/web/src/domains/settings/ai/call-site-overrides-modal.tsx @@ -20,6 +20,7 @@ import { } from "@/assistant/llm-model-catalog"; import { + type CallSiteOverrideDraft, INFERENCE_PROVIDER_DISPLAY_NAMES, INFERENCE_PROVIDERS, } from "@/domains/settings/ai/ai-types"; @@ -28,7 +29,6 @@ import { visibleProfilesForPicker, gateAutoProfile, selectSeedProfileForOverride, - type ProfilePickerEntry, } from "@/domains/settings/ai/profile-pickers"; import { useDaemonConfig, @@ -39,7 +39,7 @@ import { // Sentinel value for the "Custom" profile picker option // --------------------------------------------------------------------------- -export const CUSTOM_SENTINEL = "__custom__"; +const CUSTOM_SENTINEL = "__custom__"; // --------------------------------------------------------------------------- // Types @@ -49,12 +49,6 @@ type CallSiteCatalog = ConfigLlmCallsitesGetResponse; type CallSiteEntry = CallSiteCatalog["callSites"][number]; type CallSiteDomain = CallSiteCatalog["domains"][number]; -export interface CallSiteOverrideDraft { - profile?: string | null; - provider?: string | null; - model?: string | null; -} - export interface CallSiteOverridesModalProps { isOpen: boolean; onClose: () => void; @@ -131,8 +125,7 @@ function CallSiteOverridesModalInner({ onSavingChange, }: InnerProps) { const { - profiles, - profileOrder, + orderedProfiles, callSites: persistedOverrides, config: daemonConfig, } = useDaemonConfig(); @@ -176,18 +169,6 @@ function CallSiteOverridesModalInner({ return all.filter((cs) => cs.id !== "analyzeConversation"); }, [catalog, analyzeConversationEnabled]); - // Build ordered profile list from cache slices - const orderedProfiles: ReadonlyArray = useMemo(() => { - const ordered = profileOrder - .filter((name) => name in profiles) - .map((name) => ({ name, ...profiles[name]! })); - const inOrder = new Set(profileOrder); - const extras = Object.entries(profiles) - .filter(([name]) => !inOrder.has(name)) - .map(([name, entry]) => ({ name, ...entry })); - return [...ordered, ...extras]; - }, [profiles, profileOrder]); - const daemonConfigLoaded = !!daemonConfig; // Seed drafts once per open, but defer until BOTH the catalog and the daemon diff --git a/apps/web/src/domains/settings/ai/language-model-card.tsx b/apps/web/src/domains/settings/ai/language-model-card.tsx index d899f9c56d6..a471a42cba3 100644 --- a/apps/web/src/domains/settings/ai/language-model-card.tsx +++ b/apps/web/src/domains/settings/ai/language-model-card.tsx @@ -22,8 +22,7 @@ import { export function LanguageModelCard() { const { assistantId, - profiles, - profileOrder, + orderedProfiles, activeProfile, callSites, patchDaemonConfig, @@ -55,18 +54,6 @@ export function LanguageModelCard() { const [overridesOpen, setOverridesOpen] = useState(false); const [manageProvidersOpen, setManageProvidersOpen] = useState(false); - // Derived state — computed from query cache slices - const orderedProfiles = useMemo(() => { - const ordered = profileOrder - .filter((name) => name in profiles) - .map((name) => ({ name, ...profiles[name]! })); - const inOrder = new Set(profileOrder); - const extras = Object.entries(profiles) - .filter(([name]) => !inOrder.has(name)) - .map(([name, entry]) => ({ name, ...entry })); - return [...ordered, ...extras]; - }, [profiles, profileOrder]); - const queryComplexityRoutingEnabled = useAssistantFeatureFlagStore.use.queryComplexityRouting(); diff --git a/apps/web/src/domains/settings/ai/manage-profiles-modal.tsx b/apps/web/src/domains/settings/ai/manage-profiles-modal.tsx index bd989c4278a..06a2f6e346b 100644 --- a/apps/web/src/domains/settings/ai/manage-profiles-modal.tsx +++ b/apps/web/src/domains/settings/ai/manage-profiles-modal.tsx @@ -10,7 +10,7 @@ import { Tag } from "@vellum/design-library/components/tag"; import { Typography } from "@vellum/design-library/components/typography"; import { useAssistantFeatureFlagStore } from "@/stores/assistant-feature-flag-store"; -import type { DaemonConfig, ProfileEntry } from "@/domains/settings/ai/ai-types"; +import type { DaemonConfig, ProfileEntry, ProfileWithName } from "@/domains/settings/ai/ai-types"; import { ProfileEditorModal } from "@/domains/settings/ai/profile-editor-modal"; import { AUTO_PROFILE_NAME, @@ -25,24 +25,6 @@ import { assistantDaemonConfigQueryKey } from "@/lib/sync/query-tags"; // Types // --------------------------------------------------------------------------- -export interface Profile { - name: string; - source?: "managed" | "user"; - status?: "active" | "disabled"; - label?: string | null; - description?: string | null; - provider?: string | null; - provider_connection?: string | null; - model?: string | null; - maxTokens?: number; - effort?: string; - speed?: string; - verbosity?: string; - temperature?: number | null; - thinking?: { enabled?: boolean; streamThinking?: boolean; level?: string }; - contextWindow?: { maxInputTokens?: number }; -} - interface BlockedDeleteState { name: string; label: string; @@ -56,30 +38,6 @@ interface ManageProfilesModalProps { onClose: () => void; } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function profileEntryToProfile(name: string, entry: ProfileEntry): Profile { - return { - name, - source: entry.source, - status: entry.status ?? "active", - label: entry.label ?? undefined, - description: entry.description ?? undefined, - provider: entry.provider ?? undefined, - provider_connection: entry.provider_connection ?? undefined, - model: entry.model ?? undefined, - maxTokens: entry.maxTokens, - effort: entry.effort, - speed: entry.speed, - verbosity: entry.verbosity, - temperature: entry.temperature, - thinking: entry.thinking, - contextWindow: entry.contextWindow, - }; -} - // --------------------------------------------------------------------------- // ManageProfilesModal // --------------------------------------------------------------------------- @@ -92,6 +50,7 @@ export function ManageProfilesModal({ const { profiles, profileOrder, + orderedProfiles, activeProfile, callSites, } = useDaemonConfig(); @@ -99,7 +58,7 @@ export function ManageProfilesModal({ const openAICompatibleEndpoints = useAssistantFeatureFlagStore.use.openAICompatibleEndpoints(); const [editorOpen, setEditorOpen] = useState(false); - const [editingProfile, setEditingProfile] = useState(null); + const [editingProfile, setEditingProfile] = useState(null); // Provider connections — shared TanStack Query cache with ManageProvidersModal. const { data: connectionsData } = useQuery({ @@ -186,6 +145,7 @@ export function ManageProfilesModal({ ; profileOrder: string[]; + orderedProfiles: ProfileWithName[]; activeProfile: string | null; assistantId: string; callSiteOverrides: Record; onClose: () => void; - onEditClick: (profile: Profile) => void; + onEditClick: (profile: ProfileWithName) => void; onNewClick: () => void; } function ManageProfilesModalInner({ profiles, profileOrder, + orderedProfiles, activeProfile, assistantId, callSiteOverrides, @@ -276,22 +238,12 @@ function ManageProfilesModalInner({ const queryComplexityRouting = useAssistantFeatureFlagStore.use.queryComplexityRouting(); // Build ordered profile list - const allOrderedProfiles: Profile[] = useMemo(() => { - const ordered = profileOrder - .filter((name) => name in profiles) - .map((name) => profileEntryToProfile(name, profiles[name]!)); - const inOrder = new Set(profileOrder); - const extras = Object.entries(profiles) - .filter(([name]) => !inOrder.has(name)) - .map(([name, entry]) => profileEntryToProfile(name, entry)); - return gateAutoProfile( - [...ordered, ...extras], - queryComplexityRouting, - ); - }, [profiles, profileOrder, queryComplexityRouting]); + const allOrderedProfiles: ProfileWithName[] = useMemo(() => { + return gateAutoProfile(orderedProfiles, queryComplexityRouting); + }, [orderedProfiles, queryComplexityRouting]); async function handleStatusToggle( - profile: Profile, + profile: ProfileWithName, active: boolean, ): Promise { if (togglingNames.has(profile.name)) return false; @@ -745,7 +697,7 @@ function BlockedDeleteModal({ onConfirm, }: { blocked: BlockedDeleteState | null; - availableReplacements: Profile[]; + availableReplacements: ProfileWithName[]; replacement: string; onReplacementChange: (value: string) => void; error: string | null; diff --git a/apps/web/src/domains/settings/ai/profile-editor-modal.tsx b/apps/web/src/domains/settings/ai/profile-editor-modal.tsx index 4bdff206fda..87cb28484aa 100644 --- a/apps/web/src/domains/settings/ai/profile-editor-modal.tsx +++ b/apps/web/src/domains/settings/ai/profile-editor-modal.tsx @@ -14,8 +14,7 @@ import { PROVIDER_DISPLAY_NAMES as INFERENCE_PROVIDER_DISPLAY_NAMES, } from "@/assistant/llm-model-catalog"; -import type { ProfileEntry } from "@/domains/settings/ai/ai-types"; -import { type Profile } from "@/domains/settings/ai/manage-profiles-modal"; +import type { ProfileEntry, ProfileWithName } from "@/domains/settings/ai/ai-types"; import { ProfileAdvancedParams, THINKING_LEVEL_INHERIT, @@ -50,7 +49,7 @@ export interface ProfileEditorModalProps { isOpen: boolean; mode: "create" | "edit" | "view"; profileName?: string; - initialValues?: Profile; + initialValues?: ProfileWithName; existingNames: string[]; /** * Provider connections, supplied by the parent (`ManageProfilesModal`). @@ -133,7 +132,7 @@ export function ProfileEditorModal({ interface ProfileEditorModalInnerProps { mode: "create" | "edit" | "view"; profileName?: string; - initialValues?: Profile; + initialValues?: ProfileWithName; existingNames: string[]; // See `ProfileEditorModalProps.connections` for nil-vs-empty semantics. connections: ProviderConnection[] | undefined; diff --git a/apps/web/src/domains/settings/ai/use-daemon-config.ts b/apps/web/src/domains/settings/ai/use-daemon-config.ts index 1bb090c06e1..6739a041d7c 100644 --- a/apps/web/src/domains/settings/ai/use-daemon-config.ts +++ b/apps/web/src/domains/settings/ai/use-daemon-config.ts @@ -24,17 +24,11 @@ import { toast } from "@vellum/design-library/components/toast"; import { assistantsListOptions, } from "@/generated/api/@tanstack/react-query.gen"; -import { - configGet, - configPatch, - secretsPost, - modelImagegenPut, -} from "@/generated/daemon/sdk.gen"; +import { configGet, configPatch, secretsPost, modelImagegenPut } from "@/generated/daemon/sdk.gen"; import { captureError } from "@/lib/sentry/capture-error"; import { assistantDaemonConfigQueryKey } from "@/lib/sync/query-tags"; -import { assertProvisionSuccess } from "@/domains/settings/ai/ai-utils"; -import type { DaemonConfig, ProfileEntry } from "@/domains/settings/ai/ai-types"; -import type { CallSiteOverrideDraft } from "@/domains/settings/ai/call-site-overrides-modal"; +import { assertProvisionSuccess, buildOrderedProfiles } from "@/domains/settings/ai/ai-utils"; +import type { CallSiteOverrideDraft, DaemonConfig, ProfileEntry } from "@/domains/settings/ai/ai-types"; /** * Hook providing the daemon config query and common mutation helpers. @@ -89,6 +83,10 @@ export function useDaemonConfig() { () => config?.llm?.callSites ?? {}, [config?.llm?.callSites], ); + const orderedProfiles = useMemo( + () => buildOrderedProfiles(profiles, profileOrder), + [profiles, profileOrder], + ); const invalidateConfig = useCallback(() => { void queryClient.invalidateQueries({ @@ -127,25 +125,6 @@ export function useDaemonConfig() { [resolveAssistantId], ); - const patchConfigMutation = useMutation({ - mutationFn: async (vars: { - assistantId: string; - partial: Record; - }) => { - const { data } = await configPatch({ - path: { assistant_id: vars.assistantId }, - body: vars.partial, - throwOnError: true, - }); - return data; - }, - onSettled: () => { - void queryClient.invalidateQueries({ - queryKey: assistantDaemonConfigQueryKey(assistantId), - }); - }, - }); - const patchDaemonConfig = useCallback( async (partial: Record): Promise => { const resolvedId = await resolveAssistantId(); @@ -154,35 +133,22 @@ export function useDaemonConfig() { throw new Error("No assistant found"); } try { - await patchConfigMutation.mutateAsync({ - assistantId: resolvedId, - partial, + await configPatch({ + path: { assistant_id: resolvedId }, + body: partial, + throwOnError: true, }); } catch (error) { toast.error("Failed to update assistant configuration. Please try again."); captureError(error, { context: "patch_daemon_config" }); throw error; + } finally { + invalidateConfig(); } }, - [patchConfigMutation, resolveAssistantId], + [resolveAssistantId, invalidateConfig], ); - const putImageGenModelMutation = useMutation({ - mutationFn: async (vars: { assistantId: string; modelId: string }) => { - const { data } = await modelImagegenPut({ - path: { assistant_id: vars.assistantId }, - body: { modelId: vars.modelId }, - throwOnError: true, - }); - return data; - }, - onSettled: () => { - void queryClient.invalidateQueries({ - queryKey: assistantDaemonConfigQueryKey(assistantId), - }); - }, - }); - const setImageGenModelOnDaemon = useCallback( async (modelId: string): Promise => { const resolvedId = await resolveAssistantId(); @@ -191,17 +157,20 @@ export function useDaemonConfig() { throw new Error("No assistant found"); } try { - await putImageGenModelMutation.mutateAsync({ - assistantId: resolvedId, - modelId, + await modelImagegenPut({ + path: { assistant_id: resolvedId }, + body: { modelId }, + throwOnError: true, }); } catch (error) { toast.error("Failed to update image generation model. Please try again."); captureError(error, { context: "set_image_gen_model" }); throw error; + } finally { + invalidateConfig(); } }, - [putImageGenModelMutation, resolveAssistantId], + [resolveAssistantId, invalidateConfig], ); return { @@ -211,6 +180,7 @@ export function useDaemonConfig() { configQuery, profiles, profileOrder, + orderedProfiles, activeProfile, callSites, invalidateConfig,