fix: refine assistant controls

This commit is contained in:
ajnart committed 2026-08-14 11:32:11 +02:00
1 parent ef9c37d5ec
commit ebd63bea74
6 files changed
+56 -35

No files matched your search

+5 -6
View File
@@ -46,20 +46,18 @@ Homarr includes presets for OpenRouter, OpenAI, Anthropic Claude, Google Gemini,
DeepSeek, Together AI, Ollama, and LM Studio. Provider icons make the presets easy to identify. A custom
preset connects any other service that implements the OpenAI Chat Completions API.
The provider list also shows **Homarr — Coming soon** as a disabled option. The future service will be a
Homarr-hosted OpenRouter proxy with the same model discovery, tool-calling, and server-tool features. Keeping
it visible reserves a clear place for that service without making an unavailable backend selectable today.
The provider list also shows **Homarr — Coming soon** as a disabled option.
The management page presents setup as three compact sections: provider and endpoint, credentials, then
model and availability. A status summary at the top shows whether the connection is ready, whether a key
is protected, which model is selected, and whether users can open the assistant.
is protected, and which model is selected. Redundant active-state badges are omitted when the setup is ready.
1. Choose a provider preset or **Custom endpoint**, then confirm the API base URL. It usually ends in `/v1`.
2. Add an API key if the service requires bearer authentication. Additional authentication or routing
headers are available in the collapsed advanced section.
3. Save the provider connection.
4. Refresh the model list and select a discovered model. Homarr displays the friendly model name but
stores and sends the provider's exact model ID.
stores and sends the provider's exact model ID. Context-window sizes use compact labels such as `1M`.
5. Enable the assistant and save the configuration.
The model discovery path defaults to `/models` and is appended to the API base URL. Leave it blank when
@@ -158,7 +156,8 @@ another message starts.
A compact context control beside the model selector gives a quick glance at cumulative conversation tokens and
cost. Its popover keeps the latest complete context reading while a new response starts and lists every request
turn with its model, provider, input, output, cached-input and reasoning tokens, context use, duration, speed,
agent steps, routing details, and cost when reported. Closing and reopening the popover reads persisted
agent steps, routing details, and cost when reported. Before usage is available, the control remains a compact
circle instead of showing a redundant missing-data label. Closing and reopening the popover reads persisted
conversation metadata instead of resetting the display to zero.
Each assistant response places a small provider logo in the same stable action toolbar as copy, export,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getAssistantConnectionState } from "./assistant-configuration-state";
import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state";
describe("getAssistantConnectionState", () => {
it("marks a saved required-key provider as ready", () => {
@@ -63,3 +63,10 @@ describe("getAssistantConnectionState", () => {
});
});
});
describe("formatAssistantContextWindow", () => {
it("uses Homarr's compact number format for large context windows", () => {
expect(formatAssistantContextWindow(1_048_576)).toBe("1M");
expect(formatAssistantContextWindow(131_072)).toBe("131k");
});
});
@@ -1,3 +1,5 @@
import { formatNumber } from "@homarr/common";
interface AssistantConnectionStateInput {
connectionConfigured: boolean;
destinationChanged: boolean;
@@ -21,3 +23,5 @@ export const getAssistantConnectionState = ({
connectionReady: !connectionPending,
};
};
export const formatAssistantContextWindow = (tokens: number) => formatNumber(tokens, 0);
@@ -57,7 +57,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-configuration.module.css";
import { getAssistantConnectionState } from "./assistant-configuration-state";
import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state";
type HeaderEntry = { id: number; name: string; value: string };
type CredentialFlow = "idle" | "replace" | "remove";
@@ -438,9 +438,11 @@ export const AssistantConfiguration = () => {
{t("overview.keyProtected")}
</Badge>
)}
<Badge variant="filled" color={configuration?.enabled ? "green" : "gray"}>
{configuration?.enabled ? t("overview.available") : t("overview.disabled")}
</Badge>
{configuration?.enabled === false && (
<Badge variant="filled" color="gray">
{t("overview.disabled")}
</Badge>
)}
</Group>
</Group>
</Paper>
@@ -473,7 +475,7 @@ export const AssistantConfiguration = () => {
allowDeselect={false}
leftSection={<ProviderIcon providerId={provider} />}
renderOption={({ option }) => (
<Group gap="sm" wrap="nowrap" align={option.value === "homarr" ? "flex-start" : "center"} w="100%">
<Group gap="sm" wrap="nowrap" align="center" w="100%">
<ProviderIcon providerId={option.value as AssistantProviderOption} />
<Box flex={1} miw={0}>
<Group gap="xs" wrap="nowrap" justify="space-between">
@@ -486,11 +488,6 @@ export const AssistantConfiguration = () => {
</Badge>
)}
</Group>
{option.value === "homarr" && (
<Text size="xs" c="dimmed" lineClamp={2} maw="46ch">
{t("provider.options.homarr.description")}
</Text>
)}
</Box>
</Group>
)}
@@ -518,13 +515,11 @@ export const AssistantConfiguration = () => {
title={t("credentials.title")}
description={t("credentials.description")}
status={
<Badge variant="light" color={hasStoredApiKey ? "green" : preset.requiresApiKey ? "yellow" : "gray"}>
{hasStoredApiKey
? t("apiKey.configuredBadge")
: preset.requiresApiKey
? t("credentials.required")
: t("credentials.optional")}
</Badge>
hasStoredApiKey ? undefined : (
<Badge variant="light" color={preset.requiresApiKey ? "yellow" : "gray"}>
{preset.requiresApiKey ? t("credentials.required") : t("credentials.optional")}
</Badge>
)
}
actions={
<Group justify="space-between">
@@ -781,14 +776,14 @@ export const AssistantConfiguration = () => {
title={t("model.title")}
description={t("model.description")}
status={
configuration?.modelId ? (
<Badge variant="light" color={configuration.enabled ? "green" : "gray"}>
{configuration.enabled ? t("overview.available") : t("overview.disabled")}
</Badge>
) : (
!configuration?.modelId ? (
<Badge variant="light" color="gray">
{t("overview.noModel")}
</Badge>
) : configuration.enabled ? undefined : (
<Badge variant="light" color="gray">
{t("overview.disabled")}
</Badge>
)
}
actions={
@@ -867,7 +862,9 @@ export const AssistantConfiguration = () => {
</Box>
<Group className={classes.selectedModelBadges} gap="xs" justify="flex-end">
{selectedModel.contextLength && (
<Badge variant="light">{t("model.context", { count: selectedModel.contextLength })}</Badge>
<Badge variant="light">
{t("model.context", { count: formatAssistantContextWindow(selectedModel.contextLength) })}
</Badge>
)}
<Badge
variant="light"
@@ -1157,6 +1157,13 @@
background: var(--mantine-color-default-hover);
}
.composerContext[data-compact] {
width: 1.75rem;
justify-content: center;
padding: 0;
border-radius: 50%;
}
.composerContext:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
@@ -1223,6 +1230,10 @@
background: var(--mantine-color-body);
}
.composer:has(.composerInput:focus-within) {
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
}
.composerDropzone[data-dragging] .composer {
box-shadow: inset 0 0 0 2px var(--mantine-primary-color-filled);
}
@@ -1527,7 +1527,7 @@ const ConversationContext = () => {
? Math.min(100, Math.round(((usage.contextUsed ?? 0) / Math.max(usage.contextLength ?? 1, 1)) * 100))
: 0;
const quickLabel = [
hasTokenUsage ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` : t("usage.notReported"),
hasTokenUsage ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` : undefined,
hasCost ? formatCost(usage.cost) : undefined,
]
.filter(Boolean)
@@ -1548,9 +1548,10 @@ const ConversationContext = () => {
<UnstyledButton
className={classes.composerContext}
type="button"
aria-label={`${t("usage.contextWindow")}: ${quickLabel}`}
aria-label={quickLabel ? `${t("usage.contextWindow")}: ${quickLabel}` : t("usage.contextWindow")}
aria-expanded={opened}
aria-haspopup="dialog"
data-compact={!quickLabel || undefined}
onClick={() => setOpened((value) => !value)}
>
<RingProgress
@@ -1559,9 +1560,11 @@ const ConversationContext = () => {
roundCaps
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
/>
<Text size="xs" fw={650} truncate>
{quickLabel}
</Text>
{quickLabel && (
<Text size="xs" fw={650} truncate>
{quickLabel}
</Text>
)}
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.conversationContextPopover}>