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 (
+
+
+ );
+};
+
+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",