feat: add Homarr assistant
This commit is contained in:
1 parent
3b6de1f91f
commit
fd69cd28f0
27 files changed
+5210
-772
No files matched your search
@@ -12,6 +12,10 @@ Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) serve
|
||||
|
||||
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
|
||||
|
||||
The optional [Homarr Assistant](./settings#homarr-assistant) uses these same tool definitions internally.
|
||||
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
|
||||
approval before running mutating procedures.
|
||||
|
||||
## Getting Started
|
||||
|
||||
The MCP endpoint lives under the **API** page at `Management` → `Tools` → `API` → **MCP** tab.
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "catalog:",
|
||||
"@ai-sdk/react": "catalog:",
|
||||
"@assistant-ui/react": "catalog:",
|
||||
"@assistant-ui/react-ai-sdk": "catalog:",
|
||||
"@assistant-ui/react-lexical": "catalog:",
|
||||
"@assistant-ui/react-markdown": "catalog:",
|
||||
"@dnd-kit/abstract": "catalog:",
|
||||
"@dnd-kit/collision": "catalog:",
|
||||
"@dnd-kit/core": "catalog:",
|
||||
@@ -62,7 +68,7 @@
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@mantine/modals": "catalog:",
|
||||
"@mantine/tiptap": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "catalog:",
|
||||
"@modelcontextprotocol/server": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/react-devtools": "catalog:",
|
||||
@@ -77,6 +83,8 @@
|
||||
"@xterm/addon-fit": "catalog:",
|
||||
"@xterm/xterm": "catalog:",
|
||||
"adm-zip": "catalog:",
|
||||
"ai": "catalog:",
|
||||
"assistant-stream": "catalog:",
|
||||
"babel-plugin-react-compiler": "catalog:",
|
||||
"better-sqlite3": "catalog:",
|
||||
"chroma-js": "catalog:",
|
||||
@@ -89,10 +97,9 @@
|
||||
"isomorphic-dompurify": "catalog:",
|
||||
"jotai": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mantine-datatable": "catalog:",
|
||||
"mantine-react-table": "catalog:",
|
||||
"mcp-handler": "catalog:",
|
||||
"mermaid": "catalog:",
|
||||
"next": "catalog:",
|
||||
"postcss-preset-mantine": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
@@ -100,11 +107,14 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-error-boundary": "catalog:",
|
||||
"react-shiki": "catalog:",
|
||||
"react-simple-code-editor": "catalog:",
|
||||
"remark-breaks": "catalog:",
|
||||
"remark-directive": "catalog:",
|
||||
"remark-gfm": "catalog:",
|
||||
"sass": "catalog:",
|
||||
"sql.js": "catalog:",
|
||||
"superjson": "catalog:",
|
||||
"trpc-to-mcp": "catalog:",
|
||||
"use-deep-compare-effect": "catalog:",
|
||||
"yaml": "catalog:",
|
||||
"zod": "catalog:"
|
||||
|
||||
@@ -27,6 +27,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { AssistantProvider } from "~/components/assistant/assistant-provider";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
import { env } from "~/env";
|
||||
|
||||
@@ -131,6 +132,7 @@ export default async function Layout(props: {
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
(innerProps) => <AssistantProvider {...innerProps} />,
|
||||
]);
|
||||
|
||||
const { locale } = await props.params;
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
PasswordInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconDatabaseSearch,
|
||||
IconKey,
|
||||
IconPlus,
|
||||
IconRobot,
|
||||
IconShieldLock,
|
||||
IconTrash,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
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 [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [clearApiKey, setClearApiKey] = useState(false);
|
||||
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
|
||||
const [clearHeaders, setClearHeaders] = useState(false);
|
||||
const [nextHeaderId, setNextHeaderId] = useState(1);
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setProvider(configuration?.provider ?? "openrouter");
|
||||
setBaseUrl(configuration?.baseUrl ?? providerPresets.openrouter.baseUrl);
|
||||
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
|
||||
setModelId(configuration?.modelId ?? "");
|
||||
setEnabled(configuration?.enabled ?? false);
|
||||
}, [configuration]);
|
||||
|
||||
const {
|
||||
data: models,
|
||||
error: discoveryError,
|
||||
isFetching: isDiscovering,
|
||||
refetch: discoverModels,
|
||||
} = clientApi.assistant.discoverModels.useQuery(undefined, {
|
||||
enabled: configuration?.connectionConfigured === true && configuration.modelDiscoveryPath !== null,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() =>
|
||||
(models ?? []).map((model) => ({
|
||||
value: model.id,
|
||||
label: model.name === model.id ? model.id : `${model.name} (${model.id})`,
|
||||
})),
|
||||
[models],
|
||||
);
|
||||
|
||||
const destinationChanged =
|
||||
configuration?.connectionConfigured === true &&
|
||||
(configuration.provider !== provider || configuration.baseUrl !== baseUrl.trim().replace(/\/$/, ""));
|
||||
const preset = providerPresets[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 connectionValid =
|
||||
baseUrl.trim().length > 0 && headerValuesValid && (!preset.requiresApiKey || hasEffectiveApiKey);
|
||||
|
||||
const updateConnection = clientApi.assistant.updateConnection.useMutation({
|
||||
onSuccess: async ({ credentialsClearedForDestinationChange }) => {
|
||||
setApiKey("");
|
||||
setClearApiKey(false);
|
||||
setHeaders([]);
|
||||
setClearHeaders(false);
|
||||
await Promise.all([
|
||||
utils.assistant.getAdminConfiguration.invalidate(),
|
||||
utils.assistant.discoverModels.invalidate(),
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: t("notification.connectionSaved.title"),
|
||||
message: credentialsClearedForDestinationChange
|
||||
? t("notification.connectionSaved.credentialsCleared")
|
||||
: t("notification.connectionSaved.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
|
||||
});
|
||||
|
||||
const saveConfiguration = clientApi.assistant.updateConfiguration.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.assistant.getAdminConfiguration.invalidate(),
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: t("notification.saved.title"),
|
||||
message: t("notification.saved.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
|
||||
});
|
||||
|
||||
const clearCredentials = clientApi.assistant.clearCredentials.useMutation({
|
||||
onSuccess: async () => {
|
||||
setApiKey("");
|
||||
setHeaders([]);
|
||||
setModelId("");
|
||||
setEnabled(false);
|
||||
await Promise.all([
|
||||
utils.assistant.getAdminConfiguration.invalidate(),
|
||||
utils.assistant.discoverModels.invalidate(),
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: t("notification.credentialsCleared.title"),
|
||||
message: t("notification.credentialsCleared.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
|
||||
});
|
||||
|
||||
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];
|
||||
setProvider(nextProvider);
|
||||
setBaseUrl(nextPreset.baseUrl);
|
||||
setModelDiscoveryPath(nextPreset.discoveryPath);
|
||||
setEnabled(false);
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
setHeaders((current) => [...current, { id: nextHeaderId, name: "", value: "" }]);
|
||||
setNextHeaderId((current) => current + 1);
|
||||
setClearHeaders(false);
|
||||
};
|
||||
|
||||
const updateHeader = (id: number, field: "name" | "value", value: string) => {
|
||||
setHeaders((current) => current.map((header) => (header.id === id ? { ...header, [field]: value } : header)));
|
||||
};
|
||||
|
||||
const saveConnection = () => {
|
||||
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,
|
||||
clearApiKey,
|
||||
customHeaders,
|
||||
clearCustomHeaders: clearHeaders,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="sm">
|
||||
<IconRobot size={26} />
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
</Group>
|
||||
<Text c="dimmed">{t("description")}</Text>
|
||||
|
||||
<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>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<Select
|
||||
label={t("provider.title")}
|
||||
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`),
|
||||
}))}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("baseUrl.title")}
|
||||
description={t("baseUrl.description")}
|
||||
leftSection={<IconWorld size={16} />}
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.currentTarget.value)}
|
||||
placeholder="https://provider.example/v1"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label={t("model.discoveryPath")}
|
||||
description={t("model.discoveryPathDescription")}
|
||||
value={modelDiscoveryPath}
|
||||
onChange={(event) => setModelDiscoveryPath(event.currentTarget.value)}
|
||||
placeholder="/models"
|
||||
/>
|
||||
|
||||
{destinationChanged && (
|
||||
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("destinationChanged.title")}>
|
||||
{t("destinationChanged.description")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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
|
||||
value={apiKey}
|
||||
onChange={(event) => {
|
||||
setApiKey(event.currentTarget.value);
|
||||
setClearApiKey(false);
|
||||
}}
|
||||
leftSection={<IconKey size={16} />}
|
||||
placeholder={configuration?.apiKeyConfigured ? t("apiKey.configured") : t("apiKey.placeholder")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{configuration?.apiKeyConfigured && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color={clearApiKey ? "gray" : "red"}
|
||||
onClick={() => setClearApiKey(!clearApiKey)}
|
||||
>
|
||||
{clearApiKey ? t("apiKey.keep") : t("apiKey.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</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 && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color={clearHeaders ? "gray" : "red"}
|
||||
onClick={() => setClearHeaders(!clearHeaders)}
|
||||
>
|
||||
{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={!configuration?.connectionConfigured || configuration.modelDiscoveryPath === null}
|
||||
onClick={() => void discoverModels()}
|
||||
>
|
||||
{t("model.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Autocomplete
|
||||
value={modelId}
|
||||
onChange={setModelId}
|
||||
data={modelOptions}
|
||||
placeholder={t("model.placeholder")}
|
||||
description={
|
||||
discoveryError
|
||||
? t("model.manualFallback")
|
||||
: models
|
||||
? t("model.discovered", { count: models.length })
|
||||
: t("model.manual")
|
||||
}
|
||||
limit={100}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.currentTarget.checked)}
|
||||
disabled={!configuration?.connectionConfigured || modelId.trim().length === 0}
|
||||
label={t("enabled.label")}
|
||||
description={t("enabled.description")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={() => saveConfiguration.mutate({ enabled, modelId: modelId.trim() })}
|
||||
disabled={!configuration?.connectionConfigured || modelId.trim().length === 0}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import type { ToolSet, UIMessage } from "ai";
|
||||
import { convertToModelMessages, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
||||
import { parse, stringify } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
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 { extractMcpTools } from "../../mcp/_extract-tools";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const logger = createLogger({ module: "assistant" });
|
||||
|
||||
const requestSchema = z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
messages: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().min(1).max(128),
|
||||
role: z.enum(["user", "assistant"]),
|
||||
parts: z.array(z.unknown()).max(200),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.max(200),
|
||||
tools: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
description: z.string().optional(),
|
||||
parameters: z.record(z.string(), z.unknown()),
|
||||
providerOptions: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.refine((tools) => tools === undefined || Object.keys(tools).length <= 16)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const browserToolDefinitions = {
|
||||
navigate_to_route: {
|
||||
description: "Navigate the current Homarr tab to a safe internal route.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string", description: "An internal Homarr path beginning with /" } },
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
open_command_menu: {
|
||||
description: "Open Homarr's command and search menu.",
|
||||
parameters: { type: "object", properties: {}, additionalProperties: false },
|
||||
},
|
||||
open_media_request_search: {
|
||||
description: "Open Homarr's media request search interface.",
|
||||
parameters: { type: "object", properties: {}, additionalProperties: false },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const assistantInstructions = `You are Homarr Assistant, embedded in the user's self-hosted Homarr dashboard.
|
||||
|
||||
Use the available Homarr tools whenever live instance data is needed. Never invent integrations, boards, apps, users, media, system status, or action results.
|
||||
|
||||
Homarr concepts:
|
||||
- Integrations connect Homarr to services such as Sonarr, Radarr, Plex, Jellyfin, Home Assistant, download clients, DNS filters, and monitoring systems.
|
||||
- Boards are customizable dashboards. Apps are visual links on boards. Widgets show live integration data.
|
||||
- Tool inputs that require an integrationId must use an id returned by an integration discovery tool.
|
||||
- Existing Homarr permissions are authoritative. If a tool denies access, explain that the current user lacks permission without suggesting a bypass.
|
||||
|
||||
Action rules:
|
||||
- Prefer read-only tools before actions.
|
||||
- Mutating Homarr tools require user approval. Present the exact intended change and wait for the approval flow.
|
||||
- Do not retry a denied action.
|
||||
- Browser tools can navigate within Homarr or open existing Homarr UI. Never navigate to an arbitrary external URL.
|
||||
- Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON.
|
||||
- If configuration or a service is unavailable, say what the user or administrator can do next.`;
|
||||
|
||||
const safeToolError = (error: unknown) => {
|
||||
if (error instanceof Error && "code" in error) {
|
||||
switch ((error as Error & { code: string }).code) {
|
||||
case "UNAUTHORIZED":
|
||||
case "FORBIDDEN":
|
||||
return "You do not have permission to perform this action.";
|
||||
case "NOT_FOUND":
|
||||
return "The requested resource was not found.";
|
||||
case "BAD_REQUEST":
|
||||
return "The tool input was not valid.";
|
||||
case "TOO_MANY_REQUESTS":
|
||||
return "The operation is rate limited. Try again later.";
|
||||
}
|
||||
}
|
||||
return "The Homarr tool could not complete this request.";
|
||||
};
|
||||
|
||||
const getProcedureTypeMap = () => {
|
||||
const procedures = (
|
||||
mcpRouter as unknown as {
|
||||
["_def"]: { procedures: Record<string, { ["_def"]?: { type?: string } }> };
|
||||
}
|
||||
)["_def"].procedures;
|
||||
return new Map(
|
||||
Object.entries(procedures).flatMap(([path, procedure]) => {
|
||||
const type = procedure["_def"]?.type;
|
||||
return type === "query" || type === "mutation" ? [[path, type] as const] : [];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > 2_000_000) {
|
||||
return Response.json({ error: "The assistant request is too large." }, { status: 413 });
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return Response.json({ error: "Sign in to use Homarr Assistant." }, { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.text();
|
||||
if (requestBody.length > 2_000_000) {
|
||||
return Response.json({ error: "The assistant request is too large." }, { status: 413 });
|
||||
}
|
||||
const parsed = requestSchema.safeParse(
|
||||
(() => {
|
||||
try {
|
||||
return JSON.parse(requestBody) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: "The assistant request was invalid." }, { status: 400 });
|
||||
}
|
||||
|
||||
const configuration = await db.query.assistantConfigurations.findFirst({
|
||||
where: eq(assistantConfigurations.id, "default"),
|
||||
});
|
||||
const requiresApiKey = configuration?.provider === "openrouter" || configuration?.provider === "openai";
|
||||
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
|
||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
const thread = await db.query.assistantThreads.findFirst({
|
||||
where: and(
|
||||
eq(assistantThreads.id, parsed.data.id),
|
||||
eq(assistantThreads.userId, session.user.id),
|
||||
eq(assistantThreads.status, "regular"),
|
||||
),
|
||||
});
|
||||
if (!thread) {
|
||||
return Response.json({ error: "Conversation not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
const context = createTRPCContext({ headers: request.headers, session });
|
||||
const caller = mcpRouter.createCaller(context);
|
||||
const procedureTypes = getProcedureTypeMap();
|
||||
|
||||
const homarrTools = Object.fromEntries(
|
||||
extractMcpTools().map((mcpTool) => {
|
||||
const procedurePath = mcpTool.pathInRouter.join(".");
|
||||
const isMutation = procedureTypes.get(procedurePath) === "mutation";
|
||||
return [
|
||||
mcpTool.name,
|
||||
tool({
|
||||
description: mcpTool.description,
|
||||
inputSchema: jsonSchema(
|
||||
(mcpTool.inputSchema ?? { type: "object", properties: {} }) as Parameters<typeof jsonSchema>[0],
|
||||
),
|
||||
needsApproval: isMutation,
|
||||
execute: async (input) => {
|
||||
try {
|
||||
const procedure = mcpTool.pathInRouter.reduce<unknown>(
|
||||
(current, segment) =>
|
||||
(typeof current === "object" || typeof current === "function") && current !== null
|
||||
? (current as Record<string, unknown>)[segment]
|
||||
: undefined,
|
||||
caller,
|
||||
);
|
||||
if (typeof procedure !== "function") {
|
||||
throw new Error("Procedure not callable");
|
||||
}
|
||||
const result = await (procedure as (value: unknown) => Promise<unknown>)(
|
||||
input && Object.keys(input as object).length > 0 ? input : undefined,
|
||||
);
|
||||
return parse(stringify(result));
|
||||
} catch (error) {
|
||||
logger.error("Assistant tool call failed", {
|
||||
toolName: mcpTool.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { error: safeToolError(error) };
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
}),
|
||||
) satisfies ToolSet;
|
||||
|
||||
const frontendTools = Object.fromEntries(
|
||||
Object.keys(parsed.data.tools ?? {}).flatMap((name) => {
|
||||
if (!(name in browserToolDefinitions)) return [];
|
||||
const definition = browserToolDefinitions[name as keyof typeof browserToolDefinitions];
|
||||
return [
|
||||
[
|
||||
name,
|
||||
tool({
|
||||
description: definition.description,
|
||||
inputSchema: jsonSchema(definition.parameters as Parameters<typeof jsonSchema>[0]),
|
||||
}),
|
||||
] as const,
|
||||
];
|
||||
}),
|
||||
) satisfies ToolSet;
|
||||
|
||||
try {
|
||||
const customHeaders = configuration.encryptedHeaders
|
||||
? z.record(z.string(), z.string()).parse(JSON.parse(decryptSecret(configuration.encryptedHeaders)))
|
||||
: {};
|
||||
const providerHeaders = {
|
||||
...(configuration.provider === "openrouter"
|
||||
? { "HTTP-Referer": "https://homarr.dev", "X-Title": "Homarr Assistant" }
|
||||
: {}),
|
||||
...customHeaders,
|
||||
};
|
||||
const provider = createOpenAICompatible({
|
||||
name: `homarr-${configuration.provider}`,
|
||||
baseURL: configuration.baseUrl,
|
||||
apiKey: configuration.encryptedApiKey ? decryptSecret(configuration.encryptedApiKey) : undefined,
|
||||
headers: providerHeaders,
|
||||
includeUsage: true,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: provider(configuration.modelId),
|
||||
instructions: assistantInstructions,
|
||||
messages: await convertToModelMessages(parsed.data.messages as UIMessage[]),
|
||||
tools: { ...homarrTools, ...frontendTools },
|
||||
stopWhen: stepCountIs(8),
|
||||
abortSignal: request.signal,
|
||||
timeout: { totalMs: 55_000, stepMs: 30_000, toolMs: 30_000 },
|
||||
maxRetries: 2,
|
||||
experimental_toolApprovalSecret: env.SECRET_ENCRYPTION_KEY,
|
||||
onFinish: async () => {
|
||||
await db
|
||||
.update(assistantThreads)
|
||||
.set({ modelId: configuration.modelId, updatedAt: new Date() })
|
||||
.where(eq(assistantThreads.id, thread.id));
|
||||
},
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "The configured model endpoint could not start this response. Check its URL, model, and credentials." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
.drawer {
|
||||
--assistant-panel-width: min(32rem, 100vw);
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100dvh;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
height: calc(100dvh - 4.25rem);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history {
|
||||
width: 15rem;
|
||||
flex: 0 0 15rem;
|
||||
border-inline-end: 1px solid var(--mantine-color-default-border);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 92%, var(--mantine-color-gray-6));
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.historyItem {
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.historyItem:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.thread {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.messages {
|
||||
width: 100%;
|
||||
max-width: 52rem;
|
||||
margin-inline: auto;
|
||||
padding: 1.25rem 1rem 8.75rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
width: fit-content;
|
||||
max-width: 90%;
|
||||
margin-inline-start: auto;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 1rem 1rem 0.3rem 1rem;
|
||||
background: var(--mantine-color-red-light);
|
||||
}
|
||||
|
||||
.assistantMessage {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.messageText {
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tool {
|
||||
margin-block: 0.5rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.composerWrap {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
padding: 2.5rem 1rem 1rem;
|
||||
background: var(--mantine-color-body);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 52rem;
|
||||
margin-inline: auto;
|
||||
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);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.composerInput {
|
||||
min-height: 2.5rem;
|
||||
max-height: 12rem;
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0.55rem 0.65rem;
|
||||
color: var(--mantine-color-text);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
min-height: 60vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobileHistoryButton,
|
||||
.historyClose {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.history {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
width: min(20rem, 88vw);
|
||||
transform: translateX(-105%);
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
}
|
||||
|
||||
[dir="rtl"] .history {
|
||||
transform: translateX(105%);
|
||||
}
|
||||
|
||||
.history[data-opened="true"] {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobileHistoryButton {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 0.65rem;
|
||||
inset-inline-start: 0.65rem;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.historyClose {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import {
|
||||
ActionBarPrimitive,
|
||||
ComposerPrimitive,
|
||||
MessagePartPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadListItemPrimitive,
|
||||
ThreadListPrimitive,
|
||||
ThreadPrimitive,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import { ActionIcon, Box, Button, Drawer, Group, ScrollArea, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArchive,
|
||||
IconArrowUp,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconMenu2,
|
||||
IconHistory,
|
||||
IconPlayerStop,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconRestore,
|
||||
IconRobot,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
|
||||
interface AssistantPanelProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TextPart = () => <MessagePartPrimitive.Text className={classes.messageText} />;
|
||||
|
||||
const ToolPart = ({
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
isError,
|
||||
status,
|
||||
approval,
|
||||
respondToApproval,
|
||||
}: ToolCallMessagePartProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const completed = status?.type === "complete";
|
||||
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
|
||||
const denied = approval?.approved === false;
|
||||
const failed =
|
||||
!denied &&
|
||||
(isError === true ||
|
||||
status?.type === "incomplete" ||
|
||||
(typeof result === "object" && result !== null && "error" in result));
|
||||
const successful = completed && !denied && !failed;
|
||||
return (
|
||||
<Box className={classes.tool}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size="sm"
|
||||
radius="xl"
|
||||
variant="light"
|
||||
color={denied || failed ? "red" : successful ? "green" : "gray"}
|
||||
>
|
||||
{denied || failed ? <IconX size={13} /> : successful ? <IconCheck size={13} /> : <IconRobot size={13} />}
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{toolName.replaceAll("_", " ")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{awaitingApproval
|
||||
? t("approvalRequired")
|
||||
: denied
|
||||
? t("denied")
|
||||
: failed
|
||||
? t("failed")
|
||||
: successful
|
||||
? t("complete")
|
||||
: t("working")}
|
||||
</Text>
|
||||
</Group>
|
||||
{awaitingApproval && (
|
||||
<Stack gap="xs" mt="sm">
|
||||
<Text size="sm">{t("approvalDescription")}</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{JSON.stringify(args)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-sm" onClick={() => respondToApproval({ approved: true })}>
|
||||
{t("allowOnce")}
|
||||
</Button>
|
||||
<Button size="compact-sm" variant="default" onClick={() => respondToApproval({ approved: false })}>
|
||||
{t("deny")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{!awaitingApproval && !denied && result !== undefined && (
|
||||
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
|
||||
{typeof result === "string" ? result : JSON.stringify(result)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const MessageActions = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<ActionBarPrimitive.Root hideWhenRunning>
|
||||
<Group gap={2}>
|
||||
<Tooltip label={t("copy")}>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<IconCopy size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Copy>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("regenerate")}>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<IconRefresh size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const UserMessage = () => (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessage}`}>
|
||||
<MessagePrimitive.Parts components={{ Text: TextPart }} />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
|
||||
const AssistantMessage = () => (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: TextPart,
|
||||
tools: { Fallback: ToolPart },
|
||||
}}
|
||||
/>
|
||||
<MessageActions />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
|
||||
const ThreadListItem = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ThreadListItemPrimitive.Trigger asChild>
|
||||
<Button variant="subtle" color="gray" size="compact-sm" justify="flex-start" flex={1} style={{ minWidth: 0 }}>
|
||||
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
||||
</Button>
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
<ThreadListItemPrimitive.Archive asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("archive")}>
|
||||
<IconArchive size={14} />
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Archive>
|
||||
<ThreadListItemPrimitive.Delete asChild>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Delete>
|
||||
</Group>
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ArchivedThreadListItem = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ThreadListItemPrimitive.Trigger asChild>
|
||||
<Button variant="subtle" color="gray" size="compact-sm" justify="flex-start" flex={1} style={{ minWidth: 0 }}>
|
||||
<ThreadListItemPrimitive.Title fallback={t("archivedConversation")} />
|
||||
</Button>
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
<ThreadListItemPrimitive.Unarchive asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("restore")}>
|
||||
<IconRestore size={14} />
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Unarchive>
|
||||
<ThreadListItemPrimitive.Delete asChild>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Delete>
|
||||
</Group>
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadHistory = ({ opened, onClose }: { opened: boolean; onClose: () => void }) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<Stack className={classes.history} gap="xs" p="sm" data-opened={opened}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onClose}>
|
||||
{t("newConversation")}
|
||||
</Button>
|
||||
</ThreadListPrimitive.New>
|
||||
<ActionIcon
|
||||
className={classes.historyClose}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
aria-label={t("closeHistory")}
|
||||
>
|
||||
<IconX size={17} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group gap="xs" px="xs" mt="xs">
|
||||
<IconHistory size={14} />
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
||||
{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">
|
||||
{t("archived")}
|
||||
</Text>
|
||||
<ThreadListPrimitive.Items archived components={{ ThreadListItem: ArchivedThreadListItem }} />
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyThread = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
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>
|
||||
</Box>
|
||||
</ThreadPrimitive.Empty>
|
||||
);
|
||||
};
|
||||
|
||||
const Composer = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const running = useAuiState((state) => state.thread.isRunning);
|
||||
return (
|
||||
<Box className={classes.composerWrap}>
|
||||
<ComposerPrimitive.Root className={classes.composer}>
|
||||
<ComposerPrimitive.Input className={classes.composerInput} placeholder={t("composerPlaceholder")} rows={1} />
|
||||
{running ? (
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<ActionIcon color="red" variant="light" size="lg" aria-label={t("stop")}>
|
||||
<IconPlayerStop size={18} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.Cancel>
|
||||
) : (
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<ActionIcon color="red" variant="filled" size="lg" aria-label={t("send")}>
|
||||
<IconArrowUp size={18} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.Send>
|
||||
)}
|
||||
</ComposerPrimitive.Root>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssistantPanel = ({ opened, onClose }: AssistantPanelProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const [historyOpened, setHistoryOpened] = useState(false);
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
position="right"
|
||||
size="min(52rem, 100vw)"
|
||||
zIndex={300}
|
||||
className={classes.drawer}
|
||||
classNames={{ content: classes.content, body: classes.body }}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="red" radius="xl">
|
||||
<IconRobot size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} lh={1.1}>
|
||||
{t("title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
closeButtonProps={{ icon: <IconX size={18} />, "aria-label": t("close") }}
|
||||
>
|
||||
<Box className={classes.shell}>
|
||||
<ThreadHistory opened={historyOpened} onClose={() => setHistoryOpened(false)} />
|
||||
<ThreadPrimitive.Root className={classes.thread}>
|
||||
<ActionIcon
|
||||
className={classes.mobileHistoryButton}
|
||||
variant="default"
|
||||
onClick={() => setHistoryOpened(true)}
|
||||
aria-label={t("openHistory")}
|
||||
>
|
||||
<IconMenu2 size={17} />
|
||||
</ActionIcon>
|
||||
<ThreadPrimitive.Viewport className={classes.viewport}>
|
||||
<Box className={classes.messages}>
|
||||
<EmptyThread />
|
||||
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
||||
</Box>
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
bottom={110}
|
||||
left="50%"
|
||||
variant="default"
|
||||
radius="xl"
|
||||
aria-label={t("scrollToLatest")}
|
||||
>
|
||||
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
|
||||
</ActionIcon>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
</ThreadPrimitive.Viewport>
|
||||
<Composer />
|
||||
</ThreadPrimitive.Root>
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import type { MessageFormatAdapter, RemoteThreadListAdapter, ThreadHistoryAdapter, Toolkit } from "@assistant-ui/react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
defineToolkit,
|
||||
Tools,
|
||||
useAui,
|
||||
useAuiState,
|
||||
useRemoteThreadListRuntime,
|
||||
} from "@assistant-ui/react";
|
||||
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { createAssistantStream } from "assistant-stream";
|
||||
import type { UIMessage } from "ai";
|
||||
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
openMediaRequestSearch,
|
||||
openSpotlight,
|
||||
useRegisterSpotlightContextActions,
|
||||
useRegisterSpotlightContextResults,
|
||||
} from "@homarr/spotlight";
|
||||
|
||||
import { AssistantPanel } from "./assistant-panel";
|
||||
|
||||
interface AssistantContextValue {
|
||||
enabled: boolean;
|
||||
opened: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||
|
||||
export const useHomarrAssistant = () => {
|
||||
const value = useContext(AssistantContext);
|
||||
if (!value) {
|
||||
throw new Error("useHomarrAssistant must be used within AssistantProvider");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const useOptionalHomarrAssistant = () => useContext(AssistantContext);
|
||||
|
||||
const threadAdapter: RemoteThreadListAdapter = {
|
||||
async list() {
|
||||
const threads = await fetchApi.assistant.listThreads.query();
|
||||
return {
|
||||
threads: threads.map((thread) => ({
|
||||
remoteId: thread.id,
|
||||
status: thread.status,
|
||||
title: thread.title ?? undefined,
|
||||
lastMessageAt: thread.updatedAt,
|
||||
custom: { modelId: thread.modelId },
|
||||
})),
|
||||
};
|
||||
},
|
||||
async initialize(localId) {
|
||||
const thread = await fetchApi.assistant.createThread.mutate({ localId });
|
||||
return { remoteId: thread.id, externalId: undefined };
|
||||
},
|
||||
async fetch(threadId) {
|
||||
const { thread } = await fetchApi.assistant.getThread.query({ threadId });
|
||||
return {
|
||||
remoteId: thread.id,
|
||||
status: thread.status,
|
||||
title: thread.title ?? undefined,
|
||||
lastMessageAt: thread.updatedAt,
|
||||
custom: { modelId: thread.modelId },
|
||||
};
|
||||
},
|
||||
async rename(threadId, title) {
|
||||
await fetchApi.assistant.renameThread.mutate({ threadId, title });
|
||||
},
|
||||
async archive(threadId) {
|
||||
await fetchApi.assistant.setThreadStatus.mutate({ threadId, status: "archived" });
|
||||
},
|
||||
async unarchive(threadId) {
|
||||
await fetchApi.assistant.setThreadStatus.mutate({ threadId, status: "regular" });
|
||||
},
|
||||
async delete(threadId) {
|
||||
await fetchApi.assistant.deleteThread.mutate({ threadId });
|
||||
},
|
||||
async generateTitle(_threadId, messages) {
|
||||
const firstUserMessage = messages.find((message) => message.role === "user");
|
||||
const text =
|
||||
firstUserMessage?.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
.trim()
|
||||
.slice(0, 72) || "New conversation";
|
||||
return createAssistantStream((controller) => controller.appendText(text));
|
||||
},
|
||||
};
|
||||
|
||||
const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapter => ({
|
||||
async load() {
|
||||
return { messages: [] };
|
||||
},
|
||||
async append() {
|
||||
// AI SDK runtimes use the typed adapter returned by withFormat below.
|
||||
},
|
||||
withFormat<TMessage, TStorageFormat extends Record<string, unknown>>(
|
||||
formatAdapter: MessageFormatAdapter<TMessage, TStorageFormat>,
|
||||
) {
|
||||
if (formatAdapter.format !== "ai-sdk/v6") {
|
||||
throw new Error(`Unsupported assistant message format: ${formatAdapter.format}`);
|
||||
}
|
||||
const storageFormat = formatAdapter.format;
|
||||
return {
|
||||
async load() {
|
||||
if (!threadId) return { messages: [] };
|
||||
const { messages } = await fetchApi.assistant.getThread.query({ threadId });
|
||||
return {
|
||||
messages: messages
|
||||
.filter((message) => message.format === formatAdapter.format)
|
||||
.map(
|
||||
(message) =>
|
||||
formatAdapter.decode({
|
||||
id: message.id,
|
||||
parent_id: message.parentId,
|
||||
format: message.format,
|
||||
content: message.content as TStorageFormat,
|
||||
}) as { parentId: string | null; message: TMessage },
|
||||
),
|
||||
};
|
||||
},
|
||||
async append(item) {
|
||||
if (!threadId) return;
|
||||
await fetchApi.assistant.appendMessage.mutate({
|
||||
threadId,
|
||||
id: (formatAdapter as MessageFormatAdapter<unknown, TStorageFormat>).getId(item.message),
|
||||
parentId: item.parentId,
|
||||
format: storageFormat,
|
||||
content: (formatAdapter as MessageFormatAdapter<unknown, TStorageFormat>).encode(
|
||||
item as { parentId: string | null; message: unknown },
|
||||
),
|
||||
});
|
||||
},
|
||||
async update(item, localMessageId) {
|
||||
if (!threadId) return;
|
||||
const erasedFormatAdapter = formatAdapter as MessageFormatAdapter<unknown, TStorageFormat>;
|
||||
const id = erasedFormatAdapter.getId(item.message);
|
||||
await fetchApi.assistant.appendMessage.mutate({
|
||||
threadId,
|
||||
id,
|
||||
parentId: item.parentId,
|
||||
format: storageFormat,
|
||||
content: erasedFormatAdapter.encode(item as { parentId: string | null; message: unknown }),
|
||||
});
|
||||
if (localMessageId !== id) {
|
||||
await fetchApi.assistant.deleteMessages.mutate({ threadId, ids: [localMessageId] });
|
||||
}
|
||||
},
|
||||
async delete(items) {
|
||||
if (!threadId || items.length === 0) return;
|
||||
await fetchApi.assistant.deleteMessages.mutate({
|
||||
threadId,
|
||||
ids: items.map((item) =>
|
||||
(formatAdapter as MessageFormatAdapter<unknown, TStorageFormat>).getId(item.message),
|
||||
),
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const AssistantThreadRuntime = () => {
|
||||
const localThreadId = useAuiState((state) => state.threadListItem.id);
|
||||
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const aui = useAui();
|
||||
const transport = useMemo(() => new AssistantChatTransport({ api: "/api/assistant/chat" }), []);
|
||||
const chat = useChat<UIMessage>({
|
||||
id: localThreadId,
|
||||
transport,
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
|
||||
});
|
||||
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
|
||||
const runtime = useAISDKRuntime(chat, { adapters: { history } });
|
||||
|
||||
transport.setRuntime(runtime);
|
||||
transport["__internal_setGetThreadListItem"](() => (aui.threadListItem.source ? aui.threadListItem() : undefined));
|
||||
|
||||
return runtime;
|
||||
};
|
||||
|
||||
const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
const router = useRouter();
|
||||
const runtime = useRemoteThreadListRuntime({
|
||||
adapter: threadAdapter,
|
||||
runtimeHook: AssistantThreadRuntime,
|
||||
});
|
||||
|
||||
const toolkit = useMemo(
|
||||
() =>
|
||||
defineToolkit({
|
||||
navigate_to_route: {
|
||||
type: "frontend",
|
||||
description:
|
||||
"Navigate the current Homarr tab to a safe internal route. Only paths beginning with a single slash are accepted.",
|
||||
parameters: z.object({
|
||||
path: z.string().describe("An internal Homarr route, for example /manage/apps"),
|
||||
}),
|
||||
execute: async ({ path }) => {
|
||||
if (!path.startsWith("/") || path.startsWith("//")) {
|
||||
return { success: false, error: "Only internal Homarr paths are allowed." };
|
||||
}
|
||||
router.push(path);
|
||||
return { success: true, path };
|
||||
},
|
||||
renderText: {
|
||||
running: ({ args }) => `Opening ${args.path}…`,
|
||||
complete: ({ args }) => `Opened ${args.path}`,
|
||||
},
|
||||
},
|
||||
open_command_menu: {
|
||||
type: "frontend",
|
||||
description: "Open Homarr's command and search menu.",
|
||||
parameters: z.object({}),
|
||||
execute: async () => {
|
||||
openSpotlight();
|
||||
return { success: true };
|
||||
},
|
||||
renderText: { running: "Opening command menu…", complete: "Command menu opened" },
|
||||
},
|
||||
open_media_request_search: {
|
||||
type: "frontend",
|
||||
description: "Open Homarr's media request search interface.",
|
||||
parameters: z.object({}),
|
||||
execute: async () => {
|
||||
openMediaRequestSearch();
|
||||
return { success: true };
|
||||
},
|
||||
renderText: { running: "Opening media search…", complete: "Media search opened" },
|
||||
},
|
||||
}) as Toolkit,
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<AssistantTools toolkit={toolkit} />
|
||||
{children}
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantTools = ({ toolkit }: { toolkit: Toolkit }) => {
|
||||
useAui({ tools: Tools({ toolkit }) });
|
||||
return null;
|
||||
};
|
||||
|
||||
export const AssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const session = useSession();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { data } = clientApi.assistant.getAvailability.useQuery(undefined, {
|
||||
enabled: session.status === "authenticated",
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const enabled = Boolean(data?.enabled && session.status === "authenticated");
|
||||
|
||||
const open = useCallback(() => setOpened(true), []);
|
||||
const close = useCallback(() => setOpened(false), []);
|
||||
const toggle = useCallback(() => setOpened((current) => !current), []);
|
||||
useHotkeys([[hotkeys.openAssistant, open]]);
|
||||
|
||||
const spotlightItem = useMemo(
|
||||
() => ({
|
||||
id: "homarr-assistant",
|
||||
name: t("spotlight"),
|
||||
icon: "/logo/logo.png",
|
||||
interaction: () => ({ type: "javaScript" as const, onSelect: open }),
|
||||
}),
|
||||
[open, t],
|
||||
);
|
||||
useRegisterSpotlightContextResults("homarr-assistant", enabled ? [spotlightItem] : [], [enabled, spotlightItem]);
|
||||
useRegisterSpotlightContextActions("homarr-assistant", enabled ? [spotlightItem] : [], [enabled, spotlightItem]);
|
||||
|
||||
const value = useMemo(() => ({ enabled, opened, open, close, toggle }), [close, enabled, open, opened, toggle]);
|
||||
|
||||
return (
|
||||
<AssistantContext.Provider value={value}>
|
||||
{enabled ? (
|
||||
<AssistantRuntime>
|
||||
{children}
|
||||
<AssistantPanel opened={opened} onClose={close} />
|
||||
</AssistantRuntime>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AssistantContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HeaderButton } from "../layout/header/button";
|
||||
import { useOptionalHomarrAssistant } from "./assistant-provider";
|
||||
|
||||
export const AssistantHeaderButton = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
if (!assistant?.enabled) return null;
|
||||
|
||||
return (
|
||||
<Tooltip label={`${t("title")} (Mod+Shift+K)`} position="bottom">
|
||||
<HeaderButton onClick={assistant.open} aria-label={t("open")}>
|
||||
<IconRobot size={20} />
|
||||
</HeaderButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { AssistantHeaderButton } from "../assistant/assistant-trigger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
import { UserButton } from "./header/user";
|
||||
@@ -37,6 +38,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
</TourTarget>
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{actions}
|
||||
<AssistantHeaderButton />
|
||||
<MobileSearchButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButton />
|
||||
|
||||
@@ -2,16 +2,26 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconBrandDocker, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||
import { Badge, Indicator, Kbd, Loader, Menu, Text } from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconRobot,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||
@@ -25,6 +35,12 @@ interface UserAvatarMenuProps {
|
||||
onOpenChange: (opened: boolean) => void;
|
||||
}
|
||||
|
||||
const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||
hotkey
|
||||
.split("+")
|
||||
.map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`))
|
||||
.join(" + ");
|
||||
|
||||
export const UserAvatarMenu = ({
|
||||
children,
|
||||
availableUpdates,
|
||||
@@ -37,6 +53,7 @@ export const UserAvatarMenu = ({
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
|
||||
const handleSignout = useCallback(async () => {
|
||||
const redirectUrl = logoutUrl ?? "/auth/login";
|
||||
@@ -65,6 +82,29 @@ export const UserAvatarMenu = ({
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</Text>
|
||||
) : (
|
||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||
)
|
||||
}
|
||||
onClick={assistant.open}
|
||||
>
|
||||
{t("assistant")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
@@ -94,7 +134,28 @@ export const UserAvatarMenu = ({
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>{children}</Menu.Target>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
inline
|
||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||
color="red"
|
||||
size={20}
|
||||
offset={4}
|
||||
label={
|
||||
assistant?.isRunning ? (
|
||||
<Loader type="bars" color="white" size={10} />
|
||||
) : assistant?.unreadCount ? (
|
||||
assistant.unreadCount > 99 ? (
|
||||
"99+"
|
||||
) : (
|
||||
assistant.unreadCount
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
assistant: lazy(() => import("./router/assistant").then((mod) => mod.assistantRouter)),
|
||||
user: lazy(() => import("./router/user").then((mod) => mod.userRouter)),
|
||||
group: lazy(() => import("./router/group").then((mod) => mod.groupRouter)),
|
||||
invite: lazy(() => import("./router/invite").then((mod) => mod.inviteRouter)),
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { parse, stringify } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
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 { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
const configurationId = "default";
|
||||
const providerSchema = z.enum(["openrouter", "openai", "ollama", "lm-studio", "custom"]);
|
||||
|
||||
const modelSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
contextLength: z.number().nullable(),
|
||||
promptPrice: z.string().nullable(),
|
||||
completionPrice: z.string().nullable(),
|
||||
toolSupport: z.enum(["confirmed", "unknown"]),
|
||||
});
|
||||
|
||||
type CompatibleModel = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
model?: unknown;
|
||||
description?: unknown;
|
||||
context_length?: unknown;
|
||||
supported_parameters?: unknown;
|
||||
architecture?: { output_modalities?: unknown };
|
||||
pricing?: { prompt?: unknown; completion?: unknown };
|
||||
};
|
||||
|
||||
type AssistantConfiguration = typeof assistantConfigurations.$inferSelect;
|
||||
|
||||
const customHeadersSchema = z
|
||||
.record(
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/),
|
||||
z
|
||||
.string()
|
||||
.max(2048)
|
||||
.refine((value) => !value.includes("\r") && !value.includes("\n")),
|
||||
)
|
||||
.refine((headers) => Object.keys(headers).length <= 20)
|
||||
.refine(
|
||||
(headers) =>
|
||||
!Object.keys(headers).some((name) =>
|
||||
["connection", "content-length", "host", "transfer-encoding"].includes(name.toLowerCase()),
|
||||
),
|
||||
"Hop-by-hop and transport headers cannot be configured.",
|
||||
);
|
||||
|
||||
const normalizeBaseUrl = (value: string) => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Enter a valid provider URL." });
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "The provider URL must use HTTP or HTTPS without credentials, query parameters, or fragments.",
|
||||
});
|
||||
}
|
||||
return url.toString().replace(/\/$/, "");
|
||||
};
|
||||
|
||||
const normalizeDiscoveryPath = (value: string | null) => {
|
||||
if (value === null || value.trim() === "") return null;
|
||||
const path = value.trim();
|
||||
if (!path.startsWith("/") || path.startsWith("//") || path.includes("?") || path.includes("#")) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "The model discovery path must be an absolute path such as /models.",
|
||||
});
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const getConfigurationAsync = async (db: Database) => {
|
||||
return await db.query.assistantConfigurations.findFirst({
|
||||
where: eq(assistantConfigurations.id, configurationId),
|
||||
});
|
||||
};
|
||||
|
||||
const decryptCustomHeaders = (encryptedHeaders: `${string}.${string}` | null) => {
|
||||
if (!encryptedHeaders) return {};
|
||||
try {
|
||||
return customHeadersSchema.parse(JSON.parse(decryptSecret(encryptedHeaders)));
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "The stored provider headers could not be decrypted.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderHeaders = (configuration: AssistantConfiguration) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (configuration.encryptedApiKey) {
|
||||
headers.Authorization = `Bearer ${decryptSecret(configuration.encryptedApiKey)}`;
|
||||
}
|
||||
if (configuration.provider === "openrouter") {
|
||||
headers["HTTP-Referer"] ??= "https://homarr.dev";
|
||||
headers["X-Title"] ??= "Homarr Assistant";
|
||||
}
|
||||
return { ...headers, ...decryptCustomHeaders(configuration.encryptedHeaders) };
|
||||
};
|
||||
|
||||
const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
|
||||
if (!configuration.modelDiscoveryPath) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "Model discovery is disabled for this provider. Enter a model ID manually.",
|
||||
});
|
||||
}
|
||||
|
||||
const endpoint = `${configuration.baseUrl}${configuration.modelDiscoveryPath}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
headers: getProviderHeaders(configuration),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "The model endpoint could not be reached from the Homarr server.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "The provider rejected the configured credentials." });
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: `Model discovery failed with status ${response.status}.`,
|
||||
});
|
||||
}
|
||||
|
||||
let body: { data?: CompatibleModel[]; models?: 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 ?? [];
|
||||
return models
|
||||
.filter((model) => {
|
||||
if (configuration.provider !== "openrouter") return true;
|
||||
const parameters = Array.isArray(model.supported_parameters) ? model.supported_parameters : [];
|
||||
const outputModalities = Array.isArray(model.architecture?.output_modalities)
|
||||
? model.architecture.output_modalities
|
||||
: [];
|
||||
return parameters.includes("tools") && (outputModalities.length === 0 || outputModalities.includes("text"));
|
||||
})
|
||||
.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;
|
||||
return [
|
||||
{
|
||||
id,
|
||||
name,
|
||||
description: typeof model.description === "string" ? model.description : null,
|
||||
contextLength: typeof model.context_length === "number" ? model.context_length : null,
|
||||
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),
|
||||
},
|
||||
];
|
||||
})
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
};
|
||||
|
||||
const ownedThreadAsync = async (db: Database, threadId: string, userId: string) => {
|
||||
const thread = await db.query.assistantThreads.findFirst({
|
||||
where: and(eq(assistantThreads.id, threadId), eq(assistantThreads.userId, userId)),
|
||||
});
|
||||
if (!thread) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Conversation not found." });
|
||||
}
|
||||
return thread;
|
||||
};
|
||||
|
||||
export const assistantRouter = createTRPCRouter({
|
||||
getAvailability: protectedProcedure.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
const requiresApiKey = configuration?.provider === "openrouter" || configuration?.provider === "openai";
|
||||
return {
|
||||
enabled: Boolean(
|
||||
configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
getAdminConfiguration: adminProcedure.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
return {
|
||||
connectionConfigured: Boolean(configuration),
|
||||
enabled: configuration?.enabled ?? false,
|
||||
provider: configuration?.provider ?? "openrouter",
|
||||
baseUrl: configuration?.baseUrl ?? "https://openrouter.ai/api/v1",
|
||||
modelDiscoveryPath: configuration?.modelDiscoveryPath ?? "/models",
|
||||
apiKeyConfigured: Boolean(configuration?.encryptedApiKey),
|
||||
customHeadersConfigured: Boolean(configuration?.encryptedHeaders),
|
||||
modelId: configuration?.modelId ?? null,
|
||||
updatedAt: configuration?.updatedAt ?? null,
|
||||
};
|
||||
}),
|
||||
|
||||
updateConnection: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
provider: providerSchema,
|
||||
baseUrl: z.string().trim().min(1).max(2048),
|
||||
modelDiscoveryPath: z.string().trim().max(512).nullable(),
|
||||
apiKey: z.string().trim().min(1).max(2048).optional(),
|
||||
clearApiKey: z.boolean().default(false),
|
||||
customHeaders: customHeadersSchema.optional(),
|
||||
clearCustomHeaders: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
||||
const modelDiscoveryPath = normalizeDiscoveryPath(input.modelDiscoveryPath);
|
||||
const existing = await getConfigurationAsync(ctx.db);
|
||||
const destinationChanged =
|
||||
existing !== undefined && (existing.provider !== input.provider || existing.baseUrl !== baseUrl);
|
||||
const encryptedApiKey = input.apiKey
|
||||
? encryptSecret(input.apiKey)
|
||||
: input.clearApiKey || destinationChanged
|
||||
? null
|
||||
: (existing?.encryptedApiKey ?? null);
|
||||
const encryptedHeaders = input.customHeaders
|
||||
? encryptSecret(JSON.stringify(input.customHeaders))
|
||||
: input.clearCustomHeaders || destinationChanged
|
||||
? null
|
||||
: (existing?.encryptedHeaders ?? null);
|
||||
|
||||
if (existing) {
|
||||
await ctx.db
|
||||
.update(assistantConfigurations)
|
||||
.set({
|
||||
provider: input.provider,
|
||||
baseUrl,
|
||||
modelDiscoveryPath,
|
||||
encryptedApiKey,
|
||||
encryptedHeaders,
|
||||
modelId: destinationChanged ? null : existing.modelId,
|
||||
enabled: destinationChanged ? false : existing.enabled,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(assistantConfigurations.id, configurationId));
|
||||
} else {
|
||||
await ctx.db.insert(assistantConfigurations).values({
|
||||
id: configurationId,
|
||||
provider: input.provider,
|
||||
baseUrl,
|
||||
modelDiscoveryPath,
|
||||
encryptedApiKey,
|
||||
encryptedHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apiKeyConfigured: Boolean(encryptedApiKey),
|
||||
customHeadersConfigured: Boolean(encryptedHeaders),
|
||||
credentialsClearedForDestinationChange: destinationChanged && !input.apiKey && !input.customHeaders,
|
||||
};
|
||||
}),
|
||||
|
||||
clearCredentials: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const existing = await getConfigurationAsync(ctx.db);
|
||||
if (!existing) return;
|
||||
await ctx.db
|
||||
.update(assistantConfigurations)
|
||||
.set({
|
||||
encryptedApiKey: null,
|
||||
encryptedHeaders: null,
|
||||
modelId: null,
|
||||
enabled: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(assistantConfigurations.id, configurationId));
|
||||
}),
|
||||
|
||||
discoverModels: adminProcedure.output(z.array(modelSchema)).query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
if (!configuration) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." });
|
||||
}
|
||||
return await fetchModelsAsync(configuration);
|
||||
}),
|
||||
|
||||
updateConfiguration: adminProcedure
|
||||
.input(z.object({ enabled: z.boolean(), modelId: z.string().trim().min(1).max(256) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
if (!configuration) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." });
|
||||
}
|
||||
const requiresApiKey = configuration.provider === "openrouter" || configuration.provider === "openai";
|
||||
if (input.enabled && requiresApiKey && !configuration.encryptedApiKey) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This provider requires an API key." });
|
||||
}
|
||||
await ctx.db
|
||||
.update(assistantConfigurations)
|
||||
.set({ enabled: input.enabled, modelId: input.modelId, updatedAt: new Date() })
|
||||
.where(eq(assistantConfigurations.id, configurationId));
|
||||
}),
|
||||
|
||||
listThreads: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.assistantThreads.findMany({
|
||||
where: eq(assistantThreads.userId, ctx.session.user.id),
|
||||
orderBy: desc(assistantThreads.updatedAt),
|
||||
limit: 100,
|
||||
});
|
||||
}),
|
||||
|
||||
createThread: protectedProcedure
|
||||
.input(z.object({ localId: z.string().max(128).optional() }).optional())
|
||||
.mutation(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
const id = createId();
|
||||
const now = new Date();
|
||||
await ctx.db.insert(assistantThreads).values({
|
||||
id,
|
||||
userId: ctx.session.user.id,
|
||||
modelId: configuration?.modelId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return { id };
|
||||
}),
|
||||
|
||||
getThread: protectedProcedure.input(z.object({ threadId: z.string().max(64) })).query(async ({ ctx, input }) => {
|
||||
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
||||
const messages = await ctx.db.query.assistantMessages.findMany({
|
||||
where: eq(assistantMessages.threadId, thread.id),
|
||||
orderBy: assistantMessages.createdAt,
|
||||
});
|
||||
return {
|
||||
thread,
|
||||
messages: messages.map((message) => ({
|
||||
id: message.id,
|
||||
parentId: message.parentId,
|
||||
format: message.format,
|
||||
content: parse(message.content),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
renameThread: protectedProcedure
|
||||
.input(z.object({ threadId: z.string().max(64), title: z.string().trim().min(1).max(120) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
||||
await ctx.db
|
||||
.update(assistantThreads)
|
||||
.set({ title: input.title, updatedAt: new Date() })
|
||||
.where(eq(assistantThreads.id, input.threadId));
|
||||
}),
|
||||
|
||||
setThreadStatus: protectedProcedure
|
||||
.input(z.object({ threadId: z.string().max(64), status: z.enum(["regular", "archived"]) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
||||
await ctx.db
|
||||
.update(assistantThreads)
|
||||
.set({ status: input.status, updatedAt: new Date() })
|
||||
.where(eq(assistantThreads.id, input.threadId));
|
||||
}),
|
||||
|
||||
deleteThread: protectedProcedure
|
||||
.input(z.object({ threadId: z.string().max(64) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
||||
await ctx.db.delete(assistantThreads).where(eq(assistantThreads.id, input.threadId));
|
||||
}),
|
||||
|
||||
appendMessage: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string().max(64),
|
||||
id: z.string().min(1).max(128),
|
||||
parentId: z.string().max(128).nullable(),
|
||||
format: z.literal("ai-sdk/v6"),
|
||||
content: z.unknown(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
||||
const existing = await ctx.db.query.assistantMessages.findFirst({
|
||||
where: and(eq(assistantMessages.id, input.id), eq(assistantMessages.threadId, thread.id)),
|
||||
});
|
||||
if (existing) {
|
||||
await ctx.db
|
||||
.update(assistantMessages)
|
||||
.set({ parentId: input.parentId, format: input.format, content: stringify(input.content) })
|
||||
.where(eq(assistantMessages.id, input.id));
|
||||
} else {
|
||||
await ctx.db.insert(assistantMessages).values({
|
||||
id: input.id,
|
||||
threadId: thread.id,
|
||||
parentId: input.parentId,
|
||||
format: input.format,
|
||||
content: stringify(input.content),
|
||||
});
|
||||
}
|
||||
|
||||
const title =
|
||||
thread.title ??
|
||||
(typeof input.content === "object" &&
|
||||
input.content !== null &&
|
||||
"role" in input.content &&
|
||||
input.content.role === "user" &&
|
||||
"parts" in input.content &&
|
||||
Array.isArray(input.content.parts)
|
||||
? input.content.parts
|
||||
.filter(
|
||||
(part): part is { type: "text"; text: string } =>
|
||||
typeof part === "object" &&
|
||||
part !== null &&
|
||||
"type" in part &&
|
||||
part.type === "text" &&
|
||||
"text" in part &&
|
||||
typeof part.text === "string",
|
||||
)
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
.trim()
|
||||
.slice(0, 80) || null
|
||||
: null);
|
||||
|
||||
await ctx.db
|
||||
.update(assistantThreads)
|
||||
.set({ title, updatedAt: new Date() })
|
||||
.where(eq(assistantThreads.id, thread.id));
|
||||
}),
|
||||
|
||||
deleteMessages: protectedProcedure
|
||||
.input(z.object({ threadId: z.string().max(64), ids: z.array(z.string().max(128)).max(100) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
||||
if (input.ids.length === 0) return;
|
||||
await ctx.db
|
||||
.delete(assistantMessages)
|
||||
.where(and(eq(assistantMessages.threadId, input.threadId), inArray(assistantMessages.id, input.ids)));
|
||||
await ctx.db
|
||||
.update(assistantThreads)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(assistantThreads.id, input.threadId));
|
||||
}),
|
||||
});
|
||||
|
||||
export { fetchModelsAsync, getConfigurationAsync, ownedThreadAsync };
|
||||
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE `assistant_configuration` (
|
||||
`id` varchar(64) DEFAULT 'default' NOT NULL,
|
||||
`enabled` boolean DEFAULT false NOT NULL,
|
||||
`provider` varchar(32) DEFAULT 'openrouter' NOT NULL,
|
||||
`base_url` varchar(2048) DEFAULT 'https://openrouter.ai/api/v1' NOT NULL,
|
||||
`model_discovery_path` varchar(512) DEFAULT '/models',
|
||||
`encrypted_api_key` text,
|
||||
`encrypted_headers` text,
|
||||
`model_id` varchar(256),
|
||||
`updated_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
CONSTRAINT `assistant_configuration_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `assistant_thread` (
|
||||
`id` varchar(64) NOT NULL,
|
||||
`user_id` varchar(64) NOT NULL,
|
||||
`title` varchar(256),
|
||||
`model_id` varchar(256),
|
||||
`status` varchar(16) DEFAULT 'regular' NOT NULL,
|
||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
`updated_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
CONSTRAINT `assistant_thread_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `assistant_message` (
|
||||
`id` varchar(64) NOT NULL,
|
||||
`thread_id` varchar(64) NOT NULL,
|
||||
`parent_id` varchar(64),
|
||||
`format` varchar(64) DEFAULT 'ai-sdk/v6' NOT NULL,
|
||||
`content` text DEFAULT ('{"json": {}}') NOT NULL,
|
||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
CONSTRAINT `assistant_message_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `assistant_thread` ADD CONSTRAINT `assistant_thread_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `assistant_message` ADD CONSTRAINT `assistant_message_thread_id_assistant_thread_id_fk` FOREIGN KEY (`thread_id`) REFERENCES `assistant_thread`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||
@@ -330,6 +330,13 @@
|
||||
"when": 1786215199760,
|
||||
"tag": "0046_add_layout_role",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "5",
|
||||
"when": 1786217088950,
|
||||
"tag": "0047_add_assistant_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE "assistant_configuration" (
|
||||
"id" varchar(64) PRIMARY KEY DEFAULT 'default' NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"provider" varchar(32) DEFAULT 'openrouter' NOT NULL,
|
||||
"base_url" varchar(2048) DEFAULT 'https://openrouter.ai/api/v1' NOT NULL,
|
||||
"model_discovery_path" varchar(512) DEFAULT '/models',
|
||||
"encrypted_api_key" text,
|
||||
"encrypted_headers" text,
|
||||
"model_id" varchar(256),
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "assistant_message" (
|
||||
"id" varchar(64) PRIMARY KEY NOT NULL,
|
||||
"thread_id" varchar(64) NOT NULL,
|
||||
"parent_id" varchar(64),
|
||||
"format" varchar(64) DEFAULT 'ai-sdk/v6' NOT NULL,
|
||||
"content" text DEFAULT '{"json": {}}' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "assistant_thread" (
|
||||
"id" varchar(64) PRIMARY KEY NOT NULL,
|
||||
"user_id" varchar(64) NOT NULL,
|
||||
"title" varchar(256),
|
||||
"model_id" varchar(256),
|
||||
"status" varchar(16) DEFAULT 'regular' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "assistant_message" ADD CONSTRAINT "assistant_message_thread_id_assistant_thread_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."assistant_thread"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "assistant_thread" ADD CONSTRAINT "assistant_thread_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1786215201393,
|
||||
"tag": "0012_add_layout_role",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1786217088954,
|
||||
"tag": "0013_add_assistant_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE `assistant_configuration` (
|
||||
`id` text PRIMARY KEY DEFAULT 'default' NOT NULL,
|
||||
`enabled` integer DEFAULT false NOT NULL,
|
||||
`provider` text DEFAULT 'openrouter' NOT NULL,
|
||||
`base_url` text DEFAULT 'https://openrouter.ai/api/v1' NOT NULL,
|
||||
`model_discovery_path` text DEFAULT '/models',
|
||||
`encrypted_api_key` text,
|
||||
`encrypted_headers` text,
|
||||
`model_id` text,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `assistant_message` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`thread_id` text NOT NULL,
|
||||
`parent_id` text,
|
||||
`format` text DEFAULT 'ai-sdk/v6' NOT NULL,
|
||||
`content` text DEFAULT '{"json": {}}' NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`thread_id`) REFERENCES `assistant_thread`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `assistant_thread` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`title` text,
|
||||
`model_id` text,
|
||||
`status` text DEFAULT 'regular' NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
@@ -316,6 +316,13 @@
|
||||
"when": 1786215203092,
|
||||
"tag": "0044_add_layout_role",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "6",
|
||||
"when": 1786217088950,
|
||||
"tag": "0045_add_assistant_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export const schema = createSchema({
|
||||
export const {
|
||||
accounts,
|
||||
apiKeys,
|
||||
assistantConfigurations,
|
||||
assistantMessages,
|
||||
assistantThreads,
|
||||
apps,
|
||||
boardGroupPermissions,
|
||||
boardUserPermissions,
|
||||
@@ -58,6 +61,9 @@ export const {
|
||||
|
||||
export type User = InferSelectModel<typeof schema.users>;
|
||||
export type Account = InferSelectModel<typeof schema.accounts>;
|
||||
export type AssistantConfiguration = InferSelectModel<typeof schema.assistantConfigurations>;
|
||||
export type AssistantMessage = InferSelectModel<typeof schema.assistantMessages>;
|
||||
export type AssistantThread = InferSelectModel<typeof schema.assistantThreads>;
|
||||
export type Session = InferSelectModel<typeof schema.sessions>;
|
||||
export type VerificationToken = InferSelectModel<typeof schema.verificationTokens>;
|
||||
export type Integration = InferSelectModel<typeof schema.integrations>;
|
||||
|
||||
@@ -481,6 +481,44 @@ export const serverSettings = mysqlTable("serverSetting", {
|
||||
value: text().default(emptySuperJSON).notNull(),
|
||||
});
|
||||
|
||||
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">()
|
||||
.notNull()
|
||||
.default("openrouter"),
|
||||
baseUrl: varchar({ length: 2048 }).notNull().default("https://openrouter.ai/api/v1"),
|
||||
modelDiscoveryPath: varchar({ length: 512 }).default("/models"),
|
||||
encryptedApiKey: text().$type<`${string}.${string}`>(),
|
||||
encryptedHeaders: text().$type<`${string}.${string}`>(),
|
||||
modelId: varchar({ length: 256 }),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const assistantThreads = mysqlTable("assistant_thread", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: varchar({ length: 256 }),
|
||||
modelId: varchar({ length: 256 }),
|
||||
status: varchar({ length: 16 }).$type<"regular" | "archived">().notNull().default("regular"),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const assistantMessages = mysqlTable("assistant_message", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
threadId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||
parentId: varchar({ length: 64 }),
|
||||
format: varchar({ length: 64 }).notNull().default("ai-sdk/v6"),
|
||||
content: text().default(emptySuperJSON).notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [apiKeys.userId],
|
||||
|
||||
@@ -480,6 +480,44 @@ export const serverSettings = pgTable("serverSetting", {
|
||||
value: text().default(emptySuperJSON).notNull(),
|
||||
});
|
||||
|
||||
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">()
|
||||
.notNull()
|
||||
.default("openrouter"),
|
||||
baseUrl: varchar({ length: 2048 }).notNull().default("https://openrouter.ai/api/v1"),
|
||||
modelDiscoveryPath: varchar({ length: 512 }).default("/models"),
|
||||
encryptedApiKey: text().$type<`${string}.${string}`>(),
|
||||
encryptedHeaders: text().$type<`${string}.${string}`>(),
|
||||
modelId: varchar({ length: 256 }),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const assistantThreads = pgTable("assistant_thread", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: varchar({ length: 256 }),
|
||||
modelId: varchar({ length: 256 }),
|
||||
status: varchar({ length: 16 }).$type<"regular" | "archived">().notNull().default("regular"),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const assistantMessages = pgTable("assistant_message", {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
threadId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||
parentId: varchar({ length: 64 }),
|
||||
format: varchar({ length: 64 }).notNull().default("ai-sdk/v6"),
|
||||
content: text().default(emptySuperJSON).notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [apiKeys.userId],
|
||||
|
||||
@@ -465,6 +465,49 @@ export const serverSettings = sqliteTable("serverSetting", {
|
||||
value: text().default(emptySuperJSON).notNull(),
|
||||
});
|
||||
|
||||
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"),
|
||||
baseUrl: text().notNull().default("https://openrouter.ai/api/v1"),
|
||||
modelDiscoveryPath: text().default("/models"),
|
||||
encryptedApiKey: text().$type<`${string}.${string}`>(),
|
||||
encryptedHeaders: text().$type<`${string}.${string}`>(),
|
||||
modelId: text(),
|
||||
updatedAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const assistantThreads = sqliteTable("assistant_thread", {
|
||||
id: text().notNull().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: text(),
|
||||
modelId: text(),
|
||||
status: text().$type<"regular" | "archived">().notNull().default("regular"),
|
||||
createdAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const assistantMessages = sqliteTable("assistant_message", {
|
||||
id: text().notNull().primaryKey(),
|
||||
threadId: text()
|
||||
.notNull()
|
||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||
parentId: text(),
|
||||
format: text().notNull().default("ai-sdk/v6"),
|
||||
content: text().default(emptySuperJSON).notNull(),
|
||||
createdAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [apiKeys.userId],
|
||||
|
||||
@@ -3,4 +3,5 @@ export const hotkeys = {
|
||||
toggleColorScheme: "mod+j",
|
||||
saveNotebook: "mod+s",
|
||||
openSpotlight: "mod+k",
|
||||
openAssistant: "mod+shift+k",
|
||||
};
|
||||
@@ -1950,6 +1950,7 @@
|
||||
"switchToDarkMode": "Switch to dark mode",
|
||||
"switchToLightMode": "Switch to light mode",
|
||||
"management": "Management",
|
||||
"assistant": "Homarr Assistant",
|
||||
"docker": "Docker",
|
||||
"preferences": "Your preferences",
|
||||
"logout": "Logout",
|
||||
@@ -1959,6 +1960,38 @@
|
||||
"updateAvailable": "{countUpdates} updates available: {tag}"
|
||||
}
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Homarr Assistant",
|
||||
"subtitle": "Connected to this Homarr instance",
|
||||
"open": "Open Homarr Assistant",
|
||||
"close": "Close assistant",
|
||||
"spotlight": "Ask Homarr Assistant",
|
||||
"newConversation": "New conversation",
|
||||
"archivedConversation": "Archived conversation",
|
||||
"conversations": "Conversations",
|
||||
"archived": "Archived",
|
||||
"archive": "Archive",
|
||||
"restore": "Restore",
|
||||
"delete": "Delete",
|
||||
"closeHistory": "Close conversation history",
|
||||
"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.",
|
||||
"composerPlaceholder": "Ask Homarr Assistant…",
|
||||
"stop": "Stop response",
|
||||
"send": "Send message",
|
||||
"copy": "Copy response",
|
||||
"regenerate": "Regenerate response",
|
||||
"scrollToLatest": "Scroll to latest",
|
||||
"approvalRequired": "Approval required",
|
||||
"complete": "Complete",
|
||||
"denied": "Denied",
|
||||
"failed": "Failed",
|
||||
"working": "Working",
|
||||
"approvalDescription": "This action changes Homarr. Review the input before allowing it.",
|
||||
"allowOnce": "Allow once",
|
||||
"deny": "Deny"
|
||||
},
|
||||
"dangerZone": "Danger zone",
|
||||
"noResults": "No results found",
|
||||
"unsavedChanges": "You have unsaved changes!",
|
||||
@@ -5716,6 +5749,112 @@
|
||||
"text": "This helps us figure out what people actually use so we know where to spend our time. We see stuff like how many widgets or integrations you have, your Homarr version, and what database you're on. That's it — no personal info, no URLs, no names, nothing that could identify you or your setup."
|
||||
}
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Homarr Assistant",
|
||||
"description": "Connect an OpenAI-compatible model endpoint for the instance-wide assistant.",
|
||||
"security": {
|
||||
"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."
|
||||
},
|
||||
"connection": {
|
||||
"title": "Provider connection",
|
||||
"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"
|
||||
},
|
||||
"provider": {
|
||||
"title": "Provider",
|
||||
"options": {
|
||||
"openrouter": {
|
||||
"label": "OpenRouter",
|
||||
"description": "Hosted model routing with a large tool-capable model catalog."
|
||||
},
|
||||
"openai": {
|
||||
"label": "OpenAI",
|
||||
"description": "OpenAI Chat Completions through the official API endpoint."
|
||||
},
|
||||
"ollama": {
|
||||
"label": "Ollama",
|
||||
"description": "A local or network Ollama server using its OpenAI-compatible API."
|
||||
},
|
||||
"lm-studio": {
|
||||
"label": "LM Studio",
|
||||
"description": "An LM Studio local server with OpenAI compatibility enabled."
|
||||
},
|
||||
"custom": {
|
||||
"label": "Custom endpoint",
|
||||
"description": "Any other endpoint compatible with OpenAI Chat Completions."
|
||||
}
|
||||
}
|
||||
},
|
||||
"baseUrl": {
|
||||
"title": "API base URL",
|
||||
"description": "Include the API prefix, usually /v1. In containers, localhost refers to the Homarr container."
|
||||
},
|
||||
"destinationChanged": {
|
||||
"title": "Credentials will not follow this endpoint",
|
||||
"description": "Changing the provider or base URL clears the stored API key and headers unless replacements are entered now. This prevents sending one provider's secrets to another service."
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API key",
|
||||
"required": "Required by this provider. The value is encrypted and write-only.",
|
||||
"optional": "Optional for local endpoints. Leave it blank when the service does not require bearer authentication.",
|
||||
"placeholder": "Enter an API key",
|
||||
"configured": "A key is configured. Enter a new key to replace it.",
|
||||
"configuredBadge": "Key configured",
|
||||
"pendingRemoval": "Removal pending",
|
||||
"clear": "Remove stored API key when saving",
|
||||
"keep": "Keep stored API key"
|
||||
},
|
||||
"headers": {
|
||||
"title": "Additional request headers",
|
||||
"description": "Optional authentication or routing headers. Header values are encrypted and write-only.",
|
||||
"add": "Add header",
|
||||
"name": "Header name",
|
||||
"value": "Header value",
|
||||
"valuePlaceholder": "Secret or routing value",
|
||||
"remove": "Remove header",
|
||||
"configured": "Additional encrypted headers are configured. Adding headers replaces the complete stored set.",
|
||||
"clear": "Remove stored headers when saving",
|
||||
"keep": "Keep stored headers"
|
||||
},
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"description": "Select a discovered model or enter an exact endpoint model ID.",
|
||||
"placeholder": "Model ID",
|
||||
"refresh": "Refresh models",
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Enable Homarr Assistant",
|
||||
"description": "Shows the assistant to all signed-in users. Existing Homarr permissions still apply to every internal tool call."
|
||||
},
|
||||
"save": "Save assistant settings",
|
||||
"notification": {
|
||||
"connectionSaved": {
|
||||
"title": "Provider connection saved",
|
||||
"message": "The endpoint and encrypted credentials are ready for model selection.",
|
||||
"credentialsCleared": "The endpoint changed, so previous credentials were cleared. Add credentials for the new destination if required."
|
||||
},
|
||||
"credentialsCleared": {
|
||||
"title": "Provider credentials removed",
|
||||
"message": "The stored API key and headers were removed, and the assistant was disabled."
|
||||
},
|
||||
"saved": {
|
||||
"title": "Assistant settings saved",
|
||||
"message": "The new model and availability settings are active."
|
||||
},
|
||||
"error": {
|
||||
"title": "Assistant settings could not be saved"
|
||||
}
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
"title": "Crawling and Indexing",
|
||||
"warning": "Enabling or disabling any settings here will severely impact how search engines will index & crawl your page. Any setting is a request and it is up to the crawler to apply these settings. Any modification may take up to multiple days or weeks to apply. Some settings may be search engine specific.",
|
||||
@@ -6155,7 +6294,8 @@
|
||||
"toggleBoardEdit": "Toggle board edit mode",
|
||||
"toggleColorScheme": "Toggle light/dark mode",
|
||||
"saveNotebook": "Save notebook (only inside notebook widget)",
|
||||
"openSpotlight": "Open search"
|
||||
"openSpotlight": "Open search",
|
||||
"openAssistant": "Open Homarr Assistant"
|
||||
},
|
||||
"note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS"
|
||||
}
|
||||
|
||||
Generated
+2696
-764
File diff suppressed because it is too large.
Load diff
@@ -215,6 +215,12 @@ catalog:
|
||||
zod: ^4.4.3
|
||||
zod-form-data: ^3.0.2
|
||||
zod-validation-error: ^5.0.0
|
||||
"@assistant-ui/react": ^0.14.27
|
||||
"@assistant-ui/react-ai-sdk": ^1.3.41
|
||||
"@ai-sdk/react": ^4.0.40
|
||||
ai: ^7.0.37
|
||||
assistant-stream: ^0.3.26
|
||||
"@ai-sdk/openai-compatible": 3.0.14
|
||||
|
||||
catalogMode: strict
|
||||
nodeLinker: hoisted
|
||||
|
||||
Reference in new issue
Block a user