fix(assistant): mysql message storage, migration metadata and per-page bundle cost (#6530)
This commit is contained in:
1 parent
f6f755d5c9
commit
a9b0c2ae47
14 files changed
+527
-129
No files matched your search
@@ -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";
|
||||
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user