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
@@ -13,6 +13,7 @@ import { notFound } from "next/navigation";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { env as authEnv } from "@homarr/auth/env";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
@@ -27,7 +28,8 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { AssistantProvider } from "~/components/assistant/assistant-provider";
|
||||
import type { AssistantAvailability } from "~/components/assistant/assistant-gate";
|
||||
import { AssistantGate } from "~/components/assistant/assistant-gate";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
import { env } from "~/env";
|
||||
|
||||
@@ -98,11 +100,20 @@ export default async function Layout(props: {
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const [session, user, serverSettings, colorScheme] = await Promise.all([
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
|
||||
!session
|
||||
? "unauthenticated"
|
||||
: api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error"),
|
||||
);
|
||||
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
|
||||
sessionPromise,
|
||||
userPromise,
|
||||
getRscServerSettingsAsync(),
|
||||
getCurrentColorSchemeAsync(),
|
||||
assistantAvailabilityPromise,
|
||||
]);
|
||||
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
|
||||
const publicUrls = resolveHomarrUrlConfig({
|
||||
@@ -146,7 +157,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} />,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
|
||||
import { orderMessagesByParent } from "../assistant-message-order";
|
||||
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
|
||||
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
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 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,
|
||||
|
||||
@@ -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,
|
||||
`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`)
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
foreignKey,
|
||||
index,
|
||||
int,
|
||||
mediumtext,
|
||||
mysqlTable,
|
||||
primaryKey,
|
||||
smallint,
|
||||
@@ -487,6 +488,7 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
|
||||
webSearchEnabled: boolean().notNull().default(false),
|
||||
provider: varchar({ length: 32 })
|
||||
.$type<
|
||||
| "homarr"
|
||||
| "openrouter"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
@@ -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().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
(message) => ({
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import path from "path";
|
||||
import { readFileSync } from "node:fs";
|
||||
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";
|
||||
@@ -56,6 +57,22 @@ describe("Mysql Migration", () => {
|
||||
.query<mysql.RowDataPacket[]>("SHOW TABLES LIKE 'custom_widget%'");
|
||||
expect(customWidgetTables).toHaveLength(4);
|
||||
|
||||
// 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);
|
||||
|
||||
Reference in new issue
Block a user