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

@@ -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;
};