From 48dbdcd835ac29ce0ef006e0b88e9685a5841383 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 30 Jul 2026 22:59:27 +0200 Subject: [PATCH] feat: redesign assistant management --- apps/docs/docs/management/assistant.mdx | 24 +- .../assistant-configuration-state.spec.ts | 65 ++ .../assistant-configuration-state.ts | 23 + .../assistant-configuration.module.css | 174 ++++ .../_components/assistant-configuration.tsx | 803 +++++++++++++----- e2e/assistant-management.spec.ts | 98 +++ packages/translation/src/lang/en.json | 55 +- 7 files changed, 999 insertions(+), 243 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration-state.spec.ts create mode 100644 apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration-state.ts create mode 100644 apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css create mode 100644 e2e/assistant-management.spec.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 6effaaa14..2e070a3f1 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -30,14 +30,17 @@ Homarr includes presets for OpenRouter, OpenAI, Anthropic Claude, Google Gemini, DeepSeek, Together AI, Ollama, and LM Studio. Provider icons make the presets easy to identify. A custom preset connects any other service that implements the OpenAI Chat Completions API. -1. Choose a provider preset or **Custom endpoint**. -2. Confirm the API base URL. It usually ends in `/v1`. -3. Add an API key if the service requires bearer authentication. -4. Add any provider-specific authentication or routing headers. -5. Save the provider connection. -6. Refresh the model list and select a discovered model. Homarr displays the friendly model name but +The management page presents setup as three compact sections: provider and endpoint, credentials, then +model and availability. A status summary at the top shows whether the connection is ready, whether a key +is protected, which model is selected, and whether users can open the assistant. + +1. Choose a provider preset or **Custom endpoint**, then confirm the API base URL. It usually ends in `/v1`. +2. Add an API key if the service requires bearer authentication. Additional authentication or routing + headers are available in the collapsed advanced section. +3. Save the provider connection. +4. Refresh the model list and select a discovered model. Homarr displays the friendly model name but stores and sends the provider's exact model ID. -7. Enable the assistant and save the configuration. +5. Enable the assistant and save the configuration. The model discovery path defaults to `/models` and is appended to the API base URL. Leave it blank when the service does not expose model discovery. A manual model ID can still be used when discovery is @@ -48,6 +51,13 @@ the server for discovery and model requests, and they are never returned to the provider or base URL clears existing credentials unless replacements are submitted with the change. This prevents credentials for one service from being sent to another endpoint. +When a key is already stored, the page shows its protected state instead of an ambiguous empty password +field. **Replace key** starts a two-step flow: enter the new key, then review the provider, destination, +and impact before confirming. The existing key remains active until confirmation, and its value cannot be +restored after replacement. Removing only the API key also requires explicit confirmation. **Remove all +credentials** uses a separate destructive confirmation because it also clears the selected model and +disables the assistant. + :::info Container networking For a containerized Homarr installation, `localhost` refers to the Homarr container. Use a resolvable diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration-state.spec.ts b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration-state.spec.ts new file mode 100644 index 000000000..56b78c1f8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration-state.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { getAssistantConnectionState } from "./assistant-configuration-state"; + +describe("getAssistantConnectionState", () => { + it("marks a saved required-key provider as ready", () => { + expect( + getAssistantConnectionState({ + connectionConfigured: true, + destinationChanged: false, + providerRequiresApiKey: true, + apiKeyConfigured: true, + }), + ).toEqual({ + hasStoredApiKey: true, + connectionPending: false, + connectionReady: true, + }); + }); + + it("requires the endpoint to be saved before exposing model controls", () => { + expect( + getAssistantConnectionState({ + connectionConfigured: true, + destinationChanged: true, + providerRequiresApiKey: true, + apiKeyConfigured: true, + }), + ).toEqual({ + hasStoredApiKey: false, + connectionPending: true, + connectionReady: false, + }); + }); + + it("does not call a required-key connection ready after its key is removed", () => { + expect( + getAssistantConnectionState({ + connectionConfigured: true, + destinationChanged: false, + providerRequiresApiKey: true, + apiKeyConfigured: false, + }), + ).toEqual({ + hasStoredApiKey: false, + connectionPending: true, + connectionReady: false, + }); + }); + + it("allows a saved local endpoint without an API key", () => { + expect( + getAssistantConnectionState({ + connectionConfigured: true, + destinationChanged: false, + providerRequiresApiKey: false, + apiKeyConfigured: false, + }), + ).toEqual({ + hasStoredApiKey: false, + connectionPending: false, + connectionReady: true, + }); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration-state.ts b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration-state.ts new file mode 100644 index 000000000..572410bd0 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration-state.ts @@ -0,0 +1,23 @@ +interface AssistantConnectionStateInput { + connectionConfigured: boolean; + destinationChanged: boolean; + providerRequiresApiKey: boolean; + apiKeyConfigured: boolean; +} + +export const getAssistantConnectionState = ({ + connectionConfigured, + destinationChanged, + providerRequiresApiKey, + apiKeyConfigured, +}: AssistantConnectionStateInput) => { + const hasStoredApiKey = apiKeyConfigured && !destinationChanged; + const credentialsMissing = providerRequiresApiKey && !hasStoredApiKey; + const connectionPending = !connectionConfigured || destinationChanged || credentialsMissing; + + return { + hasStoredApiKey, + connectionPending, + connectionReady: !connectionPending, + }; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css new file mode 100644 index 000000000..48f4ada11 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css @@ -0,0 +1,174 @@ +.configuration { + width: 100%; + max-width: 74rem; +} + +.summary { + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); +} + +.summaryProvider { + min-width: 0; +} + +.summaryProviderText { + min-width: 0; +} + +.summaryEndpoint { + max-width: min(46rem, 70vw); +} + +.section { + overflow: hidden; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + background-color: var(--mantine-color-body); +} + +.sectionHeader { + padding: var(--mantine-spacing-md); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); +} + +.sectionHeading { + min-width: 0; +} + +.stepNumber { + width: 1.75rem; + height: 1.75rem; + flex: 0 0 1.75rem; + border-radius: 50%; + background-color: var(--mantine-primary-color-light); + color: var(--mantine-primary-color-light-color); + font-size: var(--mantine-font-size-sm); + font-weight: 700; + line-height: 1.75rem; + text-align: center; +} + +.sectionBody { + padding: var(--mantine-spacing-md); +} + +.sectionActions { + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + border-top: 1px solid var(--mantine-color-default-border); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); +} + +.credentialSummary { + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + border-radius: var(--mantine-radius-sm); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); +} + +.credentialSummaryContent { + min-width: 0; +} + +.credentialFlow { + padding: var(--mantine-spacing-md); + border: 1px solid light-dark(var(--mantine-color-yellow-4), var(--mantine-color-yellow-8)); + border-radius: var(--mantine-radius-md); + background-color: light-dark( + var(--mantine-color-yellow-0), + color-mix(in srgb, var(--mantine-color-yellow-9) 18%, transparent) + ); +} + +.credentialStepper :global(.mantine-Stepper-stepLabel) { + font-size: var(--mantine-font-size-sm); +} + +.credentialStepper :global(.mantine-Stepper-content) { + padding-top: var(--mantine-spacing-md); +} + +.advancedControl { + min-height: 3.25rem; +} + +.selectedModel { + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + border-radius: var(--mantine-radius-sm); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); +} + +.selectedModelText { + min-width: 0; +} + +.selectedModelBadges { + flex-shrink: 0; +} + +.enableRow { + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + border-radius: var(--mantine-radius-sm); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); +} + +.securityNote { + max-width: 66ch; +} + +.flowActions { + display: flex; + justify-content: flex-end; + gap: var(--mantine-spacing-xs); +} + +@media (max-width: 48em) { + .summaryEndpoint { + max-width: 78vw; + } + + .summaryActions, + .credentialActions, + .sectionActions > :global(.mantine-Group-root) { + width: 100%; + } + + .summaryActions > *, + .credentialActions > *, + .flowActions > *, + .sectionActions :global(.mantine-Button-root) { + flex: 1 1 auto; + } + + .credentialSummary { + align-items: stretch; + } + + .flowActions { + display: grid; + grid-template-columns: 1fr; + } + + .credentialStepper :global(.mantine-Stepper-stepLabel) { + display: none; + } + + .selectedModel { + align-items: stretch; + flex-direction: column; + } + + .selectedModelBadges { + justify-content: flex-start; + flex-shrink: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .configuration *, + .configuration *::before, + .configuration *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx index bfb3841f7..2e91251e9 100644 --- a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx @@ -1,30 +1,42 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { + Accordion, ActionIcon, Alert, Badge, + Box, Button, - Card, Divider, Group, Image, LoadingOverlay, + Modal, + Paper, PasswordInput, Select, SimpleGrid, + Skeleton, Stack, + Stepper, Switch, Text, TextInput, + ThemeIcon, Tooltip, } from "@mantine/core"; import { IconAlertTriangle, + IconArrowLeft, + IconCheck, + IconCircleCheck, IconDatabaseSearch, IconKey, + IconLock, IconPlus, + IconShieldCheck, IconShieldLock, IconTool, IconTrash, @@ -42,7 +54,11 @@ import type { AssistantProvider, AssistantProviderCategory } from "@homarr/defin import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; +import classes from "./assistant-configuration.module.css"; +import { getAssistantConnectionState } from "./assistant-configuration-state"; + type HeaderEntry = { id: number; name: string; value: string }; +type CredentialFlow = "idle" | "replace" | "remove"; const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProvider; size?: number }) => { const iconUrl = assistantProviderPresets[providerId].iconUrl; @@ -53,6 +69,64 @@ const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProvider ); }; +interface ConfigurationSectionProps { + number: number; + title: string; + description: string; + status?: ReactNode; + actions?: ReactNode; + children: ReactNode; +} + +const ConfigurationSection = ({ number, title, description, status, actions, children }: ConfigurationSectionProps) => ( +
+ + + + {number} + + + {title} + + {description} + + + + {status} + + + {children} + + {actions && {actions}} +
+); + +const ConfigurationSkeleton = () => ( + + + + + + + + + + + + + + {[1, 2, 3].map((number) => ( + + + + + + + + ))} + +); + export const AssistantConfiguration = () => { const t = useScopedI18n("management.page.settings.section.assistant"); const utils = clientApi.useUtils(); @@ -61,12 +135,15 @@ export const AssistantConfiguration = () => { const [baseUrl, setBaseUrl] = useState(assistantProviderPresets.openrouter.baseUrl); const [modelDiscoveryPath, setModelDiscoveryPath] = useState("/models"); const [apiKey, setApiKey] = useState(""); - const [clearApiKey, setClearApiKey] = useState(false); + const [credentialFlow, setCredentialFlow] = useState("idle"); + const [credentialStep, setCredentialStep] = useState(0); const [headers, setHeaders] = useState([]); const [clearHeaders, setClearHeaders] = useState(false); const [nextHeaderId, setNextHeaderId] = useState(1); const [modelId, setModelId] = useState(""); const [enabled, setEnabled] = useState(false); + const [clearCredentialsOpened, setClearCredentialsOpened] = useState(false); + const replacementKeyInputRef = useRef(null); useEffect(() => { setProvider(configuration?.provider ?? "openrouter"); @@ -74,6 +151,9 @@ export const AssistantConfiguration = () => { setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? ""); setModelId(configuration?.modelId ?? ""); setEnabled(configuration?.enabled ?? false); + setApiKey(""); + setCredentialFlow("idle"); + setCredentialStep(0); }, [configuration]); const normalizedBaseUrl = baseUrl.trim().replace(/\/$/, ""); @@ -129,20 +209,31 @@ export const AssistantConfiguration = () => { } }, [modelId, models]); + useEffect(() => { + if (credentialFlow !== "replace" || credentialStep !== 0) return; + const animationFrame = requestAnimationFrame(() => replacementKeyInputRef.current?.focus()); + return () => cancelAnimationFrame(animationFrame); + }, [credentialFlow, credentialStep]); + const preset = assistantProviderPresets[provider]; const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0); - const hasEffectiveApiKey = - apiKey.trim().length > 0 || (!destinationChanged && configuration?.apiKeyConfigured === true && !clearApiKey); + const { hasStoredApiKey, connectionPending, connectionReady } = getAssistantConnectionState({ + connectionConfigured: configuration?.connectionConfigured === true, + destinationChanged, + providerRequiresApiKey: preset.requiresApiKey, + apiKeyConfigured: configuration?.apiKeyConfigured === true, + }); + const hasEffectiveApiKey = apiKey.trim().length > 0 || hasStoredApiKey; const connectionValid = baseUrl.trim().length > 0 && headerValuesValid && (!preset.requiresApiKey || hasEffectiveApiKey); - const connectionPending = configuration?.connectionConfigured !== true || connectionChanged; const modelControlsDisabled = connectionPending || isDiscovering; const canSaveConfiguration = !modelControlsDisabled && modelId.trim().length > 0; const updateConnection = clientApi.assistant.updateConnection.useMutation({ onSuccess: async ({ credentialsClearedForDestinationChange }) => { setApiKey(""); - setClearApiKey(false); + setCredentialFlow("idle"); + setCredentialStep(0); setHeaders([]); setClearHeaders(false); await Promise.all([ @@ -176,6 +267,7 @@ export const AssistantConfiguration = () => { const clearCredentials = clientApi.assistant.clearCredentials.useMutation({ onSuccess: async () => { + setClearCredentialsOpened(false); setApiKey(""); setHeaders([]); setModelId(""); @@ -193,11 +285,16 @@ export const AssistantConfiguration = () => { onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }), }); - const pending = isLoading || updateConnection.isPending || saveConfiguration.isPending || clearCredentials.isPending; + const pending = updateConnection.isPending || saveConfiguration.isPending || clearCredentials.isPending; + + const resetCredentialFlow = () => { + setApiKey(""); + setCredentialFlow("idle"); + setCredentialStep(0); + }; const clearDraftConnectionState = () => { - setApiKey(""); - setClearApiKey(false); + resetCredentialFlow(); setHeaders([]); setClearHeaders(false); setModelId(""); @@ -229,13 +326,6 @@ export const AssistantConfiguration = () => { setModelDiscoveryPath(value); }; - const changeApiKeyRemoval = (remove: boolean) => { - if (remove) { - setApiKey(""); - } - setClearApiKey(remove); - }; - const addHeader = () => { setHeaders((current) => [...current, { id: nextHeaderId, name: "", value: "" }]); setNextHeaderId((current) => current + 1); @@ -246,14 +336,14 @@ export const AssistantConfiguration = () => { setHeaders((current) => current.map((header) => (header.id === id ? { ...header, [field]: value } : header))); }; - const saveConnection = () => { + const saveConnection = (clearApiKey = false) => { const customHeaders = headers.length > 0 ? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value])) : undefined; updateConnection.mutate({ provider, baseUrl, modelDiscoveryPath: modelDiscoveryPath.trim() || null, - apiKey: apiKey.trim() || undefined, + apiKey: clearApiKey ? undefined : apiKey.trim() || undefined, clearApiKey, customHeaders, clearCustomHeaders: clearHeaders, @@ -265,27 +355,70 @@ export const AssistantConfiguration = () => { saveConfiguration.mutate({ enabled, modelId: modelId.trim() }); }; + if (isLoading) { + return ; + } + return ( - - - - - } color="blue" title={t("security.title")}> - {t("security.description")} - - - -
- {t("connection.title")} - - {t("connection.description")} - -
- - {configuration?.connectionConfigured ? t("connection.saved") : t("connection.notSaved")} - + <> + + + + + + + + + + + {t(`provider.options.${provider}.label`)} + + {connectionReady + ? t("overview.connectionReady") + : connectionChanged + ? t("overview.unsavedChanges") + : t("overview.setupRequired")} + + + + {configuration?.modelId ?? t("overview.noModel")} ยท {normalizedBaseUrl} + + + + + {hasStoredApiKey && ( + }> + {t("overview.keyProtected")} + + )} + + {configuration?.enabled ? t("overview.available") : t("overview.disabled")} + + + + + + + {t("security.description")} + + + + + {t(`provider.options.${provider}.label`)} + + } + > setModelId(value ?? "")} - data={modelControlsDisabled ? [] : modelOptions} - disabled={modelControlsDisabled} - placeholder={t("model.placeholder")} - description={t("model.discovered", { count: models?.length ?? 0 })} - searchable - allowDeselect={false} - limit={100} - /> - ) : ( - setModelId(event.currentTarget.value)} - disabled={modelControlsDisabled} - placeholder={t("model.placeholder")} - description={ - connectionPending - ? t("model.saveConnectionFirst") - : isDiscovering - ? t("model.discovering") - : discoveryError || models?.length === 0 - ? t("model.manualFallback") - : t("model.manual") - } - /> - )} - {discoveryError && !connectionPending && ( - } title={t("model.discoveryFailed")}> - {discoveryError.message} - - )} - {selectedModel && ( - - -
- {selectedModel.name} - {selectedModel.description && ( - - {selectedModel.description} - - )} -
- - {selectedModel.contextLength && ( - - {t("model.context", { count: selectedModel.contextLength.toLocaleString() })} - - )} - } - > - {selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")} - - -
-
- )} -
+ } + > + + + {connectionPending ? t("model.saveConnectionFirst") : t("model.chooseModel")} + + + + {hasDiscoveredModels ? ( +