feat: improve assistant background controls

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 0c5fc1d331
commit 41389b61e2
13 files changed
+644 -86

No files matched your search

+7 -2
View File
@@ -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,
@@ -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 }) => {
@@ -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;
}
}
@@ -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 (
<Group className={classes.runtimeControls} gap={6} wrap="nowrap">
<Select
className={classes.modelSelect}
classNames={{ input: classes.runtimeSelectInput }}
size="xs"
value={modelId}
onChange={(value) => {
if (value) onModelChange(value);
}}
data={models.map((model) => ({ value: model.id, label: model.name }))}
searchable
allowDeselect={false}
disabled={modelOptionsLoading || models.length === 0}
leftSection={modelOptionsLoading ? <Loader size={13} /> : <IconSparkles size={14} />}
aria-label={t("runtime.model")}
placeholder={t("runtime.model")}
nothingFoundMessage={t("runtime.noModels")}
comboboxProps={{ withinPortal: true, position: "top-start" }}
/>
<Select
className={classes.reasoningSelect}
classNames={{ input: classes.runtimeSelectInput }}
size="xs"
value={reasoning}
onChange={(value) => {
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={<IconBrain size={14} />}
aria-label={t("runtime.thinking")}
comboboxProps={{ withinPortal: true, position: "top-end" }}
/>
</Group>
);
};
const Composer = (props: ComposerProps) => {
const t = useScopedI18n("common.assistant");
const running = useAuiState((state) => state.thread.isRunning);
return (
@@ -1220,13 +1290,16 @@ const Composer = () => {
</ComposerPrimitive.Send>
)}
</Group>
<Group className={classes.composerHints} gap="xs">
<Text size="xs" c="dimmed">
{t("mentions.hint")}
</Text>
<Text size="xs" c="dimmed">
{t("commands.hint")}
</Text>
<Group className={classes.composerFooter} justify="space-between" gap="xs" wrap="nowrap">
<RuntimeControls {...props} />
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("mentions.hint")}
</Text>
<Text size="xs" c="dimmed">
{t("commands.hint")}
</Text>
</Group>
</Group>
</ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone>
@@ -1242,12 +1315,25 @@ const AssistantActivityBar = ({
latestUserText,
latestStatus,
onOpen,
onMarkRead,
}: Omit<AssistantPanelProps, "opened" | "onClose">) => {
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")}
>
<IconX size={15} />
@@ -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<HTMLElement | null>(null);
@@ -1354,7 +1447,8 @@ export const AssistantPanel = ({
{!opened && (
<AssistantActivityBar
onOpen={onOpen}
onMarkRead={onMarkRead}
onDismissActivity={onDismissActivity}
activityDismissed={activityDismissed}
isRunning={isRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
@@ -1393,15 +1487,17 @@ export const AssistantPanel = ({
</ActionIcon>
</ThreadListPrimitive.New>
</Tooltip>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onClose}
aria-label={t("close")}
>
<IconX size={18} />
</ActionIcon>
<Tooltip label={t("minimize")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onClose}
aria-label={t("minimize")}
>
<IconMinus size={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<ThreadPrimitive.Root className={classes.thread}>
@@ -1428,7 +1524,14 @@ export const AssistantPanel = ({
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<Composer />
<Composer
modelId={modelId}
models={models}
modelOptionsLoading={modelOptionsLoading}
reasoning={reasoning}
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
/>
</ThreadPrimitive.Root>
</dialog>
</FocusTrap>
@@ -0,0 +1,7 @@
export type { AssistantReasoningMode } from "@homarr/definitions";
export type AssistantRuntimeModelOption = {
id: string;
name: string;
inputModalities: string[];
};
@@ -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<AssistantContextValue | null>(null);
interface AssistantPreferencesContextValue {
defaultModelId: string | null;
modelId: string | null;
models: AssistantRuntimeModelOption[];
reasoning: AssistantReasoningMode;
isLoading: boolean;
setModelId: (modelId: string) => void;
setReasoning: (reasoning: AssistantReasoningMode) => void;
getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
}
const AssistantPreferencesContext = createContext<AssistantPreferencesContextValue | null>(null);
const ignoreUnsupportedArchiveAction = () => Promise.resolve();
const assistantImageAttachmentTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"];
const assistantDocumentAttachmentTypes = [
@@ -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<string | null>(null);
const [reasoning, setReasoningState] = useState<AssistantReasoningMode>("auto");
const preferencesRef = useRef<{ modelId: string | null; reasoning: AssistantReasoningMode }>({
modelId: null,
reasoning: "auto",
});
const models = useMemo<AssistantRuntimeModelOption[]>(
() => 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 <AssistantPreferencesContext.Provider value={value}>{children}</AssistantPreferencesContext.Provider>;
};
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) => {
<AssistantRuntimeProvider runtime={runtime}>
<AssistantTools toolkit={toolkit} />
<AssistantRuntimeEvents />
<AssistantPreferenceSync />
{children}
</AssistantRuntimeProvider>
);
};
const AssistantPreferenceSync = () => {
const preferences = useAssistantPreferences();
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const threadModelId = useAuiState((state) => state.threadListItem.custom?.modelId);
const previousSyncKeyRef = useRef<string | null>(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<string | null>(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<string | null>(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}
/>
</AssistantContext.Provider>
);
@@ -559,9 +713,11 @@ export const AssistantProvider = ({ children }: PropsWithChildren) => {
if (enabled) {
return (
<AssistantRuntime>
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
</AssistantRuntime>
<AssistantPreferencesProvider>
<AssistantRuntime>
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
</AssistantRuntime>
</AssistantPreferencesProvider>
);
}
@@ -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();
});
});
@@ -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;
};
+1
View File
@@ -1,5 +1,6 @@
export {
getAssistantContextEntitiesAsync,
getAssistantModelsAsync,
getSelectedModelDetailsAsync,
type AssistantContextEntity,
} from "./router/assistant";
+100 -33
View File
@@ -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<string, { expiresAt: number; value: z.infer<typeof modelSchema> | null }>();
const modelListCache = new Map<string, { expiresAt: number; value: z.infer<typeof modelSchema>[] }>();
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<AssistantContextEntity[]> => {
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({
@@ -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 });
+3
View File
@@ -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;
+14
View File
@@ -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",