fix: refine assistant controls
This commit is contained in:
1 parent
ef9c37d5ec
commit
ebd63bea74
6 files changed
+56
-35
No files matched your search
@@ -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,
|
||||
|
||||
+8
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
+4
@@ -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);
|
||||
+20
-23
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user