From 41389b61e2e95ebf900f0060b2d9645dcadd490e Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 30 Jul 2026 18:33:54 +0200 Subject: [PATCH] feat: improve assistant background controls --- apps/docs/docs/management/assistant.mdx | 9 +- .../src/app/api/assistant/chat/route.ts | 14 +- .../assistant/assistant-panel.module.css | 58 ++++- .../components/assistant/assistant-panel.tsx | 153 +++++++++++--- .../assistant/assistant-preferences.ts | 7 + .../assistant/assistant-provider.tsx | 200 ++++++++++++++++-- .../assistant/assistant-send.spec.ts | 24 +++ .../components/assistant/assistant-send.ts | 13 ++ packages/api/src/assistant.ts | 1 + packages/api/src/router/assistant.ts | 133 +++++++++--- .../api/src/router/test/assistant.spec.ts | 101 +++++++++ packages/definitions/src/assistant.ts | 3 + packages/translation/src/lang/en.json | 14 ++ 13 files changed, 644 insertions(+), 86 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-preferences.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-send.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-send.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 0f654c7f7..f08ecf484 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -15,7 +15,8 @@ It is available to signed-in users from the user menu and the `Cmd/Ctrl + K` com While a response is running, a compact activity bar appears at the bottom of the page and the user-menu avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed -responses add an unread count to the user menu until they are opened or dismissed. +responses add an unread count to the user menu until they are opened or dismissed. The expanded +conversation has a minimize action that returns it to the activity bar without interrupting a response. `Cmd/Ctrl + Shift + K` opens the compact conversation panel directly. @@ -83,7 +84,11 @@ as part of the prompt. Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations, boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on -the server before adding it to the model context. Type `/` for common prompt shortcuts. +the server before adding it to the model context. Type `/` for common prompt shortcuts. The compact +controls below the composer can switch between models discovered from the configured endpoint. Model +selection is stored on the conversation after a completed request. Thinking mode can be left on +**Auto**, disabled, or set from minimal through high; explicit levels are sent using the AI SDK's +provider-neutral reasoning setting and still depend on support from the chosen model and endpoint. Each completed response includes a circular context-window meter. Select it to open request details when the provider reports them: input, output, cached-input and reasoning tokens, used and remaining context, diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index 67ac03e61..f5c4d6917 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -16,7 +16,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { and, eq } from "@homarr/db"; import { db } from "@homarr/db"; import { assistantConfigurations, assistantThreads } from "@homarr/db/schema"; -import { assistantProviderRequiresApiKey } from "@homarr/definitions"; +import { assistantProviderRequiresApiKey, assistantReasoningModes } from "@homarr/definitions"; import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts"; import type { @@ -50,6 +50,8 @@ const allowedAttachmentMediaTypes = new Set([ ]); const requestSchema = z.object({ id: z.string().min(1).max(64), + modelId: z.string().trim().min(1).max(256).optional(), + reasoning: z.enum(assistantReasoningModes).default("auto"), messages: z .array( z @@ -324,10 +326,17 @@ export async function POST(request: Request) { const context = createTRPCContext({ headers: request.headers, session }); const requestStartedAt = Date.now(); const requestId = crypto.randomUUID(); + const requestedModelId = parsed.data.modelId ?? configuration.modelId; const [selectedModel, mentionContext] = await Promise.all([ - getSelectedModelDetailsAsync(configuration).catch(() => null), + getSelectedModelDetailsAsync(configuration, requestedModelId).catch(() => null), getMentionContextAsync(context, parsed.data.messages), ]); + if (requestedModelId !== configuration.modelId && !selectedModel) { + return Response.json( + { error: "The selected model is not available from the configured provider." }, + { status: 400 }, + ); + } const modelId = selectedModel?.id ?? configuration.modelId; if ( selectedModel && @@ -436,6 +445,7 @@ export async function POST(request: Request) { abortSignal: request.signal, timeout: { totalMs: 55_000, stepMs: 30_000, toolMs: 30_000 }, maxRetries: 2, + reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning, toolApproval, experimental_toolApprovalSecret: getToolApprovalSecret(), onChunk: ({ chunk }) => { diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 05ef455ee..7b7e6dd34 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -675,6 +675,42 @@ padding-inline: 0.45rem; } +.composerFooter { + min-width: 0; + padding-inline: 0.2rem; +} + +.runtimeControls { + min-width: 0; + flex: 1 1 auto; +} + +.modelSelect { + width: min(15rem, 52%); + min-width: 8rem; +} + +.reasoningSelect { + width: 8.75rem; + flex: 0 0 auto; +} + +.runtimeSelectInput { + border-color: transparent; + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-body); + font-weight: 600; +} + +.runtimeSelectInput:hover { + background: var(--mantine-color-default-hover); +} + +.runtimeSelectInput:focus, +.runtimeSelectInput:focus-within { + border-color: var(--mantine-primary-color-filled); +} + .triggerPopover { position: absolute; z-index: 5; @@ -815,6 +851,24 @@ padding: 0.6rem 0.55rem; } + .composerFooter { + padding-inline: 0.1rem; + } + + .composerHints { + display: none; + } + + .runtimeControls { + width: 100%; + } + + .modelSelect { + width: auto; + min-width: 0; + flex: 1 1 auto; + } + .suggestions { grid-template-columns: 1fr; } @@ -861,8 +915,8 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .composerHints { - display: none; + .reasoningSelect { + width: 7.75rem; } } diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 5e65964f1..e760faaf1 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -43,6 +43,7 @@ import { Popover, RingProgress, ScrollArea, + Select, Stack, Text, ThemeIcon, @@ -56,6 +57,7 @@ import { IconApps, IconArrowUp, IconAt, + IconBrain, IconCheck, IconChevronDown, IconChevronLeft, @@ -68,6 +70,7 @@ import { IconFileExport, IconLink, IconMessage, + IconMinus, IconPaperclip, IconPencil, IconHistory, @@ -77,6 +80,7 @@ import { IconRefresh, IconRobot, IconSearch, + IconSparkles, IconThumbDown, IconThumbUp, IconTool, @@ -87,23 +91,32 @@ import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { clientApi } from "@homarr/api/client"; +import { assistantReasoningModes } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; import classes from "./assistant-panel.module.css"; import { normalizeAssistantMarkdown } from "./assistant-markdown"; import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata"; +import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; import { getToolResultPresentation } from "./assistant-tool-result"; interface AssistantPanelProps { opened: boolean; onOpen: () => void; onClose: () => void; - onMarkRead: () => void; + onDismissActivity: () => void; + activityDismissed: boolean; isRunning: boolean; unreadCount: number; latestAssistantText: string; latestUserText: string; latestStatus: MessageStatus | undefined; + modelId: string | null; + models: AssistantRuntimeModelOption[]; + modelOptionsLoading: boolean; + reasoning: AssistantReasoningMode; + onModelChange: (modelId: string) => void; + onReasoningChange: (reasoning: AssistantReasoningMode) => void; } const markdownRemarkPlugins = [remarkGfm, remarkBreaks]; @@ -1171,7 +1184,64 @@ const ComposerTriggers = () => { ); }; -const Composer = () => { +type ComposerProps = Pick< + AssistantPanelProps, + "modelId" | "models" | "modelOptionsLoading" | "reasoning" | "onModelChange" | "onReasoningChange" +>; + +const RuntimeControls = ({ + modelId, + models, + modelOptionsLoading, + reasoning, + onModelChange, + onReasoningChange, +}: ComposerProps) => { + const t = useScopedI18n("common.assistant"); + return ( + + { + if (value && assistantReasoningModes.includes(value as AssistantReasoningMode)) { + onReasoningChange(value as AssistantReasoningMode); + } + }} + data={assistantReasoningModes.map((mode) => ({ + value: mode, + label: t(`runtime.reasoning.${mode}`), + }))} + allowDeselect={false} + leftSection={} + aria-label={t("runtime.thinking")} + comboboxProps={{ withinPortal: true, position: "top-end" }} + /> + + ); +}; + +const Composer = (props: ComposerProps) => { const t = useScopedI18n("common.assistant"); const running = useAuiState((state) => state.thread.isRunning); return ( @@ -1220,13 +1290,16 @@ const Composer = () => { )} - - - {t("mentions.hint")} - - - {t("commands.hint")} - + + + + + {t("mentions.hint")} + + + {t("commands.hint")} + + @@ -1242,12 +1315,25 @@ const AssistantActivityBar = ({ latestUserText, latestStatus, onOpen, - onMarkRead, -}: Omit) => { + onDismissActivity, + activityDismissed, +}: Pick< + AssistantPanelProps, + | "isRunning" + | "unreadCount" + | "latestAssistantText" + | "latestUserText" + | "latestStatus" + | "onOpen" + | "onDismissActivity" + | "activityDismissed" +>) => { const t = useScopedI18n("common.assistant"); const needsApproval = latestStatus?.type === "requires-action"; const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled"; - const visible = isRunning || unreadCount > 0 || needsApproval; + const visible = + !activityDismissed && + (isRunning || unreadCount > 0 || needsApproval || latestStatus !== undefined || latestAssistantText.length > 0); if (!visible) return null; @@ -1304,7 +1390,7 @@ const AssistantActivityBar = ({ className={classes.activityDismiss} variant="subtle" color="gray" - onClick={onMarkRead} + onClick={onDismissActivity} aria-label={t("activity.dismiss")} > @@ -1318,12 +1404,19 @@ export const AssistantPanel = ({ opened, onOpen, onClose, - onMarkRead, + onDismissActivity, + activityDismissed, isRunning, unreadCount, latestAssistantText, latestUserText, latestStatus, + modelId, + models, + modelOptionsLoading, + reasoning, + onModelChange, + onReasoningChange, }: AssistantPanelProps) => { const t = useScopedI18n("common.assistant"); const previousFocusRef = useRef(null); @@ -1354,7 +1447,8 @@ export const AssistantPanel = ({ {!opened && ( - - - + + + + + @@ -1428,7 +1524,14 @@ export const AssistantPanel = ({ - + diff --git a/apps/nextjs/src/components/assistant/assistant-preferences.ts b/apps/nextjs/src/components/assistant/assistant-preferences.ts new file mode 100644 index 000000000..35c448552 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-preferences.ts @@ -0,0 +1,7 @@ +export type { AssistantReasoningMode } from "@homarr/definitions"; + +export type AssistantRuntimeModelOption = { + id: string; + name: string; + inputModalities: string[]; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 4f37522f3..a38b7c3b6 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -39,6 +39,8 @@ import { } from "@homarr/spotlight"; import { AssistantPanel } from "./assistant-panel"; +import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; +import { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send"; import { createAssistantPromptInteraction } from "./assistant-spotlight"; import { browserToolContracts } from "./assistant-tool-contracts"; import type { AssistantUIMessage } from "./assistant-message-metadata"; @@ -55,6 +57,18 @@ interface AssistantContextValue { } 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 = [ @@ -143,6 +157,67 @@ export const useHomarrAssistant = () => { export const useOptionalHomarrAssistant = () => useContext(AssistantContext); +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, + }); + const [modelId, setModelIdState] = useState(null); + const [reasoning, setReasoningState] = useState("auto"); + const preferencesRef = useRef<{ modelId: string | null; reasoning: AssistantReasoningMode }>({ + modelId: null, + reasoning: "auto", + }); + const models = useMemo( + () => data?.models.map(({ id, name, inputModalities }) => ({ id, name, inputModalities })) ?? [], + [data?.models], + ); + + const setModelId = useCallback((nextModelId: string) => { + preferencesRef.current.modelId = nextModelId; + setModelIdState(nextModelId); + }, []); + const setReasoning = useCallback((nextReasoning: AssistantReasoningMode) => { + preferencesRef.current.reasoning = nextReasoning; + setReasoningState(nextReasoning); + }, []); + const getRequestBody = useCallback(() => { + const current = preferencesRef.current; + return { + ...(current.modelId ? { modelId: current.modelId } : {}), + reasoning: current.reasoning, + }; + }, []); + + useEffect(() => { + if (!data) return; + const currentModelId = preferencesRef.current.modelId; + if (currentModelId && data.models.some((model) => model.id === currentModelId)) return; + setModelId(data.defaultModelId); + }, [data, setModelId]); + + const value = useMemo( + () => ({ + defaultModelId: data?.defaultModelId ?? null, + modelId, + models, + reasoning, + isLoading, + setModelId, + setReasoning, + getRequestBody, + }), + [data?.defaultModelId, getRequestBody, isLoading, modelId, models, reasoning, setModelId, setReasoning], + ); + + return {children}; +}; + const threadAdapter: RemoteThreadListAdapter = { async list() { const threads = await fetchApi.assistant.listThreads.query(); @@ -173,6 +248,11 @@ const threadAdapter: RemoteThreadListAdapter = { async rename(threadId, title) { await fetchApi.assistant.renameThread.mutate({ threadId, title }); }, + async updateCustom(threadId, custom) { + const modelId = custom?.modelId; + if (typeof modelId !== "string") return; + await fetchApi.assistant.updateThreadModel.mutate({ threadId, modelId }); + }, // assistant-ui requires these callbacks even when the product does not expose archiving. archive: ignoreUnsupportedArchiveAction, unarchive: ignoreUnsupportedArchiveAction, @@ -267,14 +347,25 @@ const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapte const AssistantThreadRuntime = () => { const t = useScopedI18n("common.assistant"); const threadId = useAuiState((state) => state.threadListItem.remoteId); - const transport = useMemo(() => new AssistantChatTransport({ api: "/api/assistant/chat" }), []); + const preferences = useAssistantPreferences(); + const transport = useMemo( + () => + new AssistantChatTransport({ + api: "/api/assistant/chat", + body: preferences.getRequestBody, + }), + [preferences.getRequestBody], + ); const history = useMemo(() => createHistoryAdapter(threadId), [threadId]); - const { data: modelCapabilities } = clientApi.assistant.getModelCapabilities.useQuery(undefined, { - staleTime: 10 * 60_000, - }); + const selectedModel = preferences.models.find((model) => model.id === preferences.modelId); const attachments = useMemo( - () => createAssistantAttachmentAdapter(modelCapabilities?.imageInput !== "unsupported"), - [modelCapabilities?.imageInput], + () => + createAssistantAttachmentAdapter( + selectedModel === undefined || + selectedModel.inputModalities.length === 0 || + selectedModel.inputModalities.includes("image"), + ), + [selectedModel], ); const feedback = useMemo( () => ({ @@ -367,11 +458,35 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => { + {children} ); }; +const AssistantPreferenceSync = () => { + const preferences = useAssistantPreferences(); + const threadId = useAuiState((state) => state.threadListItem.remoteId); + const threadModelId = useAuiState((state) => state.threadListItem.custom?.modelId); + const previousSyncKeyRef = useRef(null); + const modelCatalogKey = preferences.models.map((model) => model.id).join("\0"); + + useEffect(() => { + if (preferences.models.length === 0) return; + const syncKey = `${threadId ?? "new"}:${modelCatalogKey}`; + if (previousSyncKeyRef.current === syncKey) return; + previousSyncKeyRef.current = syncKey; + const storedModelId = typeof threadModelId === "string" ? threadModelId : null; + const nextModelId = + storedModelId && preferences.models.some((model) => model.id === storedModelId) + ? storedModelId + : preferences.defaultModelId; + if (nextModelId) preferences.setModelId(nextModelId); + }, [modelCatalogKey, preferences, threadId, threadModelId]); + + return null; +}; + const AssistantTools = ({ toolkit }: { toolkit: Toolkit }) => { useAui({ tools: Tools({ toolkit }) }); return null; @@ -407,8 +522,11 @@ const getNotificationKey = (message: ThreadMessage | undefined) => { const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { const t = useScopedI18n("common.assistant"); const [opened, setOpened] = useState(false); + const [activityDismissed, setActivityDismissed] = useState(true); const [unreadCount, setUnreadCount] = useState(0); + const [queuedPrompt, setQueuedPrompt] = useState(null); const aui = useAui(); + const preferences = useAssistantPreferences(); const messages = useAuiState((state) => state.thread.messages); const isRunning = useAuiState((state) => state.thread.isRunning); const isLoading = useAuiState((state) => state.thread.isLoading); @@ -417,6 +535,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { const latestAssistantText = getMessageText(latestAssistantMessage); const latestUserText = getMessageText(latestUserMessage); const latestStatus = latestAssistantMessage?.role === "assistant" ? latestAssistantMessage.status : undefined; + const assistantIsRunning = isRunning || queuedPrompt !== null; const notificationKey = getNotificationKey(latestAssistantMessage); const initializedRef = useRef(false); const lastNotifiedKeyRef = useRef(null); @@ -427,30 +546,54 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { }, [notificationKey]); const open = useCallback(() => { markRead(); + setActivityDismissed(false); setOpened(true); }, [markRead]); const close = useCallback(() => setOpened(false), []); const toggle = useCallback(() => { - setOpened((current) => { - if (!current) markRead(); - return !current; - }); - }, [markRead]); + if (opened) close(); + else open(); + }, [close, open, opened]); const sendPrompt = useCallback( (prompt: string) => { const text = prompt.trim(); if (text.length === 0) return; - if (isRunning || latestStatus?.type === "requires-action") { + if (assistantIsRunning || latestStatus?.type === "requires-action") { showWarningNotification({ title: t("busy.title"), message: t("busy.description"), }); return; } - aui.thread().append({ role: "user", content: [{ type: "text", text }] }); + setActivityDismissed(false); + if (isLoading) { + setQueuedPrompt(text); + return; + } + sendPromptThroughComposer(aui.composer(), text); }, - [aui, isRunning, latestStatus?.type, t], + [assistantIsRunning, aui, isLoading, latestStatus?.type, t], ); + const selectModel = useCallback( + (modelId: string) => { + preferences.setModelId(modelId); + const threadListItem = aui.threadListItem(); + if (!threadListItem.getState().remoteId) return; + threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId }); + }, + [aui, preferences], + ); + + useEffect(() => { + if (assistantIsRunning) setActivityDismissed(false); + }, [assistantIsRunning]); + + useEffect(() => { + if (isLoading || isRunning || queuedPrompt === null) return; + const prompt = queuedPrompt; + setQueuedPrompt(null); + sendPromptThroughComposer(aui.composer(), prompt); + }, [aui, isLoading, isRunning, queuedPrompt]); useEffect(() => { if (isLoading) return; @@ -462,6 +605,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { } if (notificationKey === null || notificationKey === lastNotifiedKeyRef.current) return; + setActivityDismissed(false); if (opened) { lastNotifiedKeyRef.current = notificationKey; @@ -489,8 +633,8 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]); const value = useMemo( - () => ({ enabled: true, opened, isRunning, unreadCount, open, close, toggle, sendPrompt }), - [close, isRunning, open, opened, sendPrompt, toggle, unreadCount], + () => ({ enabled: true, opened, isRunning: assistantIsRunning, unreadCount, open, close, toggle, sendPrompt }), + [assistantIsRunning, close, open, opened, sendPrompt, toggle, unreadCount], ); return ( @@ -500,12 +644,22 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { opened={opened} onOpen={open} onClose={close} - onMarkRead={markRead} - isRunning={isRunning} + onDismissActivity={() => { + markRead(); + setActivityDismissed(true); + }} + activityDismissed={activityDismissed} + isRunning={assistantIsRunning} unreadCount={unreadCount} latestAssistantText={latestAssistantText} - latestUserText={latestUserText} + latestUserText={queuedPrompt ?? latestUserText} latestStatus={latestStatus} + modelId={preferences.modelId} + models={preferences.models} + modelOptionsLoading={preferences.isLoading} + reasoning={preferences.reasoning} + onModelChange={selectModel} + onReasoningChange={preferences.setReasoning} /> ); @@ -559,9 +713,11 @@ export const AssistantProvider = ({ children }: PropsWithChildren) => { if (enabled) { return ( - - {children} - + + + {children} + + ); } diff --git a/apps/nextjs/src/components/assistant/assistant-send.spec.ts b/apps/nextjs/src/components/assistant/assistant-send.spec.ts new file mode 100644 index 000000000..eb8894014 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-send.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, test, vi } from "vitest"; + +import { sendAssistantPrompt } from "./assistant-send"; + +describe("sendAssistantPrompt", () => { + test("uses the composer lifecycle so programmatic prompts start a run", () => { + const calls: string[] = []; + const composer = { + setText: vi.fn((text: string) => calls.push(`set:${text}`)), + send: vi.fn(() => calls.push("send")), + }; + + expect(sendAssistantPrompt(composer, " Check my services ")).toBe(true); + expect(calls).toEqual(["set:Check my services", "send"]); + }); + + test("ignores empty prompts", () => { + const composer = { setText: vi.fn(), send: vi.fn() }; + + expect(sendAssistantPrompt(composer, " ")).toBe(false); + expect(composer.setText).not.toHaveBeenCalled(); + expect(composer.send).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-send.ts b/apps/nextjs/src/components/assistant/assistant-send.ts new file mode 100644 index 000000000..a442c06a9 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-send.ts @@ -0,0 +1,13 @@ +type AssistantComposerActions = { + setText: (text: string) => void; + send: () => void; +}; + +export const sendAssistantPrompt = (composer: AssistantComposerActions, prompt: string) => { + const text = prompt.trim(); + if (text.length === 0) return false; + + composer.setText(text); + composer.send(); + return true; +}; diff --git a/packages/api/src/assistant.ts b/packages/api/src/assistant.ts index f707e0f57..6468bf426 100644 --- a/packages/api/src/assistant.ts +++ b/packages/api/src/assistant.ts @@ -1,5 +1,6 @@ export { getAssistantContextEntitiesAsync, + getAssistantModelsAsync, getSelectedModelDetailsAsync, type AssistantContextEntity, } from "./router/assistant"; diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index ccff2d849..ee75b972c 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -34,13 +34,13 @@ const configurationId = "default"; const providerSchema = z.enum(assistantProviderIds); const modelSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullable(), - contextLength: z.number().nullable(), - promptPrice: z.string().nullable(), - completionPrice: z.string().nullable(), - inputModalities: z.array(z.string()), + id: z.string().min(1).max(256), + name: z.string().min(1).max(256), + description: z.string().max(2048).nullable(), + contextLength: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).nullable(), + promptPrice: z.string().max(64).nullable(), + completionPrice: z.string().max(64).nullable(), + inputModalities: z.array(z.string().max(32)).max(16), toolSupport: z.enum(["confirmed", "unknown"]), }); @@ -71,7 +71,7 @@ export type AssistantContextEntity = { boardId?: string; }; -const selectedModelCache = new Map | null }>(); +const modelListCache = new Map[] }>(); const customHeadersSchema = z .record( @@ -197,7 +197,7 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => { } catch { throw new TRPCError({ code: "BAD_GATEWAY", message: "The model endpoint returned invalid JSON." }); } - const models = Array.isArray(body) ? body : (body.data ?? body.models ?? []); + const models = (Array.isArray(body) ? body : (body.data ?? body.models ?? [])).slice(0, 1_000); return models .filter((model) => { if (configuration.provider !== "openrouter") return true; @@ -209,15 +209,21 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => { }) .flatMap((model) => { const id = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : null; - if (!id) return []; + if (!id || id.length > 256) return []; const name = typeof model.name === "string" ? model.name : typeof model.display_name === "string" ? model.display_name : id; const contextLength = - typeof model.context_length === "number" + typeof model.context_length === "number" && + Number.isSafeInteger(model.context_length) && + model.context_length > 0 ? model.context_length - : typeof model.max_context_length === "number" + : typeof model.max_context_length === "number" && + Number.isSafeInteger(model.max_context_length) && + model.max_context_length > 0 ? model.max_context_length - : typeof model.max_input_tokens === "number" + : typeof model.max_input_tokens === "number" && + Number.isSafeInteger(model.max_input_tokens) && + model.max_input_tokens > 0 ? model.max_input_tokens : null; const toolSupport = @@ -231,15 +237,17 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => { : Array.isArray(model.input_modalities) ? model.input_modalities : []; - const inputModalities = rawInputModalities.filter((modality): modality is string => typeof modality === "string"); + const inputModalities = rawInputModalities + .filter((modality): modality is string => typeof modality === "string" && modality.length <= 32) + .slice(0, 16); return [ { id, - name, - description: typeof model.description === "string" ? model.description : null, + name: name.slice(0, 256), + description: typeof model.description === "string" ? model.description.slice(0, 2048) : null, contextLength, - promptPrice: typeof model.pricing?.prompt === "string" ? model.pricing.prompt : null, - completionPrice: typeof model.pricing?.completion === "string" ? model.pricing.completion : null, + promptPrice: typeof model.pricing?.prompt === "string" ? model.pricing.prompt.slice(0, 64) : null, + completionPrice: typeof model.pricing?.completion === "string" ? model.pricing.completion.slice(0, 64) : null, inputModalities, toolSupport, }, @@ -248,27 +256,36 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => { .toSorted((left, right) => left.name.localeCompare(right.name)); }; -export const getSelectedModelDetailsAsync = async (configuration: AssistantConfiguration) => { - if (!configuration.modelId || !configuration.modelDiscoveryPath) return null; - - const cacheKey = [ +const getModelListCacheKey = (configuration: AssistantConfiguration) => + [ configuration.provider, configuration.baseUrl, configuration.modelDiscoveryPath, - configuration.modelId, configuration.updatedAt.getTime(), ].join(":"); - const cached = selectedModelCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) return cached.value; - const models = await fetchModelsAsync(configuration); - const resolvedModelId = resolveAssistantModelId(models, configuration.modelId); - const value = models.find((model) => model.id === resolvedModelId) ?? null; - selectedModelCache.clear(); - selectedModelCache.set(cacheKey, { expiresAt: Date.now() + 10 * 60_000, value }); +export const getAssistantModelsAsync = async (configuration: AssistantConfiguration, refresh = false) => { + if (!configuration.modelDiscoveryPath) return []; + const cacheKey = getModelListCacheKey(configuration); + const cached = modelListCache.get(cacheKey); + if (!refresh && cached && cached.expiresAt > Date.now()) return cached.value; + + const value = await fetchModelsAsync(configuration); + modelListCache.clear(); + modelListCache.set(cacheKey, { expiresAt: Date.now() + 10 * 60_000, value }); return value; }; +export const getSelectedModelDetailsAsync = async ( + configuration: AssistantConfiguration, + requestedModelId = configuration.modelId, +) => { + if (!requestedModelId || !configuration.modelDiscoveryPath) return null; + const models = await getAssistantModelsAsync(configuration); + const resolvedModelId = resolveAssistantModelId(models, requestedModelId); + return models.find((model) => model.id === resolvedModelId) ?? null; +}; + export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): Promise => { if (!ctx.session) return []; @@ -427,12 +444,13 @@ export const assistantRouter = createTRPCRouter({ "Returns the configured assistant model's discovered input modalities and whether image input is supported, unsupported, or unknown.", }, }) - .query(async ({ ctx }) => { + .input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional()) + .query(async ({ ctx, input }) => { const configuration = await getConfigurationAsync(ctx.db); if (!configuration?.enabled || !configuration.modelId) { return { imageInput: "unsupported" as const, inputModalities: [] as string[] }; } - const model = await getSelectedModelDetailsAsync(configuration).catch(() => null); + const model = await getSelectedModelDetailsAsync(configuration, input?.modelId).catch(() => null); if (!model || model.inputModalities.length === 0) { return { imageInput: "unknown" as const, inputModalities: [] as string[] }; } @@ -442,6 +460,30 @@ export const assistantRouter = createTRPCRouter({ }; }), + getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => { + const configuration = await getConfigurationAsync(ctx.db); + if (!configuration?.enabled || !configuration.modelId) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." }); + } + const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []); + const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId; + const defaultModel = discoveredModels.find((model) => model.id === resolvedDefaultId) ?? { + id: resolvedDefaultId, + name: resolvedDefaultId, + description: null, + contextLength: null, + promptPrice: null, + completionPrice: null, + inputModalities: [], + toolSupport: "unknown" as const, + }; + return { + provider: configuration.provider, + defaultModelId: defaultModel.id, + models: [defaultModel, ...discoveredModels.filter((model) => model.id !== defaultModel.id)], + }; + }), + getAdminConfiguration: adminProcedure.query(async ({ ctx }) => { const configuration = await getConfigurationAsync(ctx.db); return { @@ -540,7 +582,7 @@ export const assistantRouter = createTRPCRouter({ if (!configuration) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." }); } - return await fetchModelsAsync(configuration); + return await getAssistantModelsAsync(configuration, true); }), updateConfiguration: adminProcedure @@ -590,6 +632,31 @@ export const assistantRouter = createTRPCRouter({ return { id }; }), + updateThreadModel: protectedProcedure + .input(z.object({ threadId: z.string().max(64), modelId: z.string().trim().min(1).max(256) })) + .mutation(async ({ ctx, input }) => { + const [thread, configuration] = await Promise.all([ + ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id), + getConfigurationAsync(ctx.db), + ]); + if (!configuration?.enabled || !configuration.modelId) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." }); + } + const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null); + if (input.modelId !== configuration.modelId && !selectedModel) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The selected model is not available from the configured provider.", + }); + } + const modelId = selectedModel?.id ?? configuration.modelId; + await ctx.db + .update(assistantThreads) + .set({ modelId, updatedAt: new Date() }) + .where(eq(assistantThreads.id, thread.id)); + return { modelId }; + }), + getThread: protectedProcedure.input(z.object({ threadId: z.string().max(64) })).query(async ({ ctx, input }) => { const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id); const messages = await ctx.db.query.assistantMessages.findMany({ diff --git a/packages/api/src/router/test/assistant.spec.ts b/packages/api/src/router/test/assistant.spec.ts index 409f9c37d..f71b5b26c 100644 --- a/packages/api/src/router/test/assistant.spec.ts +++ b/packages/api/src/router/test/assistant.spec.ts @@ -10,6 +10,7 @@ import { apps, assistantConfigurations, assistantMessages, + assistantThreads, boards, integrations, integrationUserPermissions, @@ -141,6 +142,59 @@ describe("assistantRouter.updateConfiguration", () => { }); describe("assistant conversation features", () => { + test("returns safe runtime model options to signed-in users", async () => { + const db = await createConfiguredAssistantAsync(); + await db + .update(assistantConfigurations) + .set({ + baseUrl: "https://models.example/v1", + encryptedHeaders: null, + updatedAt: new Date("2026-07-30T14:00:00.000Z"), + }) + .where(eq(assistantConfigurations.id, "default")); + const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json({ + data: [ + { + id: "example/model", + name: "Example Model", + supported_parameters: ["tools"], + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + }, + { + id: "example/no-tools", + name: "No tools", + supported_parameters: [], + architecture: { output_modalities: ["text"] }, + }, + ], + }), + ), + ); + + try { + const options = await caller.getRuntimeOptions(); + + expect(options).toEqual({ + provider: "openrouter", + defaultModelId: "example/model", + models: [ + expect.objectContaining({ + id: "example/model", + name: "Example Model", + inputModalities: ["text", "image"], + }), + ], + }); + expect(JSON.stringify(options)).not.toContain("encrypted"); + } finally { + vi.unstubAllGlobals(); + } + }); + test("returns permission-checked entities for composer mentions", async () => { const db = await createConfiguredAssistantAsync(); await db.insert(users).values({ id: adminSession.user.id }); @@ -172,6 +226,53 @@ describe("assistant conversation features", () => { ); }); + test("persists a discovered model choice on the owned conversation", async () => { + const db = await createConfiguredAssistantAsync(); + await db.insert(users).values({ id: adminSession.user.id }); + await db + .update(assistantConfigurations) + .set({ + baseUrl: "https://thread-models.example/v1", + encryptedHeaders: null, + updatedAt: new Date("2026-07-30T15:00:00.000Z"), + }) + .where(eq(assistantConfigurations.id, "default")); + const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession }); + const thread = await caller.createThread(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json({ + data: [ + { + id: "example/model", + name: "Default", + supported_parameters: ["tools"], + architecture: { output_modalities: ["text"] }, + }, + { + id: "example/reasoning", + name: "Reasoning", + supported_parameters: ["tools"], + architecture: { output_modalities: ["text"] }, + }, + ], + }), + ), + ); + + try { + await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" }); + } finally { + vi.unstubAllGlobals(); + } + + const storedThread = await db.query.assistantThreads.findFirst({ + where: eq(assistantThreads.id, thread.id), + }); + expect(storedThread?.modelId).toBe("example/reasoning"); + }); + test("persists feedback on an owned assistant message", async () => { const db = await createConfiguredAssistantAsync(); await db.insert(users).values({ id: adminSession.user.id }); diff --git a/packages/definitions/src/assistant.ts b/packages/definitions/src/assistant.ts index acb3d1d78..85df0c0e3 100644 --- a/packages/definitions/src/assistant.ts +++ b/packages/definitions/src/assistant.ts @@ -16,6 +16,9 @@ export const assistantProviderIds = [ export type AssistantProvider = (typeof assistantProviderIds)[number]; export type AssistantProviderCategory = "hosted" | "local" | "custom"; +export const assistantReasoningModes = ["auto", "none", "minimal", "low", "medium", "high"] as const; +export type AssistantReasoningMode = (typeof assistantReasoningModes)[number]; + export interface AssistantModelOption { id: string; name: string; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index f7a8067c2..2ddb38e7f 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1984,6 +1984,7 @@ "subtitle": "Connected to this Homarr instance", "open": "Open Homarr Assistant", "close": "Close assistant", + "minimize": "Minimize assistant", "spotlight": "Ask Homarr Assistant", "spotlightDescription": "Type and send a prompt without leaving the command menu.", "spotlightPrompt": { @@ -2024,6 +2025,19 @@ } }, "composerPlaceholder": "Ask Homarr Assistant…", + "runtime": { + "model": "Model", + "noModels": "No models available", + "thinking": "Thinking mode", + "reasoning": { + "auto": "Thinking: Auto", + "none": "Thinking: Off", + "minimal": "Thinking: Minimal", + "low": "Thinking: Low", + "medium": "Thinking: Medium", + "high": "Thinking: High" + } + }, "stop": "Stop response", "send": "Send message", "copy": "Copy response",