Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc11e8d17 | ||
|
|
09c56002d6 | ||
|
|
7276bdad21 | ||
|
|
f25be7cf1c | ||
|
|
a4e1c3c60e | ||
|
|
d82db8c6bd | ||
|
|
a4a53af79d | ||
|
|
4acd6548b5 | ||
|
|
da93adac14 | ||
|
|
926b7e2a5e | ||
|
|
17f08c6d14 | ||
|
|
32cad81e1c | ||
|
|
eaacfdf7ce | ||
|
|
96659fb543 | ||
|
|
d625640051 | ||
|
|
b14756874e | ||
|
|
6bbb786ede | ||
|
|
31d9e8d582 | ||
|
|
bc12aba433 | ||
|
|
f76a0f240a | ||
|
|
637f03e107 | ||
|
|
55214ccbc5 | ||
|
|
eca8b4286d | ||
|
|
778533a62e |
No files matched your search
@@ -44,4 +44,4 @@ jobs:
|
||||
- name: Commit fixes
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "style: auto-fix lint and format"
|
||||
commit_message: "style: auto-fix lint and format [skip ci]"
|
||||
@@ -7,8 +7,8 @@ tags:
|
||||
- Ping
|
||||
---
|
||||
|
||||
Apps are the building blocks of your Homarr board.
|
||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||
Apps are the building blocks of your Homarr board.
|
||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||
Apps are used in the bookmark and app widget.
|
||||
|
||||
## Create an app
|
||||
@@ -27,7 +27,7 @@ The name must be at least one character long and can be chosen freely.
|
||||
|
||||
### Icon URL
|
||||
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
|
||||
|
||||

