feat: polish assistant conversations

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 1ad1439828
commit f991dc6334
14 files changed
+509 -120

No files matched your search

+8 -2
View File
@@ -131,18 +131,24 @@ 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. Selected references appear as compact, typed pills in the
composer and sent message, while preserving assistant-ui's underlying directive syntax for the model. Type `/`
composer and sent message, while preserving assistant-ui's underlying directive syntax for the model. When the
assistant cites one of those references in its own response, Homarr renders the same typed pill and uses the
stored app or integration icon when one is available. Type `/`
for common prompt shortcuts. Both menus support keyboard navigation and keep the active result visible while
moving through long lists. The selector below the composer combines searchable, provider-grouped models with
their description, context size and image-input capability, plus the thinking-level control. 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.
still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider
streams it. The **Thinking** disclosure can be opened or closed without interrupting the response.
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,
time to first output, total duration, output speed, agent-step timing, and cost. OpenRouter-reported cost
is labeled as reported.
While the next response is still streaming, the meter keeps the last completed reading for the same model and
context capacity instead of briefly showing an incorrect zero. It switches to the new request when token usage
arrives; incompatible model capacities are never mixed.
OpenRouter responses request inline usage and routing metadata. When request details are opened, Homarr also
loads the server-side generation record for each model step and briefly polls while OpenRouter finishes
processing it. The details separate Homarr's end-to-end timing from OpenRouter's provider latency,
@@ -11,7 +11,7 @@ describe("assistant chat input", () => {
parts: [
{
type: "text",
text: "Check :integration[integration-1]{name=Media server} on :board[board-1]",
text: "Check :integration[Media server]{name=integration-1} on :board[Home]{name=board-1}",
},
],
},
@@ -3,7 +3,7 @@ export type AssistantMentionReference = {
id: string;
};
const mentionPattern = /:(app|integration|board|widget)\[([^\]\n]{1,1024})\](?:\{name=[^}\n]{1,1024}\})?/gu;
const mentionPattern = /:(app|integration|board|widget)\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/gu;
const getMessageText = (message: { parts: unknown[] }) =>
message.parts
@@ -20,7 +20,9 @@ export const getRequestedMentionIds = (messages: { role: "user" | "assistant"; p
if (message.role !== "user") continue;
for (const match of getMessageText(message).matchAll(mentionPattern)) {
const type = match[1] as AssistantMentionReference["type"];
const id = match[2];
// assistant-ui stores the display label between brackets and the stable entity ID in `name`.
// Keep the bracket value as a fallback for older messages written before named directives.
const id = match[3] ?? match[2];
if (id) mentions.set(`${type}:${id}`, { type, id });
if (mentions.size >= 30) return [...mentions.values()];
}
@@ -646,6 +646,7 @@ export async function POST(request: Request) {
return result.toUIMessageStreamResponse<UIMessage<AssistantMessageMetadata>>({
originalMessages: parsed.data.messages as UIMessage<AssistantMessageMetadata>[],
sendReasoning: true,
messageMetadata: ({ part }) => {
const common = {
requestId,
@@ -682,7 +683,11 @@ export async function POST(request: Request) {
latestStep?.inputTokens !== undefined && latestStep.outputTokens !== undefined
? latestStep.inputTokens + latestStep.outputTokens
: undefined;
const contextUsed = latestContextUsed ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
const contextUsed =
latestContextUsed ??
(usage.inputTokens !== undefined || usage.outputTokens !== undefined
? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
: undefined);
const promptPrice = asFiniteNumber(selectedModel?.promptPrice);
const completionPrice = asFiniteNumber(selectedModel?.completionPrice);
const estimatedCost =
@@ -721,8 +726,8 @@ export async function POST(request: Request) {
...(outputTokensPerSecond !== undefined ? { outputTokensPerSecond } : {}),
...(providerOutputTokensPerSecond !== undefined ? { providerOutputTokensPerSecond } : {}),
...(providerGenerationTimeMs !== undefined ? { generationTimeMs: providerGenerationTimeMs } : {}),
...(contextUsed > 0 ? { contextUsed } : {}),
...(selectedModel?.contextLength && contextUsed > 0
...(contextUsed !== undefined && contextUsed > 0 ? { contextUsed } : {}),
...(selectedModel?.contextLength && contextUsed !== undefined && contextUsed > 0
? { contextUtilization: Math.min(contextUsed / selectedModel.contextLength, 1) }
: {}),
...(cost !== undefined ? { cost, costType: hasReportedCost ? "reported" : "estimated" } : {}),
@@ -2,7 +2,19 @@
import { useEffect, useRef, useState } from "react";
import type { ToolCallMessagePartProps, ToolCallMessagePartStatus } from "@assistant-ui/react";
import { Alert, Badge, Box, Button, Group, Skeleton, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import {
Alert,
Badge,
Box,
Button,
Group,
Skeleton,
Stack,
Text,
TextInput,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import {
IconAlertTriangle,
IconArrowRight,
@@ -170,36 +182,45 @@ export const AssistantAskUserTool = ({
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
return (
<Button
<UnstyledButton
key={`${option.id}:${index}`}
className={classes.humanToolOption}
variant={kind === "alternative" ? "default" : "light"}
color={color}
size="md"
fullWidth
justify="space-between"
leftSection={icon}
rightSection={
<Badge variant="light" color={color} size="xs">
{t(`optionKind.${kind}`)}
</Badge>
}
data-kind={kind}
disabled={submitting}
onClick={() =>
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
}
>
<Box ta="start">
<Text component="span" size="sm" fw={650}>
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
{icon}
</ThemeIcon>
<Box className={classes.humanToolOptionText} ta="start">
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
{option.label}
</Text>
{option.description && (
<Text component="span" display="block" size="xs" c="dimmed" fw={400}>
<Text
component="span"
display="block"
size="xs"
c="dimmed"
fw={400}
className={classes.humanToolOptionDescription}
>
{option.description}
</Text>
)}
</Box>
</Button>
<Badge
className={classes.humanToolOptionBadge}
classNames={{ label: classes.humanToolOptionBadgeLabel }}
variant="light"
color={color}
size="xs"
>
{t(`optionKind.${kind}`)}
</Badge>
</UnstyledButton>
);
})
)}
@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import { resolveAssistantDirectiveEntity, transformAssistantMarkdownDirectives } from "./assistant-markdown-directives";
describe("assistant markdown directives", () => {
test("turns assistant item citations into typed inline elements", () => {
const directive: {
type: string;
name: string;
attributes: { name: string };
children: { type: string; value: string }[];
data?: Record<string, unknown>;
} = {
type: "textDirective",
name: "app",
attributes: { name: "app-1" },
children: [{ type: "text", value: "Discord" }],
};
transformAssistantMarkdownDirectives({ type: "root", children: [directive] });
expect(directive.data).toEqual({
hName: "span",
hProperties: {
"data-assistant-directive": "true",
"data-directive-id": "app-1",
"data-directive-label": "Discord",
"data-directive-type": "app",
},
});
});
test("resolves shorthand citations only when their label is unambiguous", () => {
const entities = [
{ id: "app-1", type: "app" as const, label: "Discord", description: "Chat", iconUrl: "/discord.svg" },
{ id: "board-1", type: "board" as const, label: "Home", description: "Home board" },
];
expect(resolveAssistantDirectiveEntity(entities, { id: "Discord", label: "Discord", type: "app" })?.id).toBe(
"app-1",
);
expect(resolveAssistantDirectiveEntity(entities, { id: "Home", label: "Home", type: "board" })?.id).toBe("board-1");
});
});
@@ -0,0 +1,63 @@
export type AssistantDirectiveType = "app" | "integration" | "board" | "widget";
export type AssistantDirectiveEntity = {
id: string;
type: AssistantDirectiveType;
label: string;
description: string;
iconUrl?: string;
};
type MarkdownNode = {
type?: string;
name?: string;
value?: string;
attributes?: Record<string, string | null | undefined> | null;
children?: MarkdownNode[];
data?: Record<string, unknown>;
};
const directiveTypes = new Set<AssistantDirectiveType>(["app", "integration", "board", "widget"]);
const getNodeText = (node: MarkdownNode): string =>
typeof node.value === "string" ? node.value : (node.children ?? []).map(getNodeText).join("");
export const transformAssistantMarkdownDirectives = (tree: MarkdownNode) => {
const visit = (node: MarkdownNode) => {
if (node.type === "textDirective" && node.name && directiveTypes.has(node.name as AssistantDirectiveType)) {
const label = getNodeText(node).trim();
const directiveId = node.attributes?.name?.trim() || label;
if (label && directiveId) {
node.data = {
...node.data,
hName: "span",
hProperties: {
"data-assistant-directive": "true",
"data-directive-id": directiveId,
"data-directive-label": label,
"data-directive-type": node.name,
},
};
}
}
for (const child of node.children ?? []) visit(child);
};
visit(tree);
};
export const remarkAssistantDirectives = () => transformAssistantMarkdownDirectives;
export const resolveAssistantDirectiveEntity = (
entities: AssistantDirectiveEntity[],
directive: { id: string; label: string; type: string },
) => {
if (!directiveTypes.has(directive.type as AssistantDirectiveType)) return undefined;
const matchingType = entities.filter((entity) => entity.type === directive.type);
const exactId = matchingType.find((entity) => entity.id === directive.id);
if (exactId) return exactId;
const normalizedLabel = directive.label.trim().toLocaleLowerCase();
const labelMatches = matchingType.filter((entity) => entity.label.trim().toLocaleLowerCase() === normalizedLabel);
return labelMatches.length === 1 ? labelMatches[0] : undefined;
};
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
import {
getAssistantTelemetry,
getAssistantUsage,
resolveAssistantContextWindowTelemetry,
} from "./assistant-message-metadata";
describe("getAssistantTelemetry", () => {
test("extracts persisted request telemetry", () => {
@@ -117,3 +121,53 @@ describe("getAssistantTelemetry", () => {
).toEqual({ inputTokens: 120, cachedInputTokens: 24 });
});
});
describe("resolveAssistantContextWindowTelemetry", () => {
const current = {
requestId: "current",
provider: "openrouter",
modelId: "example/model",
startedAt: "2026-08-04T10:00:00.000Z",
contextLength: 128_000,
steps: [],
};
const previousMetadata = {
custom: {
telemetry: {
...current,
requestId: "previous",
completedAt: "2026-08-04T09:59:00.000Z",
contextUsed: 12_500,
},
},
};
test("keeps the previous completed reading while a matching request streams", () => {
expect(resolveAssistantContextWindowTelemetry(current, [previousMetadata])).toMatchObject({
source: "previous",
telemetry: { contextUsed: 12_500 },
});
});
test("prefers a current completed reading", () => {
expect(
resolveAssistantContextWindowTelemetry({ ...current, contextUsed: 13_000 }, [previousMetadata]),
).toMatchObject({ source: "current", telemetry: { contextUsed: 13_000 } });
});
test("does not mix readings from a model with another context capacity", () => {
expect(
resolveAssistantContextWindowTelemetry(current, [
{
custom: {
telemetry: {
...previousMetadata.custom.telemetry,
modelId: "another/model",
contextLength: 1_000_000,
},
},
},
]),
).toBeNull();
});
});
@@ -87,6 +87,11 @@ export type AssistantMessageMetadata = {
export type AssistantUIMessage = UIMessage<AssistantMessageMetadata>;
export type AssistantContextWindowTelemetry = {
telemetry: AssistantRequestTelemetry;
source: "current" | "previous";
};
const getFiniteNonNegativeNumber = (value: unknown) =>
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
@@ -300,3 +305,26 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme
...(typeof value.finishReason === "string" ? { finishReason: value.finishReason } : {}),
};
};
export const resolveAssistantContextWindowTelemetry = (
current: AssistantRequestTelemetry,
previousMetadata: unknown[],
): AssistantContextWindowTelemetry | null => {
if (current.contextLength !== undefined && current.contextUsed !== undefined) {
return { telemetry: current, source: "current" };
}
for (const metadata of previousMetadata.toReversed()) {
const previous = getAssistantTelemetry(metadata);
if (
previous?.completedAt &&
previous.requestId !== current.requestId &&
previous.modelId === current.modelId &&
previous.contextLength === current.contextLength &&
previous.contextUsed !== undefined
) {
return { telemetry: previous, source: "previous" };
}
}
return null;
};
@@ -48,6 +48,7 @@
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-yellow-light-color);
background: var(--mantine-color-yellow-light);
white-space: nowrap;
}
.activityDismiss {
@@ -213,8 +214,8 @@
}
.composerInput {
min-height: 2.625rem;
padding: 0.6rem 0.5rem;
min-height: 2.25rem;
padding: 0.35rem 0.45rem;
}
.runtimeSelectorTrigger {
@@ -390,6 +391,13 @@
text-overflow: ellipsis;
}
.directiveChipImage {
width: 0.9rem;
height: 0.9rem;
flex: 0 0 auto;
object-fit: contain;
}
.messageMarkdown {
line-height: 1.65;
overflow-wrap: anywhere;
@@ -670,16 +678,65 @@
}
.humanToolOption {
display: grid;
width: 100%;
height: auto;
min-height: 3.25rem;
padding-block: 0.55rem;
padding-inline: 0.8rem;
white-space: normal;
grid-template-columns: max-content minmax(0, 1fr) max-content;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.7rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
text-align: start;
}
.humanToolOption :global(.mantine-Button-inner),
.humanToolOption :global(.mantine-Button-label) {
width: 100%;
.humanToolOption:hover:not(:disabled) {
border-color: var(--mantine-color-default-border);
background: var(--mantine-color-default-hover);
}
.humanToolOption:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.humanToolOption:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.humanToolOption[data-kind="affirmative"] {
border-color: color-mix(in srgb, var(--mantine-color-green-filled) 35%, var(--mantine-color-default-border));
}
.humanToolOption[data-kind="negative"] {
border-color: color-mix(in srgb, var(--mantine-color-red-filled) 30%, var(--mantine-color-default-border));
}
.humanToolOptionIcon,
.humanToolOptionBadge {
width: max-content;
flex: 0 0 auto;
overflow: visible;
white-space: nowrap;
}
.humanToolOptionText {
min-width: 0;
}
.humanToolOptionBadgeLabel {
width: max-content;
overflow: visible;
text-overflow: clip;
}
.humanToolOptionLabel,
.humanToolOptionDescription {
overflow-wrap: anywhere;
white-space: normal;
}
.humanToolOtherInput {
@@ -797,20 +854,25 @@
background: var(--mantine-color-default);
}
.reasoning summary {
.reasoningToggle {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
border-radius: var(--mantine-radius-sm);
cursor: pointer;
list-style: none;
}
.reasoning summary::-webkit-details-marker {
display: none;
.reasoningToggle:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.reasoningChevron {
transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.reasoning[open] .reasoningChevron {
.reasoning[data-opened] .reasoningChevron {
transform: rotate(180deg);
}
@@ -1091,27 +1153,35 @@
.composerInput {
position: relative;
min-height: 2.5rem;
min-height: 2.25rem;
max-height: 9rem;
flex: 1;
padding: 0.55rem 0.65rem;
box-sizing: border-box;
padding: 0.35rem 0.45rem;
color: var(--mantine-color-text);
background: transparent;
font: inherit;
line-height: 1.5;
cursor: text;
}
.composerInput :global(.aui-lexical-input) {
min-height: 1.5rem;
line-height: 1.5;
outline: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.composerInput :global(.aui-lexical-input > p) {
margin: 0;
}
.composerInput :global(.aui-lexical-placeholder) {
position: absolute;
inset: 0.55rem 0.65rem auto;
inset: 0.35rem 0.45rem auto;
color: var(--mantine-color-dimmed);
line-height: 1.5;
pointer-events: none;
}
@@ -1128,7 +1198,7 @@
width: fit-content;
min-width: 0;
max-width: min(24rem, 100%);
padding: 0.3rem 0.45rem;
padding: 0.22rem 0.4rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-text);
@@ -1149,10 +1219,6 @@
cursor: not-allowed;
}
.runtimeSelectorIcon {
flex: 0 0 auto;
}
.runtimeSelectorName {
min-width: 0;
flex: 1;
@@ -1165,7 +1231,6 @@
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 0.2rem;
text-transform: none;
}
@@ -1180,7 +1245,16 @@
}
.modelOption {
padding-block: 0.45rem;
margin-inline: 0.25rem;
padding: 0.32rem 0.45rem;
border-radius: var(--mantine-radius-sm);
}
.modelOption[data-combobox-selected],
.modelOption[data-combobox-active] {
color: var(--mantine-color-text);
background: var(--mantine-color-default-hover);
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled);
}
.modelOptionText {
@@ -1193,13 +1267,17 @@
color: var(--mantine-color-dimmed);
}
.modelOptionDescription {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-3));
}
.runtimeOptionCheck {
flex: 0 0 auto;
color: var(--mantine-primary-color-filled);
}
.reasoningSelector {
padding: 0.55rem;
padding: 0.4rem 0.45rem 0.45rem;
}
.reasoningSegmentedControl :global(.mantine-SegmentedControl-label) {
@@ -1342,8 +1420,8 @@
}
.composerInput {
min-height: 2.625rem;
padding: 0.6rem 0.55rem;
min-height: 2.25rem;
padding: 0.35rem 0.45rem;
}
.composerFooter {
@@ -1369,10 +1447,6 @@
}
@media (max-width: 30em) {
.activityReviewAction > span {
display: none;
}
.historyButton {
width: 2.625rem;
max-width: 2.625rem;
@@ -67,7 +67,6 @@ import {
IconArrowUp,
IconArrowsMaximize,
IconAt,
IconBrain,
IconCheck,
IconChevronDown,
IconChevronLeft,
@@ -92,7 +91,6 @@ import {
IconRobot,
IconSearch,
IconShieldCheck,
IconSparkles,
IconThumbDown,
IconThumbUp,
IconTool,
@@ -100,6 +98,7 @@ import {
IconX,
} from "@tabler/icons-react";
import remarkBreaks from "remark-breaks";
import remarkDirective from "remark-directive";
import remarkGfm from "remark-gfm";
import { clientApi } from "@homarr/api/client";
@@ -110,9 +109,15 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
import { getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
import {
getAssistantTelemetry,
getAssistantUsage,
resolveAssistantContextWindowTelemetry,
} from "./assistant-message-metadata";
import type { AssistantPendingAction } from "./assistant-pending-action";
import { AssistantQuestionPortalProvider } from "./assistant-question-portal";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
@@ -144,7 +149,7 @@ interface AssistantPanelProps extends AssistantConversationControls {
pendingAction: AssistantPendingAction | undefined;
}
const markdownRemarkPlugins = [remarkGfm, remarkBreaks];
const markdownRemarkPlugins = [remarkDirective, remarkAssistantDirectives, remarkGfm, remarkBreaks];
const contextIcons = {
app: IconApps,
@@ -155,9 +160,15 @@ const contextIcons = {
tools: IconTool,
};
const ContextDirectiveChip = ({ directiveId, directiveType, label }: DirectiveChipProps) => {
const ContextDirectiveChip = ({
directiveId,
directiveType,
label,
iconUrl,
}: DirectiveChipProps & { iconUrl?: string }) => {
const t = useScopedI18n("common.assistant");
const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt;
const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl);
const translationKey = getAssistantDirectiveTranslationKey(directiveType);
const typeLabel = t(`mentions.${translationKey}`);
@@ -168,7 +179,11 @@ const ContextDirectiveChip = ({ directiveId, directiveType, label }: DirectiveCh
data-directive-id={directiveId}
title={`${typeLabel}: ${label}`}
>
<Icon size={12} aria-hidden />
{safeIconUrl ? (
<img className={classes.directiveChipImage} src={safeIconUrl} alt="" aria-hidden referrerPolicy="no-referrer" />
) : (
<Icon size={12} aria-hidden />
)}
<span className={classes.directiveChipLabel}>{label}</span>
</span>
);
@@ -273,34 +288,78 @@ const MermaidDiagram = ({ code, components }: SyntaxHighlighterProps) => {
);
};
const AssistantTextPart = () => (
<MarkdownTextPrimitive
className={classes.messageMarkdown}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={{ a: MarkdownLink, img: MarkdownImage, table: MarkdownTable }}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
defer
/>
);
type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & {
"data-assistant-directive"?: string;
"data-directive-id"?: string;
"data-directive-label"?: string;
"data-directive-type"?: string;
};
const AssistantDirectiveEntitiesContext = createContext<AssistantDirectiveEntity[]>([]);
const AssistantMarkdownSpan = ({
children,
"data-assistant-directive": assistantDirective,
"data-directive-id": directiveId,
"data-directive-label": directiveLabel,
"data-directive-type": directiveType,
...props
}: MarkdownSpanProps) => {
const entities = useContext(AssistantDirectiveEntitiesContext);
if (assistantDirective === "true" && directiveId && directiveLabel && directiveType) {
const entity = resolveAssistantDirectiveEntity(entities, {
id: directiveId,
label: directiveLabel,
type: directiveType,
});
return (
<ContextDirectiveChip
directiveId={entity?.id ?? directiveId}
directiveType={directiveType}
label={entity?.label ?? directiveLabel}
iconUrl={entity?.iconUrl}
/>
);
}
return <span {...props}>{children}</span>;
};
const AssistantTextPart = () => {
const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 });
return (
<AssistantDirectiveEntitiesContext.Provider value={entities}>
<MarkdownTextPrimitive
className={classes.messageMarkdown}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={{ a: MarkdownLink, img: MarkdownImage, span: AssistantMarkdownSpan, table: MarkdownTable }}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
defer
/>
</AssistantDirectiveEntitiesContext.Provider>
);
};
const UserTextPart = () => {
const { text } = useMessagePartText();
const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 });
const segments = parseAssistantDirectives(text);
return (
<span className={classes.messageText}>
{segments.map((segment, index) =>
segment.kind === "text" ? (
<span key={`${index}:${segment.text}`}>{segment.text}</span>
) : (
{segments.map((segment, index) => {
if (segment.kind === "text") return <span key={`${index}:${segment.text}`}>{segment.text}</span>;
const entity = resolveAssistantDirectiveEntity(entities, segment);
return (
<ContextDirectiveChip
key={`${index}:${segment.type}:${segment.id}`}
directiveId={segment.id}
directiveId={entity?.id ?? segment.id}
directiveType={segment.type}
label={segment.label}
label={entity?.label ?? segment.label}
iconUrl={entity?.iconUrl}
/>
),
)}
);
})}
</span>
);
};
@@ -333,23 +392,40 @@ const ReasoningPart = ({ text }: ReasoningMessagePartProps) => (
const ReasoningGroup = ({ children, running }: { children: ReactNode; running: boolean }) => {
const t = useScopedI18n("common.assistant");
const contentId = useId();
const [opened, setOpened] = useState(running);
const [manuallyToggled, setManuallyToggled] = useState(false);
useEffect(() => {
if (running && !manuallyToggled) setOpened(true);
}, [manuallyToggled, running]);
return (
<details className={classes.reasoning} open={running}>
<summary>
<Group gap="xs" wrap="nowrap" justify="space-between">
<Group gap="xs" wrap="nowrap">
{running ? <Loader type="bars" size="xs" color="gray" /> : <IconBrain size={14} />}
<Text size="xs" fw={600} c="dimmed">
{t("reasoning")}
</Text>
</Group>
<IconChevronDown size={14} className={classes.reasoningChevron} />
<Box className={classes.reasoning} data-opened={opened || undefined}>
<UnstyledButton
className={classes.reasoningToggle}
type="button"
aria-expanded={opened}
aria-controls={contentId}
onClick={() => {
setManuallyToggled(true);
setOpened((value) => !value);
}}
>
<Group gap="xs" wrap="nowrap">
{running && <Loader type="bars" size="xs" color="gray" />}
<Text size="xs" fw={650} c="dimmed">
{t("reasoning")}
</Text>
</Group>
</summary>
<Box className={classes.reasoningContent} aria-busy={running}>
{children}
</Box>
</details>
<IconChevronDown size={14} className={classes.reasoningChevron} />
</UnstyledButton>
<Collapse expanded={opened}>
<Box id={contentId} className={classes.reasoningContent} aria-busy={running}>
{children}
</Box>
</Collapse>
</Box>
);
};
@@ -847,6 +923,7 @@ const RequestTelemetry = () => {
const metadata = useAuiState((state) => state.message.metadata);
const messageId = useAuiState((state) => state.message.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const threadMessageMetadata = useAuiState((state) => state.thread.messages.map((message) => message.metadata));
const persistedTelemetry = getAssistantTelemetry(metadata);
const persistedUsage = getAssistantUsage(metadata);
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
@@ -941,13 +1018,18 @@ const RequestTelemetry = () => {
: {}),
};
const contextLength = telemetry.contextLength ?? 0;
const contextUsed = telemetry.contextUsed ?? 0;
const hasContextWindow = telemetry.contextLength !== undefined && telemetry.contextUsed !== undefined;
const contextWindow = resolveAssistantContextWindowTelemetry(telemetry, threadMessageMetadata);
const contextTelemetry = contextWindow?.telemetry;
const contextLength = contextTelemetry?.contextLength ?? 0;
const contextUsed = contextTelemetry?.contextUsed ?? 0;
const hasContextWindow = contextTelemetry?.contextLength !== undefined && contextTelemetry.contextUsed !== undefined;
const contextPercentage = hasContextWindow
? Math.min(
100,
Math.max(0, Math.round((telemetry.contextUtilization ?? contextUsed / Math.max(contextLength, 1)) * 100)),
Math.max(
0,
Math.round((contextTelemetry.contextUtilization ?? contextUsed / Math.max(contextLength, 1)) * 100),
),
)
: 0;
const contextRemaining = hasContextWindow ? Math.max(contextLength - contextUsed, 0) : undefined;
@@ -1054,7 +1136,7 @@ const RequestTelemetry = () => {
{t("usage.used")}
</Text>
<Text size="sm" fw={600}>
{telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")}
{contextTelemetry?.contextUsed?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
@@ -1070,7 +1152,7 @@ const RequestTelemetry = () => {
{t("usage.capacity")}
</Text>
<Text size="sm" fw={600}>
{telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")}
{contextTelemetry?.contextLength?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
</Box>
@@ -1929,7 +2011,11 @@ type ComposerProps = AssistantConversationControls & { pendingAction: AssistantP
const getModelProviderLabel = (modelId: string, fallback: string) => {
const separator = modelId.indexOf("/");
if (separator <= 0) return fallback;
const provider = modelId.slice(0, separator).replaceAll(/[-_]/gu, " ");
const provider = modelId
.slice(0, separator)
.replace(/^[^\p{L}\p{N}]+/u, "")
.replaceAll(/[-_]/gu, " ");
if (!provider) return fallback;
return `${provider.charAt(0).toLocaleUpperCase()}${provider.slice(1)}`;
};
@@ -1981,7 +2067,7 @@ const RuntimeControls = ({
};
return (
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={360}>
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={340}>
<Combobox.Target>
<UnstyledButton
className={classes.runtimeSelectorTrigger}
@@ -1992,14 +2078,11 @@ const RuntimeControls = ({
aria-expanded={modelCombobox.dropdownOpened}
>
<Group gap="xs" wrap="nowrap">
<ThemeIcon className={classes.runtimeSelectorIcon} size="sm" variant="light" color="gray">
{modelOptionsLoading ? <Loader size={12} /> : <IconSparkles size={13} />}
</ThemeIcon>
{modelOptionsLoading && <Loader size={12} />}
<Text className={classes.runtimeSelectorName} size="xs" fw={650} lineClamp={1}>
{selectedModel?.name ?? t("runtime.model")}
</Text>
<Badge className={classes.runtimeSelectorEffort} size="xs" variant="light" color="gray">
<IconBrain size={11} />
{t(`runtime.reasoning.${reasoning}`)}
</Badge>
<Combobox.Chevron size="xs" />
@@ -2015,6 +2098,7 @@ const RuntimeControls = ({
}}
placeholder={t("runtime.searchModels")}
aria-label={t("runtime.searchModels")}
size="xs"
/>
<Combobox.Options className={classes.modelOptions}>
{[...groupedModels.entries()].map(([provider, providerModels]) => (
@@ -2027,14 +2111,11 @@ const RuntimeControls = ({
active={model.id === modelId}
>
<Group gap="xs" wrap="nowrap">
<ThemeIcon size="md" radius="md" variant="light" color="gray">
<IconSparkles size={14} />
</ThemeIcon>
<Stack gap={1} className={classes.modelOptionText}>
<Text size="sm" fw={model.id === modelId ? 650 : 500} lineClamp={1}>
{model.name}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
<Text size="xs" className={classes.modelOptionDescription} lineClamp={1}>
{model.description?.trim() || model.id}
</Text>
</Stack>
@@ -2060,12 +2141,9 @@ const RuntimeControls = ({
</Combobox.Options>
<Divider />
<Box className={classes.reasoningSelector}>
<Group gap="xs" mb={6} wrap="nowrap">
<IconBrain size={14} />
<Text size="xs" fw={650}>
{t("runtime.thinking")}
</Text>
</Group>
<Text size="xs" fw={650} mb={5}>
{t("runtime.thinking")}
</Text>
<SegmentedControl
className={classes.reasoningSegmentedControl}
value={reasoning}
+5 -1
View File
@@ -23,6 +23,7 @@ import {
assistantProviderIds,
assistantProviderPresets,
assistantProviderRequiresApiKey,
getIconUrl,
resolveAssistantModelId,
} from "@homarr/definitions";
@@ -71,6 +72,7 @@ export type AssistantContextEntity = {
type: "app" | "integration" | "board" | "widget";
label: string;
description: string;
iconUrl?: string;
boardId?: string;
};
@@ -340,7 +342,7 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
limit: 250,
}),
ctx.db.query.apps.findMany({
columns: { id: true, name: true, description: true },
columns: { id: true, name: true, description: true, iconUrl: true },
orderBy: asc(apps.name),
limit: 250,
}),
@@ -366,6 +368,7 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
type: "app",
label: app.name,
description: app.description ?? "Homarr app",
iconUrl: app.iconUrl,
}),
),
...availableIntegrations
@@ -376,6 +379,7 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
type: "integration",
label: integration.name,
description: `${integration.kind} integration`,
iconUrl: getIconUrl(integration.kind),
}),
),
...availableBoards.map(
+12 -2
View File
@@ -271,10 +271,20 @@ describe("assistant conversation features", () => {
expect(entities).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "app-1", type: "app", label: "Plex" }),
expect.objectContaining({
id: "app-1",
type: "app",
label: "Plex",
iconUrl: "https://example.com/plex.svg",
}),
expect.objectContaining({ id: "board-1", type: "board", label: "Home" }),
expect.objectContaining({ id: "widget-1", type: "widget", boardId: "board-1" }),
expect.objectContaining({ id: "integration-1", type: "integration", label: "Media server" }),
expect.objectContaining({
id: "integration-1",
type: "integration",
label: "Media server",
iconUrl: expect.stringContaining("plex"),
}),
]),
);
});
+1
View File
@@ -224,6 +224,7 @@ catalog:
assistant-stream: ^0.3.26
"@ai-sdk/openai-compatible": 3.0.14
remark-breaks: ^4.0.0
remark-directive: ^3.0.1
remark-gfm: ^4.0.1
catalogMode: strict