Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc11e8d17 | ||
|
|
09c56002d6 | ||
|
|
7276bdad21 | ||
|
|
f25be7cf1c | ||
|
|
a4e1c3c60e | ||
|
|
d82db8c6bd | ||
|
|
a4a53af79d | ||
|
|
4acd6548b5 |
No files matched your search
@@ -26,7 +26,8 @@ import type { SupportedLanguage } from "@homarr/translation";
|
|||||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||||
|
|
||||||
import { Analytics } from "~/components/layout/analytics";
|
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 { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||||
|
|
||||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||||
@@ -86,6 +87,16 @@ export default async function Layout(props: {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
||||||
const serverSettings = await getServerSettingsAsync(db);
|
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 colorScheme = await getCurrentColorSchemeAsync();
|
||||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
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) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||||
(innerProps) => <ModalProvider {...innerProps} />,
|
(innerProps) => <ModalProvider {...innerProps} />,
|
||||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||||
(innerProps) => <AssistantProvider {...innerProps} />,
|
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { locale } = await props.params;
|
const { locale } = await props.params;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
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 { useRouter } from "next/navigation";
|
||||||
import type {
|
import type {
|
||||||
AttachmentAdapter,
|
AttachmentAdapter,
|
||||||
@@ -17,7 +17,6 @@ import { useHotkeys } from "@mantine/hooks";
|
|||||||
import { createAssistantStream } from "assistant-stream";
|
import { createAssistantStream } from "assistant-stream";
|
||||||
|
|
||||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
|
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
} from "@homarr/spotlight";
|
} from "@homarr/spotlight";
|
||||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||||
|
|
||||||
|
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||||
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
|
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
|
||||||
import { createAssistantBrowserToolExecutors } from "./assistant-browser-tool-executors";
|
import { createAssistantBrowserToolExecutors } from "./assistant-browser-tool-executors";
|
||||||
@@ -53,33 +53,6 @@ import { initialAssistantNotificationState, updateAssistantNotificationState } f
|
|||||||
import { AssistantViewRefreshProvider, useAssistantViewRefresh } from "./assistant-view-refresh";
|
import { AssistantViewRefreshProvider, useAssistantViewRefresh } from "./assistant-view-refresh";
|
||||||
import { AssistantBoardWidget } from "./assistant-widget";
|
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 ignoreUnsupportedArchiveAction = () => Promise.resolve();
|
||||||
const assistantImageAttachmentTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"];
|
const assistantImageAttachmentTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"];
|
||||||
const assistantDocumentAttachmentTypes = [
|
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 AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
|
||||||
const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, {
|
const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, {
|
||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
@@ -766,79 +723,16 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DisabledAssistantProviderProps extends PropsWithChildren {
|
/**
|
||||||
description: string;
|
* 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`.
|
||||||
|
*/
|
||||||
const DisabledAssistantProvider = ({ children, description }: DisabledAssistantProviderProps) => {
|
export const EnabledAssistantRoot = ({ children }: PropsWithChildren) => (
|
||||||
const t = useScopedI18n("common.assistant");
|
<AssistantPreferencesProvider>
|
||||||
const spotlightItem = useMemo(
|
<AssistantViewRefreshProvider>
|
||||||
() => ({
|
<AssistantRuntime>
|
||||||
id: "homarr-assistant",
|
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
|
||||||
name: t("spotlight"),
|
</AssistantRuntime>
|
||||||
icon: "/logo/logo.png",
|
</AssistantViewRefreshProvider>
|
||||||
description,
|
</AssistantPreferencesProvider>
|
||||||
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>;
|
|
||||||
};
|
|
||||||
@@ -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 classes from "./assistant-panel.module.css";
|
||||||
import { AssistantConversationSurface } from "./assistant-panel";
|
import { AssistantConversationSurface } from "./assistant-panel";
|
||||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||||
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-provider";
|
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
|
||||||
|
|
||||||
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
||||||
const assistant = useHomarrAssistant();
|
const assistant = useHomarrAssistant();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
|||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
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 { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
|
|
||||||
import type { createTRPCContext } from "../trpc";
|
import type { createTRPCContext } from "../trpc";
|
||||||
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
|
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
|
||||||
|
import { orderMessagesByParent } from "../assistant-message-order";
|
||||||
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
|
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
|
||||||
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||||
import { boardRouter } from "./board";
|
import { boardRouter } from "./board";
|
||||||
@@ -710,11 +711,13 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
|
||||||
const messages = await ctx.db.query.assistantMessages.findMany({
|
const messages = await ctx.db.query.assistantMessages.findMany({
|
||||||
where: eq(assistantMessages.threadId, thread.id),
|
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 {
|
return {
|
||||||
thread,
|
thread,
|
||||||
messages: messages.map((message) => ({
|
messages: orderMessagesByParent(messages).map((message) => ({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
parentId: message.parentId,
|
parentId: message.parentId,
|
||||||
format: message.format,
|
format: message.format,
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ CREATE TABLE `assistant_message` (
|
|||||||
`thread_id` varchar(64) NOT NULL,
|
`thread_id` varchar(64) NOT NULL,
|
||||||
`parent_id` varchar(128),
|
`parent_id` varchar(128),
|
||||||
`format` varchar(64) DEFAULT 'ai-sdk/v6' NOT NULL,
|
`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,
|
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
||||||
CONSTRAINT `assistant_message_id` PRIMARY KEY(`id`)
|
CONSTRAINT `assistant_message_id` PRIMARY KEY(`id`)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2627,7 +2627,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraint": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"views": {},
|
"views": {},
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -6,8 +6,10 @@ import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
customType,
|
customType,
|
||||||
|
foreignKey,
|
||||||
index,
|
index,
|
||||||
int,
|
int,
|
||||||
|
mediumtext,
|
||||||
mysqlTable,
|
mysqlTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
smallint,
|
smallint,
|
||||||
@@ -536,7 +538,9 @@ export const assistantMessages = mysqlTable(
|
|||||||
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
.references(() => assistantThreads.id, { onDelete: "cascade" }),
|
||||||
parentId: varchar({ length: 128 }),
|
parentId: varchar({ length: 128 }),
|
||||||
format: varchar({ length: 64 }).notNull().default("ai-sdk/v6"),
|
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(),
|
createdAt: timestamp().notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(message) => ({
|
(message) => ({
|
||||||
@@ -594,14 +598,20 @@ export const customWidgetSecrets = mysqlTable(
|
|||||||
updatedAt: timestamp()
|
updatedAt: timestamp()
|
||||||
.$onUpdateFn(() => new Date())
|
.$onUpdateFn(() => new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
definitionId: varchar({ length: 64 })
|
definitionId: varchar({ length: 64 }).notNull(),
|
||||||
.notNull()
|
|
||||||
.references(() => customWidgetDefinitions.id, { onDelete: "cascade" }),
|
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
compoundKey: primaryKey({
|
compoundKey: primaryKey({
|
||||||
columns: [table.definitionId, table.kind],
|
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 path from "path";
|
||||||
import { MySqlContainer } from "@testcontainers/mysql";
|
import { MySqlContainer } from "@testcontainers/mysql";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { drizzle } from "drizzle-orm/mysql2";
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
import { migrate } from "drizzle-orm/mysql2/migrator";
|
import { migrate } from "drizzle-orm/mysql2/migrator";
|
||||||
import mysql from "mysql2";
|
import mysql from "mysql2";
|
||||||
import { describe, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||||
|
|
||||||
@@ -35,6 +36,22 @@ describe("Mysql Migration", () => {
|
|||||||
// Check if users table exists
|
// Check if users table exists
|
||||||
await database.query.users.findMany();
|
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();
|
connection.end();
|
||||||
await mysqlContainer.stop();
|
await mysqlContainer.stop();
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import { is } from "drizzle-orm";
|
||||||
import type { Column, InferSelectModel } 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 { ForeignKey as MysqlForeignKey, MySqlTableWithColumns } from "drizzle-orm/mysql-core";
|
||||||
import type { PgTableWithColumns, ForeignKey as PostgresqlForeignKey } from "drizzle-orm/pg-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 type { ForeignKey as SqliteForeignKey, SQLiteTableWithColumns } from "drizzle-orm/sqlite-core";
|
||||||
import { expect, expectTypeOf, test } from "vitest";
|
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", () => {
|
test("schemas should match", () => {
|
||||||
expectTypeOf<SqliteTables>().toEqualTypeOf<MysqlTables>();
|
expectTypeOf<SqliteTables>().toEqualTypeOf<MysqlTables>();
|
||||||
expectTypeOf<MysqlTables>().toEqualTypeOf<SqliteTables>();
|
expectTypeOf<MysqlTables>().toEqualTypeOf<SqliteTables>();
|
||||||
@@ -88,56 +106,64 @@ test("schemas should match", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mysqlTable = mysqlTables[tableName];
|
const mysqlTable = mysqlTables[tableName];
|
||||||
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
|
// Read through getTableConfig so keys declared at table level count too, not just inline
|
||||||
| SqliteForeignKey[]
|
// `.references()` ones. MySQL needs a table-level declaration wherever drizzle's derived name
|
||||||
| undefined;
|
// would exceed its 64-character identifier limit.
|
||||||
const mysqlForeignKeys = mysqlTable[Symbol.for("drizzle:MySqlInlineForeignKeys") as keyof typeof mysqlTable] as
|
if (!is(sqliteTable, SQLiteTable) || !is(mysqlTable, MySqlTable)) return;
|
||||||
| MysqlForeignKey[]
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
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();
|
if (sqliteForeignKeys.length === 0 && mysqlForeignKeys.length === 0) return;
|
||||||
expect(sqliteForeignKeys, `sqlite foreign key for ${tableName} to be defined`).toBeDefined();
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
sqliteForeignKeys!.length,
|
sqliteForeignKeys.length,
|
||||||
`expect number of foreign keys in table ${tableName} to be the same for both schemas`,
|
`expect number of foreign keys in table ${tableName} to be the same for both schemas`,
|
||||||
).toEqual(mysqlForeignKeys!.length);
|
).toEqual(mysqlForeignKeys.length);
|
||||||
|
|
||||||
sqliteForeignKeys?.forEach((sqliteForeignKey) => {
|
sqliteForeignKeys.forEach((sqliteForeignKey) => {
|
||||||
sqliteForeignKey.getName();
|
const describeKey = `${tableName}.${referenceSignature(sqliteForeignKey)}`;
|
||||||
const mysqlForeignKey = mysqlForeignKeys!.find((key) => key.getName() === sqliteForeignKey.getName());
|
// Matched on what the key actually does rather than on its generated name, because the name
|
||||||
expect(
|
// is allowed to differ per dialect.
|
||||||
mysqlForeignKey,
|
const mysqlForeignKey = mysqlForeignKeys.find(
|
||||||
`expect foreign key ${sqliteForeignKey.getName()} to be defined in mysql schema`,
|
(key) => referenceSignature(key) === referenceSignature(sqliteForeignKey),
|
||||||
).toBeDefined();
|
);
|
||||||
|
expect(mysqlForeignKey, `expect foreign key ${describeKey} to be defined in mysql schema`).toBeDefined();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
sqliteForeignKey.onDelete,
|
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);
|
).toEqual(mysqlForeignKey!.onDelete);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
sqliteForeignKey.onUpdate,
|
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);
|
).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", () => {
|
test("schemas should match for postgresql", () => {
|
||||||
expectTypeOf<SqliteTables>().toEqualTypeOf<PostgresqlTables>();
|
expectTypeOf<SqliteTables>().toEqualTypeOf<PostgresqlTables>();
|
||||||
expectTypeOf<PostgresqlTables>().toEqualTypeOf<SqliteTables>();
|
expectTypeOf<PostgresqlTables>().toEqualTypeOf<SqliteTables>();
|
||||||
|
|||||||
Reference in new issue
Block a user