feat: redesign assistant management

This commit is contained in:
ajnart committed 2026-07-30 22:59:27 +02:00
1 parent 5fdf408378
commit 48dbdcd835
7 files changed
+999 -243

No files matched your search

+17 -7
View File
@@ -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
@@ -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,
});
});
});
@@ -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,
};
};
@@ -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;
}
}
@@ -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) => (
<section className={classes.section}>
<Group className={classes.sectionHeader} justify="space-between" align="flex-start">
<Group className={classes.sectionHeading} gap="sm" align="flex-start" wrap="nowrap">
<Box className={classes.stepNumber} aria-hidden>
{number}
</Box>
<Box>
<Text fw={650}>{title}</Text>
<Text size="sm" c="dimmed" maw="66ch">
{description}
</Text>
</Box>
</Group>
{status}
</Group>
<Stack className={classes.sectionBody} gap="md">
{children}
</Stack>
{actions && <Box className={classes.sectionActions}>{actions}</Box>}
</section>
);
const ConfigurationSkeleton = () => (
<Stack className={classes.configuration} gap="md" aria-label="Loading assistant configuration">
<Paper withBorder p="md">
<Group justify="space-between">
<Group>
<Skeleton circle h={36} />
<Stack gap={5}>
<Skeleton h={14} w={180} />
<Skeleton h={10} w={280} />
</Stack>
</Group>
<Skeleton h={24} w={110} radius="xl" />
</Group>
</Paper>
{[1, 2, 3].map((number) => (
<Paper key={number} withBorder p="md">
<Stack gap="sm">
<Skeleton h={18} w={220} />
<Skeleton h={12} w="65%" />
<Skeleton h={42} />
</Stack>
</Paper>
))}
</Stack>
);
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<string>(assistantProviderPresets.openrouter.baseUrl);
const [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
const [apiKey, setApiKey] = useState("");
const [clearApiKey, setClearApiKey] = useState(false);
const [credentialFlow, setCredentialFlow] = useState<CredentialFlow>("idle");
const [credentialStep, setCredentialStep] = useState(0);
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
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<HTMLInputElement>(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 <ConfigurationSkeleton />;
}
return (
<Stack>
<Card pos="relative" withBorder>
<LoadingOverlay visible={pending} />
<Stack gap="lg">
<Alert icon={<IconShieldLock size={18} />} color="blue" title={t("security.title")}>
{t("security.description")}
</Alert>
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>{t("connection.title")}</Text>
<Text size="sm" c="dimmed">
{t("connection.description")}
</Text>
</div>
<Badge variant="light" color={configuration?.connectionConfigured ? "green" : "gray"}>
{configuration?.connectionConfigured ? t("connection.saved") : t("connection.notSaved")}
</Badge>
<>
<Stack className={classes.configuration} gap="md">
<Paper className={classes.summary} withBorder p="md" radius="md" pos="relative">
<LoadingOverlay visible={pending} />
<Group justify="space-between" align="center">
<Group className={classes.summaryProvider} gap="sm" wrap="nowrap">
<ThemeIcon variant="default" size="lg" radius="md">
<ProviderIcon providerId={provider} size={22} />
</ThemeIcon>
<Box className={classes.summaryProviderText}>
<Group gap="xs">
<Text fw={650}>{t(`provider.options.${provider}.label`)}</Text>
<Badge
size="sm"
variant="light"
color={connectionReady ? "green" : connectionChanged ? "yellow" : "gray"}
>
{connectionReady
? t("overview.connectionReady")
: connectionChanged
? t("overview.unsavedChanges")
: t("overview.setupRequired")}
</Badge>
</Group>
<Text className={classes.summaryEndpoint} size="sm" c="dimmed" truncate>
{configuration?.modelId ?? t("overview.noModel")} · {normalizedBaseUrl}
</Text>
</Box>
</Group>
<Group className={classes.summaryActions} gap="xs">
{hasStoredApiKey && (
<Badge variant="light" color="teal" leftSection={<IconLock size={12} />}>
{t("overview.keyProtected")}
</Badge>
)}
<Badge variant="filled" color={configuration?.enabled ? "green" : "gray"}>
{configuration?.enabled ? t("overview.available") : t("overview.disabled")}
</Badge>
</Group>
</Group>
</Paper>
<Group gap="xs" align="flex-start" wrap="nowrap">
<IconShieldCheck size={18} color="var(--mantine-color-blue-6)" aria-hidden />
<Text className={classes.securityNote} size="sm" c="dimmed">
{t("security.description")}
</Text>
</Group>
<ConfigurationSection
number={1}
title={t("connection.endpointTitle")}
description={t("connection.description")}
status={
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
{t(`provider.options.${provider}.label`)}
</Badge>
}
>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Select
label={t("provider.title")}
@@ -325,218 +458,418 @@ export const AssistantConfiguration = () => {
{t("destinationChanged.description")}
</Alert>
)}
</ConfigurationSection>
<Stack gap="xs">
<Group justify="space-between" align="flex-end">
<div>
<Text fw={600}>{t("apiKey.title")}</Text>
<Text size="sm" c="dimmed">
{preset.requiresApiKey ? t("apiKey.required") : t("apiKey.optional")}
</Text>
</div>
{configuration?.apiKeyConfigured && (
<Badge variant="light" color={clearApiKey ? "yellow" : "green"}>
{clearApiKey ? t("apiKey.pendingRemoval") : t("apiKey.configuredBadge")}
</Badge>
)}
</Group>
<PasswordInput
aria-label={t("apiKey.title")}
value={apiKey}
onChange={(event) => {
setApiKey(event.currentTarget.value);
setClearApiKey(false);
}}
leftSection={<IconKey size={16} />}
placeholder={
configuration?.apiKeyConfigured ? t("apiKey.replacementPlaceholder") : t("apiKey.placeholder")
}
autoComplete="new-password"
/>
{configuration?.apiKeyConfigured && (
<Switch
checked={clearApiKey}
onChange={(event) => changeApiKeyRemoval(event.currentTarget.checked)}
color="red"
label={t("apiKey.remove")}
description={clearApiKey ? t("apiKey.removalPendingDescription") : t("apiKey.removeDescription")}
/>
)}
</Stack>
<Divider />
<Stack gap="xs">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>{t("headers.title")}</Text>
<Text size="sm" c="dimmed">
{t("headers.description")}
</Text>
</div>
<Button variant="subtle" size="compact-sm" leftSection={<IconPlus size={14} />} onClick={addHeader}>
{t("headers.add")}
</Button>
</Group>
{configuration?.customHeadersConfigured && headers.length === 0 && (
<Alert color={clearHeaders ? "yellow" : "gray"}>{t("headers.configured")}</Alert>
)}
{headers.map((header) => (
<Group key={header.id} align="flex-end" wrap="wrap">
<TextInput
flex={1}
miw="12rem"
label={t("headers.name")}
value={header.name}
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
placeholder="X-Provider-Header"
/>
<PasswordInput
flex={2}
miw="12rem"
label={t("headers.value")}
value={header.value}
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
placeholder={t("headers.valuePlaceholder")}
autoComplete="off"
/>
<Tooltip label={t("headers.remove")}>
<ActionIcon
size="lg"
variant="subtle"
color="red"
aria-label={t("headers.remove")}
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
>
<IconTrash size={17} />
</ActionIcon>
</Tooltip>
</Group>
))}
{configuration?.customHeadersConfigured && headers.length === 0 && (
<ConfigurationSection
number={2}
title={t("credentials.title")}
description={t("credentials.description")}
status={
<Badge variant="light" color={hasStoredApiKey ? "green" : preset.requiresApiKey ? "yellow" : "gray"}>
{hasStoredApiKey
? t("apiKey.configuredBadge")
: preset.requiresApiKey
? t("credentials.required")
: t("credentials.optional")}
</Badge>
}
actions={
<Group justify="space-between">
<Button
variant="subtle"
color={clearHeaders ? "gray" : "red"}
onClick={() => setClearHeaders(!clearHeaders)}
color="red"
onClick={() => setClearCredentialsOpened(true)}
disabled={!configuration?.apiKeyConfigured && !configuration?.customHeadersConfigured}
>
{clearHeaders ? t("headers.keep") : t("headers.clear")}
</Button>
)}
</Stack>
<Group justify="space-between">
<Button onClick={saveConnection} disabled={!connectionValid}>
{t("connection.save")}
</Button>
{(configuration?.apiKeyConfigured || configuration?.customHeadersConfigured) && (
<Button variant="subtle" color="red" onClick={() => clearCredentials.mutate()}>
{t("connection.clearCredentials")}
</Button>
)}
</Group>
<Divider />
<Stack gap="xs">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>{t("model.title")}</Text>
<Text size="sm" c="dimmed">
{t("model.description")}
</Text>
</div>
<Button
variant="subtle"
size="compact-sm"
leftSection={<IconDatabaseSearch size={14} />}
loading={isDiscovering}
disabled={modelControlsDisabled || configuration?.modelDiscoveryPath === null}
onClick={() => void discoverModels()}
{credentialFlow === "idle" && (
<Button
leftSection={<IconCheck size={16} />}
onClick={() => saveConnection()}
disabled={!connectionValid}
loading={updateConnection.isPending}
>
{t("connection.save")}
</Button>
)}
</Group>
}
>
{hasStoredApiKey && credentialFlow === "idle" ? (
<Group className={classes.credentialSummary} justify="space-between" align="center">
<Group className={classes.credentialSummaryContent} gap="sm" wrap="nowrap">
<ThemeIcon color="teal" variant="light" radius="xl">
<IconShieldLock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} size="sm">
{t("apiKey.savedTitle")}
</Text>
<Text size="sm" c="dimmed">
{t("apiKey.savedDescription")}
</Text>
</Box>
</Group>
<Group className={classes.credentialActions} gap="xs">
<Button
variant="light"
leftSection={<IconKey size={16} />}
onClick={() => {
setCredentialFlow("replace");
setCredentialStep(0);
}}
>
{t("apiKey.replace")}
</Button>
<Button variant="subtle" color="red" onClick={() => setCredentialFlow("remove")}>
{t("apiKey.remove")}
</Button>
</Group>
</Group>
) : credentialFlow === "replace" ? (
<Box className={classes.credentialFlow}>
<Stepper
className={classes.credentialStepper}
active={credentialStep}
size="sm"
allowNextStepsSelect={false}
>
{t("model.refresh")}
<Stepper.Step label={t("apiKey.replaceFlow.enterStep")}>
<Stack gap="md">
<Box>
<Text fw={650}>{t("apiKey.replaceFlow.title")}</Text>
<Text size="sm" c="dimmed">
{t("apiKey.replaceFlow.description")}
</Text>
</Box>
<PasswordInput
label={t("apiKey.replaceFlow.newKey")}
description={t("apiKey.replaceFlow.newKeyDescription")}
value={apiKey}
onChange={(event) => setApiKey(event.currentTarget.value)}
leftSection={<IconKey size={16} />}
placeholder={t("apiKey.replacementPlaceholder")}
autoComplete="new-password"
ref={replacementKeyInputRef}
/>
<Box className={classes.flowActions}>
<Button variant="default" onClick={resetCredentialFlow}>
{t("apiKey.replaceFlow.cancel")}
</Button>
<Button
rightSection={<IconCheck size={16} />}
disabled={apiKey.trim().length === 0}
onClick={() => setCredentialStep(1)}
>
{t("apiKey.replaceFlow.review")}
</Button>
</Box>
</Stack>
</Stepper.Step>
<Stepper.Step label={t("apiKey.replaceFlow.reviewStep")}>
<Stack gap="md">
<Alert
color="yellow"
icon={<IconAlertTriangle size={18} />}
title={t("apiKey.replaceFlow.warningTitle")}
>
{t("apiKey.replaceFlow.warningDescription")}
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<Box>
<Text size="xs" c="dimmed">
{t("apiKey.replaceFlow.provider")}
</Text>
<Text size="sm" fw={600}>
{t(`provider.options.${provider}.label`)}
</Text>
</Box>
<Box>
<Text size="xs" c="dimmed">
{t("apiKey.replaceFlow.endpoint")}
</Text>
<Text size="sm" fw={600} truncate>
{normalizedBaseUrl}
</Text>
</Box>
</SimpleGrid>
<Box className={classes.flowActions}>
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setCredentialStep(0)}
>
{t("apiKey.replaceFlow.back")}
</Button>
<Button
color="yellow"
variant="filled"
leftSection={<IconKey size={16} />}
onClick={() => saveConnection()}
disabled={!connectionValid}
loading={updateConnection.isPending}
>
{t("apiKey.replaceFlow.confirm")}
</Button>
</Box>
</Stack>
</Stepper.Step>
</Stepper>
</Box>
) : credentialFlow === "remove" ? (
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("apiKey.removeFlow.title")}>
<Stack gap="md">
<Text size="sm">
{preset.requiresApiKey
? t("apiKey.removeFlow.description")
: t("apiKey.removeFlow.optionalDescription")}
</Text>
<Box className={classes.flowActions}>
<Button variant="default" onClick={resetCredentialFlow}>
{t("apiKey.removeFlow.cancel")}
</Button>
<Button color="red" loading={updateConnection.isPending} onClick={() => saveConnection(true)}>
{t("apiKey.removeFlow.confirm")}
</Button>
</Box>
</Stack>
</Alert>
) : (
<PasswordInput
label={t("apiKey.title")}
description={preset.requiresApiKey ? t("apiKey.required") : t("apiKey.optional")}
value={apiKey}
onChange={(event) => setApiKey(event.currentTarget.value)}
leftSection={<IconKey size={16} />}
placeholder={t("apiKey.placeholder")}
autoComplete="new-password"
/>
)}
<Accordion variant="contained" radius="md">
<Accordion.Item value="headers">
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
<Group justify="space-between" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{t("headers.title")}
</Text>
<Text size="xs" c="dimmed">
{t("headers.description")}
</Text>
</Box>
{configuration?.customHeadersConfigured && (
<Badge variant="light" color={clearHeaders ? "yellow" : "green"}>
{clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")}
</Badge>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")}
</Text>
<Button variant="light" size="compact-sm" leftSection={<IconPlus size={14} />} onClick={addHeader}>
{t("headers.add")}
</Button>
</Group>
{headers.map((header) => (
<Group key={header.id} align="flex-end" wrap="wrap">
<TextInput
flex={1}
miw="12rem"
label={t("headers.name")}
value={header.name}
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
placeholder="X-Provider-Header"
/>
<PasswordInput
flex={2}
miw="12rem"
label={t("headers.value")}
value={header.value}
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
placeholder={t("headers.valuePlaceholder")}
autoComplete="off"
/>
<Tooltip label={t("headers.remove")}>
<ActionIcon
size="lg"
variant="subtle"
color="red"
aria-label={t("headers.remove")}
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
>
<IconTrash size={17} />
</ActionIcon>
</Tooltip>
</Group>
))}
{configuration?.customHeadersConfigured && headers.length === 0 && (
<Button
variant="light"
color={clearHeaders ? "gray" : "red"}
onClick={() => setClearHeaders(!clearHeaders)}
>
{clearHeaders ? t("headers.keep") : t("headers.clear")}
</Button>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</ConfigurationSection>
<ConfigurationSection
number={3}
title={t("model.title")}
description={t("model.description")}
status={
configuration?.modelId ? (
<Badge variant="light" color={configuration.enabled ? "green" : "gray"}>
{configuration.enabled ? t("overview.available") : t("overview.disabled")}
</Badge>
) : (
<Badge variant="light" color="gray">
{t("overview.noModel")}
</Badge>
)
}
actions={
<Group justify="flex-end">
<Button
leftSection={<IconCircleCheck size={16} />}
onClick={saveAssistantConfiguration}
disabled={!canSaveConfiguration}
loading={saveConfiguration.isPending}
>
{t("save")}
</Button>
</Group>
{hasDiscoveredModels ? (
<Select
aria-label={t("model.title")}
value={modelId || null}
onChange={(value) => setModelId(value ?? "")}
data={modelControlsDisabled ? [] : modelOptions}
disabled={modelControlsDisabled}
placeholder={t("model.placeholder")}
description={t("model.discovered", { count: models?.length ?? 0 })}
searchable
allowDeselect={false}
limit={100}
/>
) : (
<TextInput
aria-label={t("model.title")}
value={modelId}
onChange={(event) => 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 && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("model.discoveryFailed")}>
{discoveryError.message}
</Alert>
)}
{selectedModel && (
<Card withBorder padding="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={600}>{selectedModel.name}</Text>
{selectedModel.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{selectedModel.description}
</Text>
)}
</div>
<Group gap="xs" justify="flex-end">
{selectedModel.contextLength && (
<Badge variant="light">
{t("model.context", { count: selectedModel.contextLength.toLocaleString() })}
</Badge>
)}
<Badge
variant="light"
color={selectedModel.toolSupport === "confirmed" ? "green" : "gray"}
leftSection={<IconTool size={12} />}
>
{selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")}
</Badge>
</Group>
</Group>
</Card>
)}
</Stack>
}
>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{connectionPending ? t("model.saveConnectionFirst") : t("model.chooseModel")}
</Text>
<Button
variant="light"
size="compact-sm"
leftSection={<IconDatabaseSearch size={14} />}
loading={isDiscovering}
disabled={modelControlsDisabled || configuration?.modelDiscoveryPath === null}
onClick={() => void discoverModels()}
>
{t("model.refresh")}
</Button>
</Group>
{hasDiscoveredModels ? (
<Select
label={t("model.title")}
value={modelId || null}
onChange={(value) => setModelId(value ?? "")}
data={modelControlsDisabled ? [] : modelOptions}
disabled={modelControlsDisabled}
placeholder={t("model.placeholder")}
description={t("model.discovered", { count: models?.length ?? 0 })}
searchable
allowDeselect={false}
limit={100}
/>
) : (
<TextInput
label={t("model.title")}
value={modelId}
onChange={(event) => 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 && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("model.discoveryFailed")}>
{discoveryError.message}
</Alert>
)}
{selectedModel && (
<Group className={classes.selectedModel} justify="space-between" align="flex-start" wrap="nowrap">
<Box className={classes.selectedModelText}>
<Text fw={600}>{selectedModel.name}</Text>
{selectedModel.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{selectedModel.description}
</Text>
)}
</Box>
<Group className={classes.selectedModelBadges} gap="xs" justify="flex-end">
{selectedModel.contextLength && (
<Badge variant="light">
{t("model.context", { count: selectedModel.contextLength.toLocaleString() })}
</Badge>
)}
<Badge
variant="light"
color={selectedModel.toolSupport === "confirmed" ? "green" : "gray"}
leftSection={<IconTool size={12} />}
>
{selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")}
</Badge>
</Group>
</Group>
)}
<Switch
checked={enabled}
onChange={(event) => setEnabled(event.currentTarget.checked)}
disabled={!canSaveConfiguration}
label={t("enabled.label")}
description={t("enabled.description")}
/>
<Group className={classes.enableRow} justify="space-between" align="center" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{t("enabled.label")}
</Text>
<Text size="sm" c="dimmed">
{t("enabled.description")}
</Text>
</Box>
<Switch
checked={enabled}
onChange={(event) => setEnabled(event.currentTarget.checked)}
disabled={!canSaveConfiguration}
aria-label={t("enabled.label")}
size="md"
/>
</Group>
</ConfigurationSection>
</Stack>
<Modal
opened={clearCredentialsOpened}
onClose={() => setClearCredentialsOpened(false)}
title={t("clearCredentials.title")}
centered
>
<Stack>
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("clearCredentials.warningTitle")}>
{t("clearCredentials.description")}
</Alert>
<Divider />
<Group justify="flex-end">
<Button onClick={saveAssistantConfiguration} disabled={!canSaveConfiguration}>
{t("save")}
<Button variant="default" onClick={() => setClearCredentialsOpened(false)}>
{t("clearCredentials.cancel")}
</Button>
<Button color="red" loading={clearCredentials.isPending} onClick={() => clearCredentials.mutate()}>
{t("clearCredentials.confirm")}
</Button>
</Group>
</Stack>
</Card>
</Stack>
</Modal>
</>
);
};
+98
View File
@@ -0,0 +1,98 @@
import { chromium, expect } from "@playwright/test";
import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
import * as sqliteSchema from "../packages/db/schema/sqlite";
const adminCredentials = {
username: "admin",
password: "Comp(exP4sswOrd",
};
describe("Assistant management", () => {
test("keeps provider actions clear and the API key replacement flow responsive", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
await seedAdminUserAsync(db, adminCredentials);
await db.insert(sqliteSchema.assistantConfigurations).values({
id: "default",
enabled: true,
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
modelDiscoveryPath: null,
encryptedApiKey: "encrypted.key",
modelId: "deepseek/deepseek-v4-flash",
});
const homarrContainer = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "credentials",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
try {
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
await page.getByRole("button", { name: "Login" }).click();
await page.waitForURL(baseUrl, { timeout: 15_000 });
await page.goto(`${baseUrl}/manage/assistant`);
await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible();
await expect(page.getByText("Connection ready")).toBeVisible();
await expect(page.getByText("Key protected")).toBeVisible();
await expect(page.getByText("Encrypted API key saved")).toBeVisible();
await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible();
await expect(page.getByRole("button", { name: "Remove saved API key" })).toBeVisible();
await page.getByRole("button", { name: "Replace key" }).click();
const newKey = page.getByLabel("New API key");
await expect(newKey).toBeFocused();
await newKey.fill("replacement-test-api-key");
await page.getByRole("button", { name: "Review replacement" }).click();
await expect(page.getByText("The current key will stop being used")).toBeVisible();
await expect(page.getByText("https://openrouter.ai/api/v1", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Replace API key" })).toBeVisible();
await page.getByRole("button", { name: "Edit new key" }).click();
await page.getByRole("button", { name: "Cancel replacement" }).click();
await page.getByRole("button", { name: "Additional request headers" }).click();
await expect(page.getByRole("button", { name: "Add header" })).toBeVisible();
await page.getByRole("button", { name: "Remove all credentials" }).click();
await expect(page.getByRole("dialog", { name: "Remove all assistant credentials" })).toBeVisible();
await expect(page.getByRole("button", { name: "Remove credentials" })).toBeVisible();
await page.getByRole("button", { name: "Keep credentials" }).click();
await expect(page.getByRole("dialog", { name: "Remove all assistant credentials" })).toBeHidden();
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible();
await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible();
await expect(page.getByRole("button", { name: "Save assistant" })).toBeVisible();
await page.getByRole("button", { name: "Replace key" }).click();
await expect(page.getByLabel("New API key")).toBeFocused();
await page.getByLabel("New API key").fill("replacement-test-api-key");
await page.getByRole("button", { name: "Review replacement" }).click();
await expect(page.getByText("The current key will stop being used")).toBeVisible();
await expect(page.getByRole("button", { name: "Edit new key" })).toBeVisible();
await expect(page.getByRole("button", { name: "Replace API key" })).toBeVisible();
const hasHorizontalOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
);
expect(hasHorizontalOverflow).toBe(false);
} finally {
await browser.close();
await homarrContainer.stop();
}
}, 120_000);
});
+54 -1
View File
@@ -4992,14 +4992,30 @@
"title": "Server-side secrets",
"description": "API keys and additional header values are encrypted in the Homarr database. They are never returned to browsers. Model requests are sent from the Homarr server directly to the configured endpoint."
},
"overview": {
"connectionReady": "Connection ready",
"unsavedChanges": "Unsaved changes",
"setupRequired": "Setup required",
"keyProtected": "Key protected",
"available": "Available",
"disabled": "Disabled",
"noModel": "No model selected"
},
"connection": {
"title": "Provider connection",
"endpointTitle": "Provider and endpoint",
"description": "Choose a preset or connect any service that implements the OpenAI Chat Completions API.",
"saved": "Connection saved",
"notSaved": "Not configured",
"save": "Save provider connection",
"clearCredentials": "Remove all credentials"
},
"credentials": {
"title": "Credentials",
"description": "Add the secrets this endpoint needs. Stored values stay encrypted and cannot be read back.",
"required": "Key required",
"optional": "Key optional"
},
"provider": {
"title": "Provider",
"groups": {
@@ -5074,9 +5090,35 @@
"replacementPlaceholder": "Enter a replacement API key",
"configuredBadge": "Saved key active",
"pendingRemoval": "Removal pending",
"savedTitle": "Encrypted API key saved",
"savedDescription": "Homarr can use this key, but it cannot display or recover its value.",
"replace": "Replace key",
"remove": "Remove saved API key",
"removeDescription": "Deletes the saved key after you click Save provider connection.",
"removalPendingDescription": "The saved key will be deleted when you save the provider connection."
"removalPendingDescription": "The saved key will be deleted when you save the provider connection.",
"replaceFlow": {
"title": "Replace the saved API key",
"description": "Enter the new key first. You will review the endpoint and impact before anything changes.",
"enterStep": "Enter new key",
"reviewStep": "Review change",
"newKey": "New API key",
"newKeyDescription": "The existing key remains active until you confirm the replacement.",
"cancel": "Cancel replacement",
"review": "Review replacement",
"warningTitle": "The current key will stop being used",
"warningDescription": "Confirming replaces the encrypted key for this endpoint. Homarr cannot restore the previous value, and model discovery and assistant requests will use the new key immediately.",
"provider": "Provider",
"endpoint": "Destination",
"back": "Edit new key",
"confirm": "Replace API key"
},
"removeFlow": {
"title": "Remove the saved API key?",
"description": "This provider requires an API key. Removing it can stop model discovery and assistant responses until another key is saved.",
"optionalDescription": "The endpoint will stop receiving bearer authentication after this key is removed. Confirm that it accepts unauthenticated requests or uses another stored header.",
"cancel": "Keep saved key",
"confirm": "Remove API key"
}
},
"headers": {
"title": "Additional request headers",
@@ -5087,6 +5129,9 @@
"valuePlaceholder": "Secret or routing value",
"remove": "Remove header",
"configured": "Additional encrypted headers are configured. Adding headers replaces the complete stored set.",
"empty": "No additional headers are stored for this provider.",
"savedBadge": "Headers saved",
"pendingRemoval": "Removal pending",
"clear": "Remove stored headers when saving",
"keep": "Keep stored headers"
},
@@ -5095,6 +5140,7 @@
"description": "Select a discovered model or enter an exact endpoint model ID.",
"placeholder": "Model ID",
"refresh": "Refresh models",
"chooseModel": "Choose the model used for new conversations.",
"discoveryPath": "Model discovery path",
"discoveryPathDescription": "Appended to the API base URL. Leave blank to disable discovery and use a manual model ID.",
"manual": "Enter a model ID manually or save the connection to discover models.",
@@ -5111,6 +5157,13 @@
"label": "Enable Assistant",
"description": "Shows the assistant to all signed-in users. Existing Homarr permissions still apply to every internal tool call."
},
"clearCredentials": {
"title": "Remove all assistant credentials",
"warningTitle": "The assistant will be disabled",
"description": "This permanently removes the stored API key and additional headers, clears the selected model, and disables the assistant. The encrypted values cannot be recovered.",
"cancel": "Keep credentials",
"confirm": "Remove credentials"
},
"save": "Save assistant",
"notification": {
"connectionSaved": {