|
||||
@@ -40,11 +40,14 @@ An optional description that can be shown as tooltip on the app widget.
|
||||
|
||||
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
|
||||
|
||||
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
|
||||
|
||||
### Ping URL
|
||||
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
|
||||
It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
|
||||
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
|
||||
|
||||
## List of apps
|
||||
|
||||
|
||||
@@ -10,109 +10,124 @@ tags:
|
||||
|
||||
# Settings
|
||||
|
||||
The settings page has a few options that will be applied to the entire Homarr application.
|
||||
They may require a full page reload for changes to apply.
|
||||
The settings page applies options to the whole Homarr application.
|
||||
Some changes need a full page reload before they take effect.
|
||||
|
||||
## Analytics
|
||||
|
||||
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
|
||||
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
|
||||
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
|
||||
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
|
||||
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
|
||||
We collect this data to see what features people ACTUALLY use.
|
||||
We use this data to decide where we spend our development time.
|
||||
|
||||
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
|
||||
We do not sell the data. We do not share the data with third parties.
|
||||
The data does not contain personal information and does not identify you or your installation.
|
||||
|
||||
We understand that you might not want Homarr to communicate with our services.
|
||||
By unchecking the `Send anonymous analytics` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
|
||||
If you are paranoid, you can permanently block `umami.homarr.dev` from your DNS / firewall.
|
||||
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
|
||||
The server sends the data once a week and when the server starts.
|
||||
|
||||
Additionally, you opt in to more data collection to help us provide more useful data.
|
||||
### What we collect
|
||||
|
||||
### Integration data
|
||||
Homarr collects technical data about your installation:
|
||||
|
||||
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
- Your Homarr version
|
||||
- Your database type (SQLite, MySQL, or PostgreSQL)
|
||||
- Whether Docker or Kubernetes is enabled
|
||||
- Your enabled authentication providers
|
||||
- Your operating system and CPU architecture
|
||||
- Your default language
|
||||
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
|
||||
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
|
||||
- The widget types you use and how many you have of each
|
||||
|
||||
### Widget data
|
||||
Homarr does NOT collect:
|
||||
|
||||
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
- URLs
|
||||
- Names
|
||||
- Usernames, emails, or passwords
|
||||
- Integration secrets or API keys
|
||||
- Error messages or logs
|
||||
- Any data that can identify you
|
||||
|
||||
### Users data
|
||||
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
|
||||
|
||||
Enabling this optional data will send the count of users and whether you activated SSO.
|
||||
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
|
||||
This is disabled by default.
|
||||
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
|
||||
|
||||
### Disable analytics
|
||||
|
||||
Analytics are enabled by default. To disable analytics:
|
||||
|
||||
1. Open the Settings page.
|
||||
2. Uncheck the "Help us improve Homarr" checkbox.
|
||||
3. Reload the full page.
|
||||
|
||||
When analytics are disabled, Homarr does not send any data to PostHog.
|
||||
|
||||
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
|
||||
|
||||
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
|
||||
|
||||
## Search Engine Optimization (SEO)
|
||||
|
||||
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
|
||||
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
|
||||
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
|
||||
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
|
||||
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
|
||||
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
|
||||
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
|
||||
|
||||
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
|
||||
|
||||
### No index
|
||||
|
||||
This will ask crawlers to not index your Homarr instance on any indexes.
|
||||
It is up to the crawler to properly respect this request.
|
||||
By default, this setting is enabled. Disabling it will allow indexing.
|
||||
This setting asks crawlers to not index your instance.
|
||||
This setting is enabled by default. Disable this setting to allow indexing.
|
||||
|
||||
### No follow
|
||||
|
||||
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
|
||||
By default, this setting is enabled and will ask crawlers to not follow any links.
|
||||
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
|
||||
Crawlers follow links to discover new pages.
|
||||
This setting asks crawlers to not follow links. This setting is enabled by default.
|
||||
Crawler behavior can differ between search engines.
|
||||
|
||||
### No translate
|
||||
|
||||
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
|
||||
This can lead to some shifting and can be undesired in some cases. By enabling this,
|
||||
you ask crawlers to never translate and never prompt users for translation.
|
||||
It should be noted that some browsers will ignore this and translate regardless.
|
||||
Some browsers offer to translate pages.
|
||||
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
|
||||
|
||||
### No site links search box
|
||||
|
||||
This setting is exclusive to Google.
|
||||
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
|
||||
The user can search them directly in Google.
|
||||
You can ask the crawlers to not allow searches by enabling this setting.
|
||||
This setting is disabled by default.
|
||||
This setting applies to Google only.
|
||||
Google can show a search box below your domain.
|
||||
This setting asks Google to not show this box. This setting is disabled by default.
|
||||
|
||||
## Boards
|
||||
|
||||
### Global home board
|
||||
|
||||
You can set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
Note that the board must be public to be used as a global home board.
|
||||
You can set a global home board.
|
||||
This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
|
||||
The board must be public.
|
||||
|
||||
### Global mobile board
|
||||
|
||||
You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header).
|
||||
It will similar to the global home board be as fallback for normal users and for any anonymous users.
|
||||
It also has to be public.
|
||||
You can set a global mobile board.
|
||||
Homarr shows this board on mobile devices instead of the global home board.
|
||||
Homarr detects mobile devices with the User-Agent header.
|
||||
The board must be public.
|
||||
|
||||
### App status
|
||||
|
||||
#### Enable status by default
|
||||
|
||||
You can decide if the app status should be enabled by default when adding a new app to the board.
|
||||
You can decide if app status is enabled when you add a new app.
|
||||
|
||||
#### Force disable status
|
||||
|
||||
You can decide if the app status should be disabled for all boards.
|
||||
|
||||
###
|
||||
You can decide if app status is disabled on all boards.
|
||||
|
||||
## Appearance
|
||||
|
||||
### Default color scheme
|
||||
|
||||
You can set the default color scheme for all users that have not set a color scheme themselves.
|
||||
You can set the default color scheme for users without their own color scheme.
|
||||
|
||||
## Culture
|
||||
|
||||
### Default language
|
||||
|
||||
You can set the default language for all users that have not set a language themselves.
|
||||
You can set the default language for users without their own language.
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Placed here because gridstack styles are used for board content
|
||||
@@ -49,8 +50,24 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
const resolvedParams = await params;
|
||||
const queryClient = getQueryClient();
|
||||
const session = await auth();
|
||||
|
||||
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
|
||||
const board = await getInitialBoard(resolvedParams).catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
const itemsMap = board.items.reduce((acc, item) => {
|
||||
const existing = acc.get(item.kind);
|
||||
@@ -110,8 +127,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Ignore not found errors and return empty metadata
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
// Ignore not found and bad-request errors and return empty metadata
|
||||
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { AssistantProvider } from "~/components/assistant/assistant-provider";
|
||||
import type { AssistantAvailability } from "~/components/assistant/assistant-gate";
|
||||
import { AssistantGate } from "~/components/assistant/assistant-gate";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
@@ -86,6 +87,16 @@ export default async function Layout(props: {
|
||||
const session = await auth();
|
||||
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
||||
const serverSettings = await getServerSettingsAsync(db);
|
||||
// Resolved on the server so the assistant runtime chunk is never referenced by a page unless the
|
||||
// assistant is actually usable. Reuses the availability check the management page invalidates.
|
||||
// The states stay distinct so a signed-out visitor is told to sign in rather than that the
|
||||
// instance is unconfigured, which the server cannot know for them.
|
||||
const assistantAvailability: AssistantAvailability = !session
|
||||
? "unauthenticated"
|
||||
: await api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? ("enabled" as const) : ("unconfigured" as const)))
|
||||
.catch(() => "error" as const);
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||
|
||||
@@ -124,7 +135,7 @@ export default async function Layout(props: {
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
(innerProps) => <AssistantProvider {...innerProps} />,
|
||||
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
|
||||
]);
|
||||
|
||||
const { locale } = await props.params;
|
||||
|
||||
+9
-9
@@ -462,7 +462,7 @@ export const AssistantConfiguration = () => {
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={t("provider.title")}
|
||||
description={t(`provider.options.${provider}.description`)}
|
||||
@@ -503,14 +503,14 @@ export const AssistantConfiguration = () => {
|
||||
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
|
||||
placeholder="https://provider.example/v1"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label={t("model.discoveryPath")}
|
||||
description={t("model.discoveryPathDescription")}
|
||||
value={modelDiscoveryPath}
|
||||
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
|
||||
placeholder="/models"
|
||||
/>
|
||||
<TextInput
|
||||
label={t("model.discoveryPath")}
|
||||
description={t("model.discoveryPathDescription")}
|
||||
value={modelDiscoveryPath}
|
||||
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
|
||||
placeholder="/models"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{destinationChanged && (
|
||||
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("destinationChanged.title")}>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-provider";
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
@@ -18,11 +16,6 @@ interface EditIntegrationPageProps {
|
||||
|
||||
export default async function EditIntegrationPage(props: EditIntegrationPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("integration-full-all")) {
|
||||
notFound();
|
||||
}
|
||||
const editT = await getScopedI18n("integration.page.edit");
|
||||
const t = await getI18n();
|
||||
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
@@ -23,10 +23,7 @@ interface NewIntegrationPageProps {
|
||||
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
if (
|
||||
!session?.user.permissions.includes("integration-full-all") ||
|
||||
!session?.user.permissions.includes("integration-create")
|
||||
) {
|
||||
if (!session?.user.permissions.includes("integration-create")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
@@ -50,9 +50,6 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
if (!session.user.permissions.includes("integration-full-all")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const integrations = await api.integration.all();
|
||||
const t = await getScopedI18n("integration");
|
||||
|
||||
@@ -72,7 +72,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session?.user.permissions.includes("integration-full-all"),
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { toProviderOptionsKey } from "./assistant-provider-options";
|
||||
|
||||
describe("toProviderOptionsKey", () => {
|
||||
test("converts the hyphenated provider name to the camelCase AI SDK provider option key", () => {
|
||||
expect(toProviderOptionsKey("homarr-openrouter")).toBe("homarrOpenrouter");
|
||||
});
|
||||
|
||||
test("matches the @ai-sdk/openai-compatible camelCase conversion", () => {
|
||||
expect(toProviderOptionsKey("homarr-anthropic")).toBe("homarrAnthropic");
|
||||
expect(toProviderOptionsKey("homarr-google-gemini")).toBe("homarrGoogleGemini");
|
||||
});
|
||||
|
||||
test("leaves names without separators unchanged", () => {
|
||||
expect(toProviderOptionsKey("homarr")).toBe("homarr");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The AI SDK resolves provider options by the provider name registered on the
|
||||
* language model. The @ai-sdk/openai-compatible provider converts the raw
|
||||
* provider name (for example `homarr-openrouter`) to camelCase
|
||||
* (`homarrOpenrouter`) and warns when the raw key is still used. This helper
|
||||
* mirrors that conversion so providerOptions reach the provider without a
|
||||
* deprecation warning.
|
||||
*/
|
||||
export const toProviderOptionsKey = (name: string) =>
|
||||
name
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((segment, index) => {
|
||||
if (index === 0) return segment;
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
})
|
||||
.join("");
|
||||
@@ -3,7 +3,15 @@ import { hkdfSync } from "node:crypto";
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
|
||||
import type { ToolSet, UIMessage } from "ai";
|
||||
import { convertToModelMessages, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
||||
import {
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
jsonSchema,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
tool,
|
||||
} from "ai";
|
||||
import { parse, stringify } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -27,6 +35,7 @@ import {
|
||||
} from "@homarr/definitions";
|
||||
|
||||
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
|
||||
import { env as appEnv } from "~/env";
|
||||
import type {
|
||||
AssistantMessageMetadata,
|
||||
AssistantRequestStep,
|
||||
@@ -43,6 +52,7 @@ import {
|
||||
normalizeOpenRouterWebSearchSources,
|
||||
withOpenRouterWebSearch,
|
||||
} from "./assistant-openrouter";
|
||||
import { toProviderOptionsKey } from "./assistant-provider-options";
|
||||
import { assistantExecutionPolicy } from "./assistant-execution-policy";
|
||||
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
|
||||
import { getSafeAssistantToolError } from "./assistant-tool-error";
|
||||
@@ -322,6 +332,31 @@ const getProcedureTypeMap = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) => {
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
const text =
|
||||
"This is the Homarr demo assistant. It is running in preview/mock mode, so it responds with a canned answer instead of calling a real language model. In a real deployment, an administrator configures a provider and a model for full conversations.";
|
||||
const responseId = crypto.randomUUID();
|
||||
const partId = crypto.randomUUID();
|
||||
const chunkTexts = text.match(/.{1,3}/gs) ?? [text];
|
||||
|
||||
const stream = createUIMessageStream<UIMessage>({
|
||||
originalMessages: request.messages as UIMessage[],
|
||||
execute: async ({ writer }) => {
|
||||
writer.write({ type: "start", messageId: responseId });
|
||||
writer.write({ type: "text-start", id: partId });
|
||||
for (const chunk of chunkTexts) {
|
||||
writer.write({ type: "text-delta", id: partId, delta: chunk });
|
||||
await delay(18);
|
||||
}
|
||||
writer.write({ type: "text-end", id: partId });
|
||||
writer.write({ type: "finish", finishReason: "stop" });
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > 2_000_000) {
|
||||
@@ -359,10 +394,7 @@ export async function POST(request: Request) {
|
||||
const configuration = await db.query.assistantConfigurations.findFirst({
|
||||
where: eq(assistantConfigurations.id, "default"),
|
||||
});
|
||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
|
||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
const thread = await db.query.assistantThreads.findFirst({
|
||||
where: and(eq(assistantThreads.id, parsed.data.id), eq(assistantThreads.userId, session.user.id)),
|
||||
});
|
||||
@@ -370,6 +402,15 @@ export async function POST(request: Request) {
|
||||
return Response.json({ error: "Conversation not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (appEnv.DEMO_MODE) {
|
||||
return createDemoAssistantResponse(parsed.data);
|
||||
}
|
||||
|
||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
|
||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
const context = createTRPCContext({ headers: request.headers, session });
|
||||
const requestStartedAt = Date.now();
|
||||
const requestId = crypto.randomUUID();
|
||||
@@ -543,7 +584,7 @@ export async function POST(request: Request) {
|
||||
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
|
||||
providerOptions:
|
||||
configuration.provider === "openrouter" || openRouterServerToolsEnabled
|
||||
? { [providerName]: { usage: { include: true } } }
|
||||
? { [toProviderOptionsKey(providerName)]: { usage: { include: true } } }
|
||||
: undefined,
|
||||
toolApproval,
|
||||
experimental_toolApprovalSecret: getToolApprovalSecret(),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
|
||||
/**
|
||||
* Context and hooks live in their own module so that consumers such as the user menu can read the
|
||||
* assistant state without pulling in `assistant-provider`, which statically imports the
|
||||
* assistant-ui runtime, the Lexical composer, the Markdown renderer and Mermaid. Importing those
|
||||
* from a component that renders on every page would ship them to every page.
|
||||
*/
|
||||
export interface AssistantContextValue {
|
||||
enabled: boolean;
|
||||
unavailableDescription: string | null;
|
||||
opened: boolean;
|
||||
isRunning: boolean;
|
||||
isRefreshing: boolean;
|
||||
unreadCount: number;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
sendPrompt: (prompt: string) => boolean;
|
||||
refreshCurrentView: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||
|
||||
export interface AssistantPreferencesContextValue {
|
||||
defaultModelId: string | null;
|
||||
modelId: string | null;
|
||||
models: AssistantRuntimeModelOption[];
|
||||
reasoning: AssistantReasoningMode;
|
||||
isLoading: boolean;
|
||||
setModelId: (modelId: string) => void;
|
||||
setReasoning: (reasoning: AssistantReasoningMode) => void;
|
||||
getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
|
||||
}
|
||||
|
||||
export const AssistantPreferencesContext = createContext<AssistantPreferencesContextValue | 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);
|
||||
|
||||
export const useAssistantPreferences = () => {
|
||||
const value = useContext(AssistantPreferencesContext);
|
||||
if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider");
|
||||
return value;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||
|
||||
import { AssistantContext } from "./assistant-context";
|
||||
import { AssistantBoardWidgetLazy } from "./assistant-widget-lazy";
|
||||
import { useRegisterAssistantSpotlightPlaceholder } from "./assistant-spotlight-placeholder";
|
||||
|
||||
/**
|
||||
* The enabled runtime is the expensive half of the assistant (assistant-ui, Lexical, Markdown,
|
||||
* Mermaid). Loading it through `next/dynamic` puts it in its own chunk that is only requested when
|
||||
* this component actually renders. Because `enabled` is resolved on the server and handed down as a
|
||||
* prop, an instance with the assistant switched off never renders it, so the chunk is never part of
|
||||
* the page payload. SSR stays on so enabled instances render children in the initial HTML without a
|
||||
* loading flash or a remount once the chunk arrives.
|
||||
*/
|
||||
const EnabledAssistantRoot = dynamic(() =>
|
||||
import("./assistant-provider").then((module) => ({ default: module.EnabledAssistantRoot })),
|
||||
);
|
||||
|
||||
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
|
||||
useRegisterAssistantSpotlightPlaceholder(description);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
enabled: false,
|
||||
unavailableDescription: description,
|
||||
opened: false,
|
||||
isRunning: false,
|
||||
isRefreshing: false,
|
||||
unreadCount: 0,
|
||||
open: () => undefined,
|
||||
close: () => undefined,
|
||||
toggle: () => undefined,
|
||||
sendPrompt: () => false,
|
||||
refreshCurrentView: () => Promise.resolve(),
|
||||
}),
|
||||
[description],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantContext.Provider value={value}>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidgetLazy}>{children}</AssistantWidgetRendererProvider>
|
||||
</AssistantContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Why the assistant is or is not usable, resolved on the server.
|
||||
*
|
||||
* `unauthenticated` is kept apart from `unconfigured` because the server cannot check availability
|
||||
* for a signed-out visitor, and telling them the instance is unconfigured would be a guess.
|
||||
*/
|
||||
export type AssistantAvailability = "enabled" | "unconfigured" | "unauthenticated" | "error";
|
||||
|
||||
interface AssistantGateProps extends PropsWithChildren {
|
||||
availability: AssistantAvailability;
|
||||
}
|
||||
|
||||
const unavailableMessageKeys = {
|
||||
unauthenticated: "unavailable.signIn",
|
||||
unconfigured: "unavailable.notConfigured",
|
||||
error: "unavailable.error",
|
||||
} as const;
|
||||
|
||||
export const AssistantGate = ({ availability, children }: AssistantGateProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
|
||||
if (availability === "enabled") {
|
||||
return <EnabledAssistantRoot>{children}</EnabledAssistantRoot>;
|
||||
}
|
||||
|
||||
return <DisabledAssistant description={t(unavailableMessageKeys[availability])}>{children}</DisabledAssistant>;
|
||||
};
|
||||
@@ -923,13 +923,14 @@ const RequestTelemetry = () => {
|
||||
const metadata = useAuiState((state) => state.message.metadata);
|
||||
const messageId = useAuiState((state) => state.message.id);
|
||||
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const threadMessageMetadata = useAuiState((state) => state.thread.messages.map((message) => message.metadata));
|
||||
const threadMessages = useAuiState((state) => state.thread.messages);
|
||||
const threadMessageMetadata = useMemo(() => threadMessages.map((message) => message.metadata), [threadMessages]);
|
||||
const persistedTelemetry = getAssistantTelemetry(metadata);
|
||||
const persistedUsage = getAssistantUsage(metadata);
|
||||
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
|
||||
{ threadId: threadId ?? "", messageId },
|
||||
{
|
||||
enabled: opened && persistedTelemetry?.provider === "openrouter" && Boolean(threadId),
|
||||
enabled: opened && persistedTelemetry?.provider === "openrouter" && Boolean(threadId) && Boolean(messageId),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.complete === false && query.state.dataUpdateCount < 6 ? 1500 : false,
|
||||
retry: 2,
|
||||
@@ -2453,9 +2454,6 @@ export const AssistantConversationSurface = ({
|
||||
<Text fw={700} lh={1.1} lineClamp={1}>
|
||||
{t("title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{isRunning ? t("activity.thinking") : t("subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type {
|
||||
AttachmentAdapter,
|
||||
@@ -17,7 +17,6 @@ import { useHotkeys } from "@mantine/hooks";
|
||||
import { createAssistantStream } from "assistant-stream";
|
||||
|
||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from "@homarr/spotlight";
|
||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||
|
||||
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
|
||||
import { createAssistantBrowserToolExecutors } from "./assistant-browser-tool-executors";
|
||||
@@ -53,33 +53,6 @@ import { initialAssistantNotificationState, updateAssistantNotificationState } f
|
||||
import { AssistantViewRefreshProvider, useAssistantViewRefresh } from "./assistant-view-refresh";
|
||||
import { AssistantBoardWidget } from "./assistant-widget";
|
||||
|
||||
interface AssistantContextValue {
|
||||
enabled: boolean;
|
||||
unavailableDescription: string | null;
|
||||
opened: boolean;
|
||||
isRunning: boolean;
|
||||
isRefreshing: boolean;
|
||||
unreadCount: number;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
sendPrompt: (prompt: string) => boolean;
|
||||
refreshCurrentView: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||
interface AssistantPreferencesContextValue {
|
||||
defaultModelId: string | null;
|
||||
modelId: string | null;
|
||||
models: AssistantRuntimeModelOption[];
|
||||
reasoning: AssistantReasoningMode;
|
||||
isLoading: boolean;
|
||||
setModelId: (modelId: string) => void;
|
||||
setReasoning: (reasoning: AssistantReasoningMode) => void;
|
||||
getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
|
||||
}
|
||||
|
||||
const AssistantPreferencesContext = createContext<AssistantPreferencesContextValue | null>(null);
|
||||
const ignoreUnsupportedArchiveAction = () => Promise.resolve();
|
||||
const assistantImageAttachmentTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"];
|
||||
const assistantDocumentAttachmentTypes = [
|
||||
@@ -158,22 +131,6 @@ const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapt
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
export const useAssistantPreferences = () => {
|
||||
const value = useContext(AssistantPreferencesContext);
|
||||
if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider");
|
||||
return value;
|
||||
};
|
||||
|
||||
const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
|
||||
const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, {
|
||||
staleTime: 10 * 60_000,
|
||||
@@ -766,79 +723,16 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface DisabledAssistantProviderProps extends PropsWithChildren {
|
||||
description: string;
|
||||
}
|
||||
|
||||
const DisabledAssistantProvider = ({ children, description }: DisabledAssistantProviderProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const spotlightItem = useMemo(
|
||||
() => ({
|
||||
id: "homarr-assistant",
|
||||
name: t("spotlight"),
|
||||
icon: "/logo/logo.png",
|
||||
description,
|
||||
unavailable: true,
|
||||
alwaysVisible: true,
|
||||
interaction: (_query: string) => ({ type: "none" as const }),
|
||||
}),
|
||||
[description, t],
|
||||
);
|
||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
enabled: false,
|
||||
unavailableDescription: description,
|
||||
opened: false,
|
||||
isRunning: false,
|
||||
isRefreshing: false,
|
||||
unreadCount: 0,
|
||||
open: () => undefined,
|
||||
close: () => undefined,
|
||||
toggle: () => undefined,
|
||||
sendPrompt: () => false,
|
||||
refreshCurrentView: () => Promise.resolve(),
|
||||
}),
|
||||
[description],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantContext.Provider value={value}>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
</AssistantContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const session = useSession();
|
||||
const { data, isLoading, isError } = clientApi.assistant.getAvailability.useQuery(undefined, {
|
||||
enabled: session.status === "authenticated",
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const enabled = Boolean(data?.enabled && session.status === "authenticated");
|
||||
|
||||
if (enabled) {
|
||||
return (
|
||||
<AssistantPreferencesProvider>
|
||||
<AssistantViewRefreshProvider>
|
||||
<AssistantRuntime>
|
||||
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
|
||||
</AssistantRuntime>
|
||||
</AssistantViewRefreshProvider>
|
||||
</AssistantPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const unavailableDescription =
|
||||
session.status !== "authenticated"
|
||||
? t("unavailable.signIn")
|
||||
: isLoading
|
||||
? t("unavailable.checking")
|
||||
: isError
|
||||
? t("unavailable.error")
|
||||
: t("unavailable.notConfigured");
|
||||
|
||||
return <DisabledAssistantProvider description={unavailableDescription}>{children}</DisabledAssistantProvider>;
|
||||
};
|
||||
/**
|
||||
* The enabled half of the assistant. Only rendered (and only downloaded) when the instance has the
|
||||
* assistant configured and the visitor is signed in - see `assistant-gate`.
|
||||
*/
|
||||
export const EnabledAssistantRoot = ({ children }: PropsWithChildren) => (
|
||||
<AssistantPreferencesProvider>
|
||||
<AssistantViewRefreshProvider>
|
||||
<AssistantRuntime>
|
||||
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
|
||||
</AssistantRuntime>
|
||||
</AssistantViewRefreshProvider>
|
||||
</AssistantPreferencesProvider>
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
/**
|
||||
* Keeps the assistant discoverable in the spotlight when it is unavailable, without loading the
|
||||
* assistant runtime. Lives apart from `assistant-provider` so the disabled path stays cheap.
|
||||
*/
|
||||
export const useRegisterAssistantSpotlightPlaceholder = (description: string) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const spotlightItem = useMemo(
|
||||
() => ({
|
||||
id: "homarr-assistant",
|
||||
name: t("spotlight"),
|
||||
icon: "/logo/logo.png",
|
||||
description,
|
||||
unavailable: true,
|
||||
alwaysVisible: true,
|
||||
interaction: (_query: string) => ({ type: "none" as const }),
|
||||
}),
|
||||
[description, t],
|
||||
);
|
||||
|
||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
|
||||
/**
|
||||
* Boards register a renderer for the assistant widget even when no board uses one. Loading the
|
||||
* renderer lazily keeps the conversation surface out of the board bundle until a board actually
|
||||
* places an assistant widget.
|
||||
*/
|
||||
const LazyAssistantBoardWidget = dynamic(() =>
|
||||
import("./assistant-widget").then((module) => ({ default: module.AssistantBoardWidget })),
|
||||
);
|
||||
|
||||
export const AssistantBoardWidgetLazy = (props: WidgetComponentProps<"assistant">) => (
|
||||
<LazyAssistantBoardWidget {...props} />
|
||||
);
|
||||
@@ -12,7 +12,7 @@ import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
import classes from "./assistant-panel.module.css";
|
||||
import { AssistantConversationSurface } from "./assistant-panel";
|
||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-provider";
|
||||
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
|
||||
|
||||
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
||||
const assistant = useHomarrAssistant();
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { useOptionalHomarrAssistant } from "./assistant/assistant-provider";
|
||||
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";
|
||||
|
||||
@@ -43,9 +43,14 @@ describe("Custom JSX custom widgets", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
// The submit button is disabled until the form finishes hydrating, so wait for it
|
||||
// before filling to avoid a hydration re-mount resetting the field values.
|
||||
const submitButton = page.locator("css=button[type='submit']");
|
||||
await expect(submitButton).toBeEnabled({ timeout: 30_000 });
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await expect(page.getByLabel("Username")).toHaveValue(adminCredentials.username);
|
||||
await submitButton.click();
|
||||
await expect(page).not.toHaveURL(/\/auth\/login/, { timeout: 30_000 });
|
||||
|
||||
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
interface OrderableMessage {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders thread messages so that a parent always precedes its children.
|
||||
*
|
||||
* The database can only order by `created_at`, which is second-granular on MySQL and SQLite. Two
|
||||
* messages written in the same second therefore tie, and the client rebuilds the conversation tree
|
||||
* from `parent_id`, so a child arriving before its parent corrupts the thread. Sorting here keeps
|
||||
* the relative order of siblings from the incoming (already deterministic) list.
|
||||
*/
|
||||
export const orderMessagesByParent = <TMessage extends OrderableMessage>(messages: TMessage[]): TMessage[] => {
|
||||
if (messages.length <= 1) return messages;
|
||||
|
||||
const messagesById = new Map<string, TMessage>();
|
||||
const incomingIndexById = new Map<string, number>();
|
||||
messages.forEach((message, index) => {
|
||||
messagesById.set(message.id, message);
|
||||
incomingIndexById.set(message.id, index);
|
||||
});
|
||||
|
||||
const childrenByParent = new Map<string | null, TMessage[]>();
|
||||
const resolvedParentIds = new Map<string, string | null>();
|
||||
|
||||
for (const message of messages) {
|
||||
// Treat a dangling parent reference as a root so the message is never dropped.
|
||||
const parentId = message.parentId !== null && messagesById.has(message.parentId) ? message.parentId : null;
|
||||
resolvedParentIds.set(message.id, parentId);
|
||||
const siblings = childrenByParent.get(parentId);
|
||||
if (siblings) siblings.push(message);
|
||||
else childrenByParent.set(parentId, [message]);
|
||||
}
|
||||
|
||||
const ordered: TMessage[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
// A conversation is one long parent chain, so the traversal depth grows with the message count.
|
||||
// An explicit stack keeps a long thread from exhausting the call stack.
|
||||
const visitFrom = (start: TMessage) => {
|
||||
const stack: TMessage[] = [start];
|
||||
while (stack.length > 0) {
|
||||
const message = stack.pop();
|
||||
if (!message || visited.has(message.id)) continue;
|
||||
visited.add(message.id);
|
||||
ordered.push(message);
|
||||
const children = childrenByParent.get(message.id);
|
||||
if (!children) continue;
|
||||
// Pushed in reverse so the stack pops siblings back in their incoming order.
|
||||
for (let index = children.length - 1; index >= 0; index--) {
|
||||
const child = children[index];
|
||||
if (child) stack.push(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of childrenByParent.get(null) ?? []) {
|
||||
visitFrom(root);
|
||||
}
|
||||
|
||||
// Whatever is left belongs to a parent cycle, which by definition has no root to start from.
|
||||
// Corrupted data should still come back complete and in a sensible order, so each cycle is
|
||||
// broken at its earliest member in the incoming order and traversed from there. Only that one
|
||||
// back-edge can violate parent-before-child; every other message in the component is fine.
|
||||
const findCycleEntry = (start: TMessage) => {
|
||||
const path: TMessage[] = [start];
|
||||
const positionInPath = new Map<string, number>([[start.id, 0]]);
|
||||
let current = start;
|
||||
|
||||
for (;;) {
|
||||
const parentId = resolvedParentIds.get(current.id) ?? null;
|
||||
if (parentId === null || visited.has(parentId)) return current;
|
||||
|
||||
const parent = messagesById.get(parentId);
|
||||
if (!parent) return current;
|
||||
|
||||
const loopStart = positionInPath.get(parent.id);
|
||||
if (loopStart !== undefined) {
|
||||
// Closed the loop: the cycle is the tail of the walked path. Start from whichever of its
|
||||
// members came first in the incoming list so the choice is deterministic.
|
||||
return path.slice(loopStart).reduce((earliest, member) => {
|
||||
const memberIndex = incomingIndexById.get(member.id) ?? 0;
|
||||
const earliestIndex = incomingIndexById.get(earliest.id) ?? 0;
|
||||
return memberIndex < earliestIndex ? member : earliest;
|
||||
});
|
||||
}
|
||||
|
||||
positionInPath.set(parent.id, path.length);
|
||||
path.push(parent);
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
if (visited.has(message.id)) continue;
|
||||
visitFrom(findCycleEntry(message));
|
||||
}
|
||||
|
||||
return ordered;
|
||||
};
|
||||
@@ -173,7 +173,7 @@ export const appRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
|
||||
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL or custom URI scheme such as vscode://, or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -29,8 +29,9 @@ import {
|
||||
|
||||
import type { createTRPCContext } from "../trpc";
|
||||
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
|
||||
import { orderMessagesByParent } from "../assistant-message-order";
|
||||
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { boardRouter } from "./board";
|
||||
|
||||
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
|
||||
@@ -440,43 +441,24 @@ const addFeedbackToMessageContent = (serializedContent: string, type: "positive"
|
||||
};
|
||||
|
||||
export const assistantRouter = createTRPCRouter({
|
||||
getAvailability: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Check whether Homarr Assistant is enabled and configured for the current user",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||
return {
|
||||
enabled: Boolean(
|
||||
configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
|
||||
),
|
||||
};
|
||||
}),
|
||||
getAvailability: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (isDemoMode) {
|
||||
return { enabled: true };
|
||||
}
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
|
||||
return {
|
||||
enabled: Boolean(
|
||||
configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
getContextEntities: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Lists the apps, integrations, boards, and widgets the signed-in user may reference. Integration entries are permission-filtered. Use returned IDs as inputs for other tools.",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
return await getAssistantContextEntitiesAsync(ctx);
|
||||
}),
|
||||
getContextEntities: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await getAssistantContextEntitiesAsync(ctx);
|
||||
}),
|
||||
|
||||
getModelCapabilities: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Returns the configured assistant model's discovered input modalities and whether image input is supported, unsupported, or unknown.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
@@ -495,6 +477,24 @@ export const assistantRouter = createTRPCRouter({
|
||||
|
||||
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
if (isDemoMode) {
|
||||
return {
|
||||
provider: "homarr",
|
||||
defaultModelId: "homarr-assistant",
|
||||
models: [
|
||||
{
|
||||
id: "homarr-assistant",
|
||||
name: "Homarr Assistant (Demo)",
|
||||
description: null,
|
||||
contextLength: null,
|
||||
promptPrice: null,
|
||||
completionPrice: null,
|
||||
inputModalities: [],
|
||||
toolSupport: "unknown" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!configuration?.enabled || !configuration.modelId) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
||||
}
|
||||
@@ -711,11 +711,13 @@ export const assistantRouter = createTRPCRouter({
|
||||
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,
|
||||
// `created_at` is only second-granular on MySQL and SQLite, so sibling messages written in the
|
||||
// same second tie. The id tiebreaker makes the database order at least deterministic.
|
||||
orderBy: [asc(assistantMessages.createdAt), asc(assistantMessages.id)],
|
||||
});
|
||||
return {
|
||||
thread,
|
||||
messages: messages.map((message) => ({
|
||||
messages: orderMessagesByParent(messages).map((message) => ({
|
||||
id: message.id,
|
||||
parentId: message.parentId,
|
||||
format: message.format,
|
||||
@@ -739,7 +741,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
}),
|
||||
getConfigurationAsync(ctx.db),
|
||||
]);
|
||||
if (!message) throw new TRPCError({ code: "NOT_FOUND", message: "Message not found." });
|
||||
if (!message) return { complete: true, generations: [] };
|
||||
if (configuration?.provider !== "openrouter" || !configuration.encryptedApiKey) {
|
||||
return { complete: true, generations: [] };
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("integration-full-all")) {
|
||||
if (ctx.session?.user.permissions.includes("integration-create")) {
|
||||
statistics.push({
|
||||
titleKey: "integration",
|
||||
subtitleKey: "resources",
|
||||
|
||||
@@ -62,13 +62,12 @@ export const integrationRouter = createTRPCRouter({
|
||||
requiredSecrets: def.secretKinds,
|
||||
}));
|
||||
}),
|
||||
all: permissionRequiredProcedure
|
||||
.requiresPermission("integration-full-all")
|
||||
all: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Requires integration-full-all permission. Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error",
|
||||
"List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { orderMessagesByParent } from "../assistant-message-order";
|
||||
|
||||
const message = (id: string, parentId: string | null) => ({ id, parentId });
|
||||
|
||||
describe("orderMessagesByParent", () => {
|
||||
test("keeps an already correct order untouched", () => {
|
||||
const messages = [message("a", null), message("b", "a"), message("c", "b")];
|
||||
|
||||
expect(orderMessagesByParent(messages).map((entry) => entry.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("moves a child that the database returned before its parent", () => {
|
||||
// Happens when created_at ties on MySQL/SQLite, which store whole seconds only.
|
||||
const messages = [message("c", "b"), message("a", null), message("b", "a")];
|
||||
|
||||
expect(orderMessagesByParent(messages).map((entry) => entry.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("preserves sibling order from the incoming list", () => {
|
||||
const messages = [message("root", null), message("first", "root"), message("second", "root")];
|
||||
|
||||
expect(orderMessagesByParent(messages).map((entry) => entry.id)).toEqual(["root", "first", "second"]);
|
||||
});
|
||||
|
||||
test("keeps branches grouped under their parent", () => {
|
||||
const messages = [
|
||||
message("root", null),
|
||||
message("branch-a", "root"),
|
||||
message("branch-b", "root"),
|
||||
message("branch-a-child", "branch-a"),
|
||||
];
|
||||
|
||||
expect(orderMessagesByParent(messages).map((entry) => entry.id)).toEqual([
|
||||
"root",
|
||||
"branch-a",
|
||||
"branch-a-child",
|
||||
"branch-b",
|
||||
]);
|
||||
});
|
||||
|
||||
test("treats a dangling parent reference as a root instead of dropping the message", () => {
|
||||
const messages = [message("orphan", "deleted-parent"), message("root", null)];
|
||||
|
||||
expect(orderMessagesByParent(messages).map((entry) => entry.id)).toEqual(["orphan", "root"]);
|
||||
});
|
||||
|
||||
test("does not lose messages that form a cycle", () => {
|
||||
const messages = [message("a", "b"), message("b", "a")];
|
||||
|
||||
expect(
|
||||
orderMessagesByParent(messages)
|
||||
.map((entry) => entry.id)
|
||||
.toSorted(),
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("keeps a child of a cycle behind its parent", () => {
|
||||
// `child` hangs off the cycle a -> b -> a. Only the cycle's own back-edge can break the
|
||||
// parent-before-child contract; `child` must still come after `a`.
|
||||
const messages = [message("child", "a"), message("a", "b"), message("b", "a")];
|
||||
|
||||
const ordered = orderMessagesByParent(messages).map((entry) => entry.id);
|
||||
|
||||
expect(ordered.toSorted()).toEqual(["a", "b", "child"]);
|
||||
expect(ordered.indexOf("a")).toBeLessThan(ordered.indexOf("child"));
|
||||
});
|
||||
|
||||
test("handles a chain deeper than the call stack", () => {
|
||||
// `getThread` loads every message in a thread, and a conversation is one long parent chain, so
|
||||
// a recursive walk would blow the stack on a long thread.
|
||||
const depth = 50_000;
|
||||
const messages = Array.from({ length: depth }, (_, index) =>
|
||||
message(`m${index}`, index === 0 ? null : `m${index - 1}`),
|
||||
);
|
||||
|
||||
const ordered = orderMessagesByParent(messages);
|
||||
|
||||
expect(ordered).toHaveLength(depth);
|
||||
expect(ordered[0]?.id).toBe("m0");
|
||||
expect(ordered[depth - 1]?.id).toBe(`m${depth - 1}`);
|
||||
});
|
||||
|
||||
test("returns short lists unchanged", () => {
|
||||
expect(orderMessagesByParent([])).toEqual([]);
|
||||
expect(orderMessagesByParent([message("only", null)]).map((entry) => entry.id)).toEqual(["only"]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { extractToolsFromProcedures } from "trpc-to-mcp";
|
||||
|
||||
import { assistantRouter } from "../router/assistant";
|
||||
import { appRouter } from "../router/app";
|
||||
import { boardRouter } from "../router/board";
|
||||
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
|
||||
@@ -28,7 +27,6 @@ vi.mock("@homarr/core/infrastructure/db/env", () => ({
|
||||
}));
|
||||
|
||||
const mcpTestRouter = createTRPCRouter({
|
||||
assistant: assistantRouter,
|
||||
app: appRouter,
|
||||
board: boardRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
@@ -49,9 +47,6 @@ test("MCP tools should contain expected procedures", () => {
|
||||
const toolNames = tools.map((tool) => tool.name);
|
||||
|
||||
expect(tools.length).toBeGreaterThan(0);
|
||||
expect(toolNames).toContain("assistant_getAvailability");
|
||||
expect(toolNames).toContain("assistant_getContextEntities");
|
||||
expect(toolNames).toContain("assistant_getModelCapabilities");
|
||||
expect(toolNames).toContain("app_all");
|
||||
expect(toolNames).toContain("app_byId");
|
||||
expect(toolNames).toContain("app_create");
|
||||
|
||||
@@ -26,7 +26,7 @@ CREATE TABLE `assistant_message` (
|
||||
`thread_id` varchar(64) NOT NULL,
|
||||
`parent_id` varchar(128),
|
||||
`format` varchar(64) DEFAULT 'ai-sdk/v6' NOT NULL,
|
||||
`content` text DEFAULT ('{"json": {}}') NOT NULL,
|
||||
`content` mediumtext DEFAULT ('{"json": {}}') NOT NULL,
|
||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
CONSTRAINT `assistant_message_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
|
||||
@@ -2627,7 +2627,7 @@
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -512,6 +512,8 @@ const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
|
||||
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
|
||||
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
|
||||
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
|
||||
// Row 12: assistant
|
||||
{ kind: "assistant", width: 3, height: 3, needsIntegration: false },
|
||||
];
|
||||
|
||||
const seedDemoUserAsync = async (db: Database) => {
|
||||
|
||||
@@ -6,8 +6,10 @@ import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
||||
import {
|
||||
boolean,
|
||||
customType,
|
||||
foreignKey,
|
||||
index,
|
||||
int,
|
||||
mediumtext,
|
||||
mysqlTable,
|
||||
primaryKey,
|
||||
smallint,
|
||||
@@ -536,7 +538,9 @@ export const assistantMessages = mysqlTable(
|
||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||
parentId: varchar({ length: 128 }),
|
||||
format: varchar({ length: 64 }).notNull().default("ai-sdk/v6"),
|
||||
content: text().default(emptySuperJSON).notNull(),
|
||||
// `text` only holds 64KB on MySQL, which a single base64 image attachment already exceeds.
|
||||
// `mediumtext` holds 16MB, comfortably above the 2MB request cap enforced by the chat route.
|
||||
content: mediumtext().default(emptySuperJSON).notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
(message) => ({
|
||||
@@ -594,14 +598,20 @@ export const customWidgetSecrets = mysqlTable(
|
||||
updatedAt: timestamp()
|
||||
.$onUpdateFn(() => new Date())
|
||||
.notNull(),
|
||||
definitionId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => customWidgetDefinitions.id, { onDelete: "cascade" }),
|
||||
definitionId: varchar({ length: 64 }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.definitionId, table.kind],
|
||||
}),
|
||||
// The name drizzle derives here is 65 characters, one over MySQL's 64-character identifier
|
||||
// limit, so migration 0040 created the key under this shortened name. Declaring it explicitly
|
||||
// keeps `drizzle-kit generate` from re-proposing an ALTER that MySQL rejects with error 1059.
|
||||
definitionReference: foreignKey({
|
||||
name: "cw_secret_definition_id_cw_definition_id_fk",
|
||||
columns: [table.definitionId],
|
||||
foreignColumns: [customWidgetDefinitions.id],
|
||||
}).onDelete("cascade"),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
/**
|
||||
* Guards the migration folders against the failure modes that have actually shipped:
|
||||
*
|
||||
* - v1.70.0 shipped `0042_add_widget_secret.sql` for MySQL without a `_journal.json` entry, so the
|
||||
* migrator never ran it and MySQL instances upgraded from that release had no `widget_secret`
|
||||
* table.
|
||||
* - The follow-up fix hand-wrote `0042_snapshot.json` with a `checkConstraints` key instead of
|
||||
* `checkConstraint`, which made `drizzle-kit generate` reject the folder and silently exit 0 for
|
||||
* every later MySQL migration.
|
||||
*/
|
||||
const dialects = ["mysql", "postgresql", "sqlite"] as const;
|
||||
|
||||
const migrationsRoot = path.join(__dirname, "..", "migrations");
|
||||
|
||||
interface Journal {
|
||||
entries: { idx: number; tag: string }[];
|
||||
}
|
||||
|
||||
const readDialect = (dialect: (typeof dialects)[number]) => {
|
||||
const folder = path.join(migrationsRoot, dialect);
|
||||
const journal = JSON.parse(fs.readFileSync(path.join(folder, "meta", "_journal.json"), "utf8")) as Journal;
|
||||
const sqlFiles = fs
|
||||
.readdirSync(folder)
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.map((file) => file.replace(/\.sql$/u, ""));
|
||||
const snapshots = fs
|
||||
.readdirSync(path.join(folder, "meta"))
|
||||
.filter((file) => /^\d+_snapshot\.json$/u.test(file))
|
||||
.map((file) => file.split("_")[0]);
|
||||
|
||||
return { folder, journal, sqlFiles, snapshots };
|
||||
};
|
||||
|
||||
describe.each(dialects)("%s migrations", (dialect) => {
|
||||
test("every journal entry has a matching .sql file", () => {
|
||||
const { journal, sqlFiles } = readDialect(dialect);
|
||||
const missing = journal.entries.filter((entry) => !sqlFiles.includes(entry.tag)).map((entry) => entry.tag);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test("every .sql file is registered in the journal", () => {
|
||||
const { journal, sqlFiles } = readDialect(dialect);
|
||||
const tags = new Set(journal.entries.map((entry) => entry.tag));
|
||||
const orphans = sqlFiles.filter((file) => !tags.has(file));
|
||||
|
||||
expect(orphans).toEqual([]);
|
||||
});
|
||||
|
||||
test("every journal entry has a matching snapshot", () => {
|
||||
const { journal, snapshots } = readDialect(dialect);
|
||||
const missing = journal.entries
|
||||
.filter((entry) => !snapshots.includes(String(entry.idx).padStart(4, "0")))
|
||||
.map((entry) => entry.tag);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test("journal indexes are unique and gap-free", () => {
|
||||
const { journal } = readDialect(dialect);
|
||||
const indexes = journal.entries.map((entry) => entry.idx);
|
||||
|
||||
expect(indexes).toEqual(indexes.map((_, position) => position));
|
||||
});
|
||||
|
||||
// The check-constraint key is spelled differently per snapshot format version (MySQL's v5 uses
|
||||
// `checkConstraint`, the newer SQLite/Postgres formats use `checkConstraints`), so rather than
|
||||
// hard-coding a spelling this asserts every snapshot in a dialect agrees with the others. A
|
||||
// hand-written snapshot that gets it wrong stands out as the odd one and fails drizzle-kit.
|
||||
test("all snapshots agree on the check-constraint key spelling", () => {
|
||||
const { folder, snapshots } = readDialect(dialect);
|
||||
|
||||
const spellingsPerSnapshot = snapshots.map((snapshot) => {
|
||||
const file = path.join(folder, "meta", `${snapshot}_snapshot.json`);
|
||||
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { tables?: Record<string, object> };
|
||||
const spellings = new Set<string>();
|
||||
|
||||
for (const table of Object.values(parsed.tables ?? {})) {
|
||||
if ("checkConstraint" in table) spellings.add("checkConstraint");
|
||||
if ("checkConstraints" in table) spellings.add("checkConstraints");
|
||||
}
|
||||
|
||||
return { snapshot, spellings: [...spellings].toSorted().join("+") };
|
||||
});
|
||||
|
||||
const used = [...new Set(spellingsPerSnapshot.map((entry) => entry.spellings))].filter(
|
||||
(spelling) => spelling.length > 0,
|
||||
);
|
||||
|
||||
expect(used, `snapshots disagree: ${JSON.stringify(spellingsPerSnapshot.filter((e) => e.spellings))}`).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import path from "path";
|
||||
import { MySqlContainer } from "@testcontainers/mysql";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { migrate } from "drizzle-orm/mysql2/migrator";
|
||||
import mysql from "mysql2";
|
||||
import { describe, test } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
|
||||
@@ -35,6 +36,22 @@ describe("Mysql Migration", () => {
|
||||
// Check if users table exists
|
||||
await database.query.users.findMany();
|
||||
|
||||
// A single base64 image attachment already exceeds the 64KB that `text` holds on MySQL, so
|
||||
// `assistant_message.content` has to be `mediumtext`. Everything below 64KB passes either way.
|
||||
const largeContent = "x".repeat(200_000);
|
||||
await database.insert(mysqlSchema.users).values({ id: "size-check-user", name: "size-check" });
|
||||
await database.insert(mysqlSchema.assistantThreads).values({ id: "size-check-thread", userId: "size-check-user" });
|
||||
await database.insert(mysqlSchema.assistantMessages).values({
|
||||
id: "size-check-message",
|
||||
threadId: "size-check-thread",
|
||||
parentId: null,
|
||||
content: largeContent,
|
||||
});
|
||||
const [storedMessage] = await database.query.assistantMessages.findMany({
|
||||
where: eq(mysqlSchema.assistantMessages.id, "size-check-message"),
|
||||
});
|
||||
expect(storedMessage?.content).toHaveLength(largeContent.length);
|
||||
|
||||
connection.end();
|
||||
await mysqlContainer.stop();
|
||||
}, 120_000);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { is } from "drizzle-orm";
|
||||
import type { Column, InferSelectModel } from "drizzle-orm";
|
||||
import { MySqlTable, getTableConfig as getMysqlTableConfig } from "drizzle-orm/mysql-core";
|
||||
import type { ForeignKey as MysqlForeignKey, MySqlTableWithColumns } from "drizzle-orm/mysql-core";
|
||||
import type { PgTableWithColumns, ForeignKey as PostgresqlForeignKey } from "drizzle-orm/pg-core";
|
||||
import { SQLiteTable, getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core";
|
||||
import type { ForeignKey as SqliteForeignKey, SQLiteTableWithColumns } from "drizzle-orm/sqlite-core";
|
||||
import { expect, expectTypeOf, test } from "vitest";
|
||||
|
||||
@@ -54,6 +57,21 @@ type FixedSqliteConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
/** Identifies a foreign key by what it links rather than by its generated name. */
|
||||
const referenceSignature = (foreignKey: {
|
||||
reference: () => { columns: { name: string }[]; foreignTable: object; foreignColumns: { name: string }[] };
|
||||
}) => {
|
||||
const reference = foreignKey.reference();
|
||||
const target = Object.keys(reference.foreignTable).sort().join("+");
|
||||
return `${reference.columns
|
||||
.map((column) => column.name)
|
||||
.sort()
|
||||
.join(",")}->${target}(${reference.foreignColumns
|
||||
.map((column) => column.name)
|
||||
.sort()
|
||||
.join(",")})`;
|
||||
};
|
||||
|
||||
test("schemas should match", () => {
|
||||
expectTypeOf<SqliteTables>().toEqualTypeOf<MysqlTables>();
|
||||
expectTypeOf<MysqlTables>().toEqualTypeOf<SqliteTables>();
|
||||
@@ -88,56 +106,64 @@ test("schemas should match", () => {
|
||||
});
|
||||
|
||||
const mysqlTable = mysqlTables[tableName];
|
||||
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
|
||||
| SqliteForeignKey[]
|
||||
| undefined;
|
||||
const mysqlForeignKeys = mysqlTable[Symbol.for("drizzle:MySqlInlineForeignKeys") as keyof typeof mysqlTable] as
|
||||
| MysqlForeignKey[]
|
||||
| undefined;
|
||||
// Read through getTableConfig so keys declared at table level count too, not just inline
|
||||
// `.references()` ones. MySQL needs a table-level declaration wherever drizzle's derived name
|
||||
// would exceed its 64-character identifier limit.
|
||||
if (!is(sqliteTable, SQLiteTable) || !is(mysqlTable, MySqlTable)) return;
|
||||
|
||||
if (!sqliteForeignKeys && !mysqlForeignKeys) return;
|
||||
const sqliteForeignKeys = getSqliteTableConfig(sqliteTable).foreignKeys as SqliteForeignKey[];
|
||||
const mysqlForeignKeys = getMysqlTableConfig(mysqlTable).foreignKeys as MysqlForeignKey[];
|
||||
|
||||
expect(mysqlForeignKeys, `mysql foreign key for ${tableName} to be defined`).toBeDefined();
|
||||
expect(sqliteForeignKeys, `sqlite foreign key for ${tableName} to be defined`).toBeDefined();
|
||||
if (sqliteForeignKeys.length === 0 && mysqlForeignKeys.length === 0) return;
|
||||
|
||||
expect(
|
||||
sqliteForeignKeys!.length,
|
||||
sqliteForeignKeys.length,
|
||||
`expect number of foreign keys in table ${tableName} to be the same for both schemas`,
|
||||
).toEqual(mysqlForeignKeys!.length);
|
||||
).toEqual(mysqlForeignKeys.length);
|
||||
|
||||
sqliteForeignKeys?.forEach((sqliteForeignKey) => {
|
||||
sqliteForeignKey.getName();
|
||||
const mysqlForeignKey = mysqlForeignKeys!.find((key) => key.getName() === sqliteForeignKey.getName());
|
||||
expect(
|
||||
mysqlForeignKey,
|
||||
`expect foreign key ${sqliteForeignKey.getName()} to be defined in mysql schema`,
|
||||
).toBeDefined();
|
||||
sqliteForeignKeys.forEach((sqliteForeignKey) => {
|
||||
const describeKey = `${tableName}.${referenceSignature(sqliteForeignKey)}`;
|
||||
// Matched on what the key actually does rather than on its generated name, because the name
|
||||
// is allowed to differ per dialect.
|
||||
const mysqlForeignKey = mysqlForeignKeys.find(
|
||||
(key) => referenceSignature(key) === referenceSignature(sqliteForeignKey),
|
||||
);
|
||||
expect(mysqlForeignKey, `expect foreign key ${describeKey} to be defined in mysql schema`).toBeDefined();
|
||||
|
||||
expect(
|
||||
sqliteForeignKey.onDelete,
|
||||
`expect foreign key (${sqliteForeignKey.getName()}) onDelete to be the same for both schemas`,
|
||||
`expect foreign key (${describeKey}) onDelete to be the same for both schemas`,
|
||||
).toEqual(mysqlForeignKey!.onDelete);
|
||||
|
||||
expect(
|
||||
sqliteForeignKey.onUpdate,
|
||||
`expect foreign key (${sqliteForeignKey.getName()}) onUpdate to be the same for both schemas`,
|
||||
`expect foreign key (${describeKey}) onUpdate to be the same for both schemas`,
|
||||
).toEqual(mysqlForeignKey!.onUpdate);
|
||||
|
||||
sqliteForeignKey.reference().foreignColumns.forEach((column) => {
|
||||
expect(
|
||||
mysqlForeignKey!.reference().foreignColumns.map((column) => column.name),
|
||||
`expect foreign key (${sqliteForeignKey.getName()}) columns to be the same for both schemas`,
|
||||
).toContainEqual(column.name);
|
||||
});
|
||||
|
||||
expect(
|
||||
Object.keys(sqliteForeignKey.reference().foreignTable),
|
||||
`expect foreign key (${sqliteForeignKey.getName()}) table to be the same for both schemas`,
|
||||
).toEqual(Object.keys(mysqlForeignKey!.reference().foreignTable));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("mysql identifiers stay within the 64 character limit", () => {
|
||||
// MySQL rejects longer identifiers with error 1059, and drizzle derives foreign key names from
|
||||
// the table and column names, so a long pairing silently produces a migration that cannot apply.
|
||||
const tooLong: string[] = [];
|
||||
|
||||
objectEntries(mysqlSchema).forEach(([, table]) => {
|
||||
if (!is(table, MySqlTable)) return;
|
||||
|
||||
const config = getMysqlTableConfig(table);
|
||||
if (config.name.length > 64) tooLong.push(config.name);
|
||||
for (const foreignKey of config.foreignKeys) {
|
||||
if (foreignKey.getName().length > 64) tooLong.push(foreignKey.getName());
|
||||
}
|
||||
for (const index of config.indexes) {
|
||||
if (index.config.name.length > 64) tooLong.push(index.config.name);
|
||||
}
|
||||
});
|
||||
|
||||
expect(tooLong).toEqual([]);
|
||||
});
|
||||
|
||||
test("schemas should match for postgresql", () => {
|
||||
expectTypeOf<SqliteTables>().toEqualTypeOf<PostgresqlTables>();
|
||||
expectTypeOf<PostgresqlTables>().toEqualTypeOf<SqliteTables>();
|
||||
|
||||
@@ -185,9 +185,15 @@ export class OverseerrIntegration
|
||||
}),
|
||||
);
|
||||
|
||||
return settled
|
||||
const fulfilled = settled
|
||||
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
|
||||
if (fulfilled.length === 0) {
|
||||
throw new Error("Failed to resolve any media request information");
|
||||
}
|
||||
|
||||
return fulfilled;
|
||||
}
|
||||
|
||||
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
|
||||
|
||||
@@ -95,9 +95,7 @@ export const globalCommandGroup = createGroup<Command>({
|
||||
icon: IconPlug,
|
||||
name: tOption("newIntegration.label"),
|
||||
useInteraction: interaction.children(newIntegrationChildrenOptions),
|
||||
hidden:
|
||||
!session?.user.permissions.includes("integration-full-all") ||
|
||||
!session?.user.permissions.includes("integration-create"),
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
},
|
||||
{
|
||||
commandKey: "newUser",
|
||||
|
||||
@@ -82,7 +82,7 @@ export const pagesSearchGroup = createGroup<{
|
||||
icon: IconPlug,
|
||||
path: "/manage/integrations",
|
||||
name: t("manageIntegration.label"),
|
||||
hidden: !session?.user.permissions.includes("integration-full-all"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
|
||||
@@ -1513,7 +1513,6 @@
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Homarr Assistant",
|
||||
"subtitle": "Connected to this Homarr instance",
|
||||
"open": "Open Homarr Assistant",
|
||||
"close": "Close assistant",
|
||||
"minimize": "Minimize assistant",
|
||||
|
||||
@@ -3,12 +3,7 @@ import { z } from "zod/v4";
|
||||
export const appHrefSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.url()
|
||||
.regex(/^(?!javascript)[a-zA-Z]*:\/\//i) // javascript: is not allowed, i for case insensitive (so Javascript: is also not allowed)
|
||||
.or(z.string().trim().includes("[homarr_base]"))
|
||||
.or(z.string().trim().includes("[homarr_hostname]"))
|
||||
.or(z.string().trim().includes("[homarr_domain]"))
|
||||
.or(z.string().trim().includes("[homarr_protocol]"))
|
||||
.refine((val) => !/^javascript:/i.test(val), "JavaScript URIs are not permitted")
|
||||
.or(z.literal(""))
|
||||
.transform((value) => (value.length === 0 ? null : value))
|
||||
.nullable();
|
||||
|
||||
Reference in new issue
Block a user