feat: expand assistant providers and polish experience
This commit is contained in:
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>
|
||||
|
||||
@@ -7,12 +7,13 @@ import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { and, desc, eq, inArray } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { assistantConfigurations, assistantMessages, assistantThreads } from "@homarr/db/schema";
|
||||
import { assistantProviderIds, assistantProviderPresets, assistantProviderRequiresApiKey } from "@homarr/definitions";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const configurationId = "default";
|
||||
const providerSchema = z.enum(["openrouter", "openai", "ollama", "lm-studio", "custom"]);
|
||||
const providerSchema = z.enum(assistantProviderIds);
|
||||
|
||||
const modelSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -27,12 +28,16 @@ const modelSchema = z.object({
|
||||
type CompatibleModel = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
display_name?: unknown;
|
||||
model?: unknown;
|
||||
description?: unknown;
|
||||
context_length?: unknown;
|
||||
max_context_length?: unknown;
|
||||
max_input_tokens?: unknown;
|
||||
supported_parameters?: unknown;
|
||||
architecture?: { output_modalities?: unknown };
|
||||
pricing?: { prompt?: unknown; completion?: unknown };
|
||||
capabilities?: { function_calling?: unknown };
|
||||
};
|
||||
|
||||
type AssistantConfiguration = typeof assistantConfigurations.$inferSelect;
|
||||
@@ -108,7 +113,13 @@ const decryptCustomHeaders = (encryptedHeaders: `${string}.${string}` | null) =>
|
||||
const getProviderHeaders = (configuration: AssistantConfiguration) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (configuration.encryptedApiKey) {
|
||||
headers.Authorization = `Bearer ${decryptSecret(configuration.encryptedApiKey)}`;
|
||||
const apiKey = decryptSecret(configuration.encryptedApiKey);
|
||||
if (assistantProviderPresets[configuration.provider].discoveryAuthentication === "anthropic") {
|
||||
headers["X-Api-Key"] = apiKey;
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
} else {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
}
|
||||
if (configuration.provider === "openrouter") {
|
||||
headers["HTTP-Referer"] ??= "https://homarr.dev";
|
||||
@@ -149,13 +160,13 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
|
||||
});
|
||||
}
|
||||
|
||||
let body: { data?: CompatibleModel[]; models?: CompatibleModel[] };
|
||||
let body: { data?: CompatibleModel[]; models?: CompatibleModel[] } | CompatibleModel[];
|
||||
try {
|
||||
body = (await response.json()) as typeof body;
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "The model endpoint returned invalid JSON." });
|
||||
}
|
||||
const models = body.data ?? body.models ?? [];
|
||||
const models = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
|
||||
return models
|
||||
.filter((model) => {
|
||||
if (configuration.provider !== "openrouter") return true;
|
||||
@@ -168,16 +179,31 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
|
||||
.flatMap((model) => {
|
||||
const id = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : null;
|
||||
if (!id) return [];
|
||||
const name = typeof model.name === "string" ? model.name : id;
|
||||
const name =
|
||||
typeof model.name === "string" ? model.name : typeof model.display_name === "string" ? model.display_name : id;
|
||||
const contextLength =
|
||||
typeof model.context_length === "number"
|
||||
? model.context_length
|
||||
: typeof model.max_context_length === "number"
|
||||
? model.max_context_length
|
||||
: typeof model.max_input_tokens === "number"
|
||||
? model.max_input_tokens
|
||||
: null;
|
||||
const toolSupport =
|
||||
configuration.provider === "openrouter" ||
|
||||
model.capabilities?.function_calling === true ||
|
||||
configuration.provider === "anthropic"
|
||||
? ("confirmed" as const)
|
||||
: ("unknown" as const);
|
||||
return [
|
||||
{
|
||||
id,
|
||||
name,
|
||||
description: typeof model.description === "string" ? model.description : null,
|
||||
contextLength: typeof model.context_length === "number" ? model.context_length : null,
|
||||
contextLength,
|
||||
promptPrice: typeof model.pricing?.prompt === "string" ? model.pricing.prompt : null,
|
||||
completionPrice: typeof model.pricing?.completion === "string" ? model.pricing.completion : null,
|
||||
toolSupport: configuration.provider === "openrouter" ? ("confirmed" as const) : ("unknown" as const),
|
||||
toolSupport,
|
||||
},
|
||||
];
|
||||
})
|
||||
@@ -204,7 +230,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
const requiresApiKey = configuration?.provider === "openrouter" || configuration?.provider === "openai";
|
||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||
return {
|
||||
enabled: Boolean(
|
||||
configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
|
||||
@@ -318,7 +344,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
if (!configuration) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." });
|
||||
}
|
||||
const requiresApiKey = configuration.provider === "openrouter" || configuration.provider === "openai";
|
||||
const requiresApiKey = assistantProviderRequiresApiKey(configuration.provider);
|
||||
if (input.enabled && requiresApiKey && !configuration.encryptedApiKey) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This provider requires an API key." });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { assistantProviderIds, assistantProviderPresets, assistantProviderRequiresApiKey } from "@homarr/definitions";
|
||||
|
||||
describe("assistant provider presets", () => {
|
||||
test("defines one complete preset for every provider", () => {
|
||||
expect(Object.keys(assistantProviderPresets)).toEqual(assistantProviderIds);
|
||||
expect(new Set(assistantProviderIds).size).toBe(assistantProviderIds.length);
|
||||
});
|
||||
|
||||
test.each(assistantProviderIds.filter((provider) => provider !== "custom"))(
|
||||
"%s has a valid default endpoint",
|
||||
(provider) => {
|
||||
expect(() => new URL(assistantProviderPresets[provider].baseUrl)).not.toThrow();
|
||||
expect(assistantProviderPresets[provider].modelDiscoveryPath).toMatch(/^\/[^/]/);
|
||||
},
|
||||
);
|
||||
|
||||
test("requires credentials for hosted providers but not local or custom endpoints", () => {
|
||||
for (const provider of assistantProviderIds) {
|
||||
expect(assistantProviderRequiresApiKey(provider)).toBe(assistantProviderPresets[provider].category === "hosted");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -485,7 +485,20 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey().default("default"),
|
||||
enabled: boolean().notNull().default(false),
|
||||
provider: varchar({ length: 32 })
|
||||
.$type<"openrouter" | "openai" | "ollama" | "lm-studio" | "custom">()
|
||||
.$type<
|
||||
| "openrouter"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google-gemini"
|
||||
| "xai"
|
||||
| "groq"
|
||||
| "mistral"
|
||||
| "deepseek"
|
||||
| "together"
|
||||
| "ollama"
|
||||
| "lm-studio"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull()
|
||||
.default("openrouter"),
|
||||
baseUrl: varchar({ length: 2048 }).notNull().default("https://openrouter.ai/api/v1"),
|
||||
|
||||
@@ -484,7 +484,20 @@ export const assistantConfigurations = pgTable("assistant_configuration", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey().default("default"),
|
||||
enabled: boolean().notNull().default(false),
|
||||
provider: varchar({ length: 32 })
|
||||
.$type<"openrouter" | "openai" | "ollama" | "lm-studio" | "custom">()
|
||||
.$type<
|
||||
| "openrouter"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google-gemini"
|
||||
| "xai"
|
||||
| "groq"
|
||||
| "mistral"
|
||||
| "deepseek"
|
||||
| "together"
|
||||
| "ollama"
|
||||
| "lm-studio"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull()
|
||||
.default("openrouter"),
|
||||
baseUrl: varchar({ length: 2048 }).notNull().default("https://openrouter.ai/api/v1"),
|
||||
|
||||
@@ -468,7 +468,23 @@ export const serverSettings = sqliteTable("serverSetting", {
|
||||
export const assistantConfigurations = sqliteTable("assistant_configuration", {
|
||||
id: text().notNull().primaryKey().default("default"),
|
||||
enabled: int({ mode: "boolean" }).notNull().default(false),
|
||||
provider: text().$type<"openrouter" | "openai" | "ollama" | "lm-studio" | "custom">().notNull().default("openrouter"),
|
||||
provider: text()
|
||||
.$type<
|
||||
| "openrouter"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google-gemini"
|
||||
| "xai"
|
||||
| "groq"
|
||||
| "mistral"
|
||||
| "deepseek"
|
||||
| "together"
|
||||
| "ollama"
|
||||
| "lm-studio"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull()
|
||||
.default("openrouter"),
|
||||
baseUrl: text().notNull().default("https://openrouter.ai/api/v1"),
|
||||
modelDiscoveryPath: text().default("/models"),
|
||||
encryptedApiKey: text().$type<`${string}.${string}`>(),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
export const assistantProviderIds = [
|
||||
"openrouter",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google-gemini",
|
||||
"xai",
|
||||
"groq",
|
||||
"mistral",
|
||||
"deepseek",
|
||||
"together",
|
||||
"ollama",
|
||||
"lm-studio",
|
||||
"custom",
|
||||
] as const;
|
||||
|
||||
export type AssistantProvider = (typeof assistantProviderIds)[number];
|
||||
export type AssistantProviderCategory = "hosted" | "local" | "custom";
|
||||
|
||||
interface AssistantProviderPreset {
|
||||
baseUrl: string;
|
||||
modelDiscoveryPath: string | null;
|
||||
requiresApiKey: boolean;
|
||||
category: AssistantProviderCategory;
|
||||
discoveryAuthentication: "bearer" | "anthropic";
|
||||
}
|
||||
|
||||
export const assistantProviderPresets = {
|
||||
openrouter: {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: true,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: true,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: true,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "anthropic",
|
||||
},
|
||||
"google-gemini": {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: true,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
xai: {
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: true,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
groq: {
|
||||
baseUrl: "https://api.groq.com/openai/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: true,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
mistral: {
|
||||
baseUrl: "https://api.mistral.ai/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: true,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
deepseek: {
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: true,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
together: {
|
||||
baseUrl: "https://api.together.xyz/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: true,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
ollama: {
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: false,
|
||||
category: "local",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
"lm-studio": {
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: false,
|
||||
category: "local",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
custom: {
|
||||
baseUrl: "",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: false,
|
||||
category: "custom",
|
||||
discoveryAuthentication: "bearer",
|
||||
},
|
||||
} as const satisfies Record<AssistantProvider, AssistantProviderPreset>;
|
||||
|
||||
export const assistantProviderRequiresApiKey = (provider: AssistantProvider) =>
|
||||
assistantProviderPresets[provider].requiresApiKey;
|
||||
@@ -18,3 +18,4 @@ export * from "./hotkeys";
|
||||
export * from "./docker-integration-match";
|
||||
export * from "./integration-url-template";
|
||||
export * from "./release-provider";
|
||||
export * from "./assistant";
|
||||
@@ -1995,11 +1995,30 @@
|
||||
"openHistory": "Open conversation history",
|
||||
"emptyTitle": "What can I help with?",
|
||||
"emptyDescription": "Ask about your Homarr setup, inspect live instance data, or approve actions when you want me to make a change.",
|
||||
"suggestions": {
|
||||
"health": {
|
||||
"label": "Check service health",
|
||||
"prompt": "Check my integrations and summarize anything that needs attention."
|
||||
},
|
||||
"explore": {
|
||||
"label": "Explore my Homarr",
|
||||
"prompt": "Show me what I can view and manage in this Homarr instance."
|
||||
},
|
||||
"media": {
|
||||
"label": "Find media to request",
|
||||
"prompt": "Help me find a movie or series to request."
|
||||
},
|
||||
"command": {
|
||||
"label": "Open the command menu",
|
||||
"prompt": "Open the Homarr command menu for me."
|
||||
}
|
||||
},
|
||||
"composerPlaceholder": "Ask Homarr Assistant…",
|
||||
"stop": "Stop response",
|
||||
"send": "Send message",
|
||||
"copy": "Copy response",
|
||||
"regenerate": "Regenerate response",
|
||||
"responseError": "The response stopped unexpectedly. Try again, or ask an administrator to verify the model endpoint.",
|
||||
"scrollToLatest": "Scroll to latest",
|
||||
"approvalRequired": "Approval required",
|
||||
"complete": "Complete",
|
||||
@@ -5852,6 +5871,11 @@
|
||||
},
|
||||
"provider": {
|
||||
"title": "Provider",
|
||||
"groups": {
|
||||
"hosted": "Hosted providers",
|
||||
"local": "Local providers",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"options": {
|
||||
"openrouter": {
|
||||
"label": "OpenRouter",
|
||||
@@ -5861,6 +5885,34 @@
|
||||
"label": "OpenAI",
|
||||
"description": "OpenAI Chat Completions through the official API endpoint."
|
||||
},
|
||||
"anthropic": {
|
||||
"label": "Anthropic Claude",
|
||||
"description": "Claude through Anthropic's OpenAI SDK compatibility endpoint."
|
||||
},
|
||||
"google-gemini": {
|
||||
"label": "Google Gemini",
|
||||
"description": "Gemini through Google's OpenAI-compatible API endpoint."
|
||||
},
|
||||
"xai": {
|
||||
"label": "xAI",
|
||||
"description": "Grok models through xAI's OpenAI-compatible API."
|
||||
},
|
||||
"groq": {
|
||||
"label": "Groq",
|
||||
"description": "Fast hosted inference through Groq's OpenAI-compatible API."
|
||||
},
|
||||
"mistral": {
|
||||
"label": "Mistral AI",
|
||||
"description": "Mistral models through the official API."
|
||||
},
|
||||
"deepseek": {
|
||||
"label": "DeepSeek",
|
||||
"description": "DeepSeek models through its OpenAI-compatible API."
|
||||
},
|
||||
"together": {
|
||||
"label": "Together AI",
|
||||
"description": "Hosted open models through Together's OpenAI-compatible API."
|
||||
},
|
||||
"ollama": {
|
||||
"label": "Ollama",
|
||||
"description": "A local or network Ollama server using its OpenAI-compatible API."
|
||||
@@ -5915,7 +5967,11 @@
|
||||
"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.",
|
||||
"manualFallback": "Discovery is unavailable. You can still enter a model ID manually.",
|
||||
"discovered": "Discovered {count} models. Tool support depends on the selected endpoint and model."
|
||||
"discovered": "Discovered {count} models. Tool support depends on the selected endpoint and model.",
|
||||
"discoveryFailed": "Model discovery did not complete",
|
||||
"context": "{count} token context",
|
||||
"toolsConfirmed": "Tools advertised",
|
||||
"toolsUnknown": "Verify tool support"
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Enable Homarr Assistant",
|
||||
|
||||
Reference in new issue
Block a user