feat: expand assistant providers and polish experience

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 5f3d676af0
commit c0445cdca2
12 files changed
+438 -70

No files matched your search

@@ -28,51 +28,25 @@ import {
IconPlus,
IconRobot,
IconShieldLock,
IconTool,
IconTrash,
IconWorld,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { assistantProviderIds, assistantProviderPresets } from "@homarr/definitions";
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
const providerPresets = {
openrouter: {
baseUrl: "https://openrouter.ai/api/v1",
discoveryPath: "/models",
requiresApiKey: true,
},
openai: {
baseUrl: "https://api.openai.com/v1",
discoveryPath: "/models",
requiresApiKey: true,
},
ollama: {
baseUrl: "http://localhost:11434/v1",
discoveryPath: "/models",
requiresApiKey: false,
},
"lm-studio": {
baseUrl: "http://localhost:1234/v1",
discoveryPath: "/models",
requiresApiKey: false,
},
custom: {
baseUrl: "",
discoveryPath: "/models",
requiresApiKey: false,
},
} as const;
type Provider = keyof typeof providerPresets;
type HeaderEntry = { id: number; name: string; value: string };
export const AssistantSettings = () => {
const t = useScopedI18n("management.page.settings.section.assistant");
const utils = clientApi.useUtils();
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
const [provider, setProvider] = useState<Provider>("openrouter");
const [baseUrl, setBaseUrl] = useState<string>(providerPresets.openrouter.baseUrl);
const [provider, setProvider] = useState<AssistantProvider>("openrouter");
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.openrouter.baseUrl);
const [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
const [apiKey, setApiKey] = useState("");
const [clearApiKey, setClearApiKey] = useState(false);
@@ -84,7 +58,7 @@ export const AssistantSettings = () => {
useEffect(() => {
setProvider(configuration?.provider ?? "openrouter");
setBaseUrl(configuration?.baseUrl ?? providerPresets.openrouter.baseUrl);
setBaseUrl(configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl);
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
setModelId(configuration?.modelId ?? "");
setEnabled(configuration?.enabled ?? false);
@@ -109,11 +83,25 @@ export const AssistantSettings = () => {
})),
[models],
);
const providerOptions = useMemo(
() =>
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => ({
group: t(`provider.groups.${category}`),
items: assistantProviderIds
.filter((providerId) => assistantProviderPresets[providerId].category === category)
.map((providerId) => ({
value: providerId,
label: t(`provider.options.${providerId}.label`),
})),
})),
[t],
);
const selectedModel = models?.find((model) => model.id === modelId);
const destinationChanged =
configuration?.connectionConfigured === true &&
(configuration.provider !== provider || configuration.baseUrl !== baseUrl.trim().replace(/\/$/, ""));
const preset = providerPresets[provider];
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);
@@ -177,12 +165,12 @@ export const AssistantSettings = () => {
const pending = isLoading || updateConnection.isPending || saveConfiguration.isPending || clearCredentials.isPending;
const changeProvider = (value: string | null) => {
if (!value || !(value in providerPresets)) return;
const nextProvider = value as Provider;
const nextPreset = providerPresets[nextProvider];
if (!value || !(value in assistantProviderPresets)) return;
const nextProvider = value as AssistantProvider;
const nextPreset = assistantProviderPresets[nextProvider];
setProvider(nextProvider);
setBaseUrl(nextPreset.baseUrl);
setModelDiscoveryPath(nextPreset.discoveryPath);
setModelDiscoveryPath(nextPreset.modelDiscoveryPath ?? "");
setEnabled(false);
};
@@ -243,10 +231,9 @@ export const AssistantSettings = () => {
description={t(`provider.options.${provider}.description`)}
value={provider}
onChange={changeProvider}
data={Object.keys(providerPresets).map((value) => ({
value,
label: t(`provider.options.${value as Provider}.label`),
}))}
data={providerOptions}
searchable
allowDeselect={false}
/>
<TextInput
label={t("baseUrl.title")}
@@ -286,6 +273,7 @@ export const AssistantSettings = () => {
)}
</Group>
<PasswordInput
aria-label={t("apiKey.title")}
value={apiKey}
onChange={(event) => {
setApiKey(event.currentTarget.value);
@@ -399,6 +387,7 @@ export const AssistantSettings = () => {
</Button>
</Group>
<Autocomplete
aria-label={t("model.title")}
value={modelId}
onChange={setModelId}
data={modelOptions}
@@ -412,6 +401,39 @@ export const AssistantSettings = () => {
}
limit={100}
/>
{discoveryError && (
<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>
<Switch
@@ -14,6 +14,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq } from "@homarr/db";
import { db } from "@homarr/db";
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
import { assistantProviderRequiresApiKey } from "@homarr/definitions";
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
@@ -134,7 +135,7 @@ export async function POST(request: Request) {
const configuration = await db.query.assistantConfigurations.findFirst({
where: eq(assistantConfigurations.id, "default"),
});
const requiresApiKey = configuration?.provider === "openrouter" || configuration?.provider === "openai";
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
}
@@ -79,6 +79,15 @@
overflow-wrap: anywhere;
}
.messageError {
margin-top: 0.5rem;
padding: 0.65rem 0.75rem;
border-radius: var(--mantine-radius-md);
color: var(--mantine-color-red-text);
background: var(--mantine-color-red-light);
font-size: var(--mantine-font-size-sm);
}
.tool {
margin-block: 0.5rem;
padding: 0.7rem 0.8rem;
@@ -105,10 +114,9 @@
align-items: flex-end;
gap: 0.5rem;
padding: 0.45rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: 1rem;
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-lg);
background: var(--mantine-color-default);
box-shadow: var(--mantine-shadow-sm);
pointer-events: auto;
}
@@ -130,9 +138,26 @@
min-height: 60vh;
align-items: center;
justify-content: center;
padding: 3rem 1rem 8rem;
text-align: center;
}
.suggestions {
display: grid;
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.suggestion {
height: auto;
min-height: 3.25rem;
justify-content: flex-start;
padding: 0.7rem 0.9rem;
text-align: start;
white-space: normal;
}
.mobileHistoryButton,
.historyClose {
display: none;
@@ -173,4 +198,18 @@
.historyClose {
display: inline-flex;
}
.suggestions {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.history {
transition: none;
}
.viewport {
scroll-behavior: auto;
}
}
@@ -15,10 +15,13 @@ import {
import { ActionIcon, Box, Button, Drawer, Group, ScrollArea, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import {
IconActivityHeartbeat,
IconApps,
IconArchive,
IconArrowUp,
IconCheck,
IconCopy,
IconCommand,
IconMenu2,
IconHistory,
IconPlayerStop,
@@ -26,6 +29,7 @@ import {
IconRefresh,
IconRestore,
IconRobot,
IconSearch,
IconTrash,
IconX,
} from "@tabler/icons-react";
@@ -143,6 +147,15 @@ const UserMessage = () => (
</MessagePrimitive.Root>
);
const RuntimeError = () => {
const t = useScopedI18n("common.assistant");
return (
<MessagePrimitive.Error>
<Box className={classes.messageError}>{t("responseError")}</Box>
</MessagePrimitive.Error>
);
};
const AssistantMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.Parts
@@ -151,6 +164,7 @@ const AssistantMessage = () => (
tools: { Fallback: ToolPart },
}}
/>
<RuntimeError />
<MessageActions />
</MessagePrimitive.Root>
);
@@ -236,14 +250,14 @@ const ThreadHistory = ({ opened, onClose }: { opened: boolean; onClose: () => vo
</Group>
<Group gap="xs" px="xs" mt="xs">
<IconHistory size={14} />
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
<Text size="sm" fw={600} c="dimmed">
{t("conversations")}
</Text>
</Group>
<ScrollArea flex={1} type="auto">
<Stack gap={3}>
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
<Text size="xs" fw={700} c="dimmed" tt="uppercase" px="xs" mt="sm">
<Text size="sm" fw={600} c="dimmed" px="xs" mt="sm">
{t("archived")}
</Text>
<ThreadListPrimitive.Items archived components={{ ThreadListItem: ArchivedThreadListItem }} />
@@ -258,16 +272,44 @@ const EmptyThread = () => {
return (
<ThreadPrimitive.Empty>
<Box className={classes.empty}>
<Stack align="center" gap="sm" maw={340}>
<ThemeIcon size={52} radius="xl" variant="light" color="red">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
{t("emptyTitle")}
</Text>
<Text size="sm" c="dimmed">
{t("emptyDescription")}
</Text>
<Stack align="center" gap="lg" maw={560} w="100%">
<Stack align="center" gap="xs" maw={430}>
<ThemeIcon size={52} radius="xl" variant="light" color="red">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
{t("emptyTitle")}
</Text>
<Text size="sm" c="dimmed">
{t("emptyDescription")}
</Text>
</Stack>
<Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
<Button
variant="default"
className={classes.suggestion}
leftSection={<IconActivityHeartbeat size={18} />}
>
{t("suggestions.health.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
{t("suggestions.explore.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.command.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconCommand size={18} />}>
{t("suggestions.command.label")}
</Button>
</ThreadPrimitive.Suggestion>
</Box>
</Stack>
</Box>
</ThreadPrimitive.Empty>