diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 360138044..b2cb2f731 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -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 = 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) => , (innerProps) => , (innerProps) => , - (innerProps) => , + (innerProps) => , ]); return ( diff --git a/apps/nextjs/src/components/assistant/assistant-context.tsx b/apps/nextjs/src/components/assistant/assistant-context.tsx new file mode 100644 index 000000000..6c1306210 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-context.tsx @@ -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; +} + +export const AssistantContext = createContext(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(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; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-gate.tsx b/apps/nextjs/src/components/assistant/assistant-gate.tsx new file mode 100644 index 000000000..b55e232f6 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-gate.tsx @@ -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 ( + + {children} + + ); +}; + +/** + * 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 {children}; + } + + return {children}; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index db9825f8f..8bdd1a5ef 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -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; -} - -const AssistantContext = createContext(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(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 ( - - {children} - - ); -}; - -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 ( - - - - {children} - - - - ); - } - - const unavailableDescription = - session.status !== "authenticated" - ? t("unavailable.signIn") - : isLoading - ? t("unavailable.checking") - : isError - ? t("unavailable.error") - : t("unavailable.notConfigured"); - - return {children}; -}; +/** + * 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) => ( + + + + {children} + + + +); diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts b/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts new file mode 100644 index 000000000..f92beec6c --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts @@ -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]); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-widget-lazy.tsx b/apps/nextjs/src/components/assistant/assistant-widget-lazy.tsx new file mode 100644 index 000000000..059453826 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-widget-lazy.tsx @@ -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">) => ( + +); diff --git a/apps/nextjs/src/components/assistant/assistant-widget.tsx b/apps/nextjs/src/components/assistant/assistant-widget.tsx index 9244444d5..0326f7fac 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget.tsx @@ -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(); diff --git a/packages/api/src/assistant-message-order.ts b/packages/api/src/assistant-message-order.ts new file mode 100644 index 000000000..6e9774e66 --- /dev/null +++ b/packages/api/src/assistant-message-order.ts @@ -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 = (messages: TMessage[]): TMessage[] => { + if (messages.length <= 1) return messages; + + const messagesById = new Map(); + const incomingIndexById = new Map(); + messages.forEach((message, index) => { + messagesById.set(message.id, message); + incomingIndexById.set(message.id, index); + }); + + const childrenByParent = new Map(); + const resolvedParentIds = new Map(); + + 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(); + + // 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([[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; +}; diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 3c7957d29..3544038a1 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -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, diff --git a/packages/api/src/test/assistant-message-order.spec.ts b/packages/api/src/test/assistant-message-order.spec.ts new file mode 100644 index 000000000..83aaa3d9d --- /dev/null +++ b/packages/api/src/test/assistant-message-order.spec.ts @@ -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"]); + }); +}); diff --git a/packages/db/migrations/mysql/0044_add_assistant.sql b/packages/db/migrations/mysql/0044_add_assistant.sql index 511ab6a81..46315c4aa 100644 --- a/packages/db/migrations/mysql/0044_add_assistant.sql +++ b/packages/db/migrations/mysql/0044_add_assistant.sql @@ -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`) ); diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 8fcf63aa8..216d80757 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -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) => ({ diff --git a/packages/db/test/migration-journal.spec.ts b/packages/db/test/migration-journal.spec.ts new file mode 100644 index 000000000..15b1e3a12 --- /dev/null +++ b/packages/db/test/migration-journal.spec.ts @@ -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 }; + const spellings = new Set(); + + 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, + ); + }); +}); diff --git a/packages/db/test/mysql-migration.spec.ts b/packages/db/test/mysql-migration.spec.ts index 472c40c96..634c82524 100644 --- a/packages/db/test/mysql-migration.spec.ts +++ b/packages/db/test/mysql-migration.spec.ts @@ -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("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);