feat: render assistant content

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 1656890b77
commit 6eb75ca559
9 files changed
+662 -4

No files matched your search

+6 -1
View File
@@ -73,6 +73,9 @@ Messages support copy, edit-and-resend, response regeneration and branch navigat
helpful/not-helpful feedback, text-to-speech, and browser speech dictation when the browser supports it.
Selecting text in a message exposes a quote action that carries the selected passage into the next prompt.
Files returned by a compatible model can be downloaded directly from the message.
Assistant responses render GitHub-flavored Markdown, including headings, links, lists, task lists, tables,
quotes, inline code, and fenced code blocks. Line breaks are preserved, and Homarr repairs common streamed
responses where a provider returns escaped line breaks or places a bold-label list on one line.
Images up to 1 MB and text, Markdown, CSV, HTML, XML, or JSON documents up to 350 KB can be attached by
using the paperclip or dropping files onto the composer. A request can contain up to five attachments.
Image understanding depends on the selected model; document text is extracted locally by Homarr and sent
@@ -94,7 +97,9 @@ The assistant can use the same internal tool definitions as the [Homarr MCP serv
Each tool call runs as the signed-in user, so normal Homarr permission checks still apply. Read-only
procedures can run during the agent loop. Mutating procedures require an explicit approval in the
conversation before they execute. Tool cards expose their status, timing, validated input, result, and
error state without flooding the conversation with raw JSON by default.
error state without flooding the conversation with raw JSON by default. Common collection and widget
results are summarized inline as compact responsive cards, while the full structured result remains
available in the tool card details.
Homarr also exposes a small set of browser-only actions to the assistant. These actions can navigate to
safe internal routes and open Homarr interfaces such as the command menu and media request search.
@@ -90,6 +90,7 @@ Action rules:
- Do not retry a denied action.
- Browser tools can navigate within Homarr or open existing Homarr UI. Never navigate to an arbitrary external URL.
- Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON.
- Use well-formed GitHub-flavored Markdown. Put each list item on its own line and leave a blank line before lists.
- If configuration or a service is unavailable, say what the user or administrator can do next.`;
const asRecord = (value: unknown): Record<string, unknown> | null =>
@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
describe("normalizeAssistantMarkdown", () => {
test("formats the assistant capability introduction as readable sections", () => {
const text =
"Hey! 👋 I'm your Homarr Assistant — ready to help you manage your dashboard. Here's what I can do: - **Boards & Apps** — create, edit, organize your dashboard boards and app shortcuts - **Integrations** — work with Sonarr, Radarr, Plex, Jellyfin, Home Assistant, Pi-hole, download clients, and more - **Media** — search and request movies/shows, check streams, browse calendars - **Docker** — view, start, stop, restart containers - **System** — monitor health, DNS stats, downloads, and smart home devices What are you looking to do?";
const normalized = normalizeAssistantMarkdown(text);
expect(normalized).toContain("Here's what I can do:\n\n- **Boards & Apps**");
expect(normalized).toContain("\n- **Integrations**");
expect(normalized).toContain("\n- **System**");
expect(normalized.endsWith("devices\n\nWhat are you looking to do?")).toBe(true);
});
test("turns an inline bold-label sequence into a Markdown list", () => {
const text =
"Here's what I can do: - **Boards & Apps** — create and organize boards - **Integrations** — work with Sonarr and Radarr - **Docker** — manage containers What would you like to do?";
expect(normalizeAssistantMarkdown(text)).toBe(
"Here's what I can do:\n\n- **Boards & Apps** — create and organize boards\n- **Integrations** — work with Sonarr and Radarr\n- **Docker** — manage containers\n\nWhat would you like to do?",
);
});
test("supports real and escaped line breaks", () => {
expect(normalizeAssistantMarkdown("First line\\nSecond line\nThird line")).toBe(
"First line\nSecond line\nThird line",
);
});
test("does not rewrite fenced or inline code", () => {
const text = "Before\\n`value\\n - **one** — 1 - **two** — 2`\n```md\nA\\n - **one** — 1 - **two** — 2\n```\nAfter";
expect(normalizeAssistantMarkdown(text)).toBe(
"Before\n`value\\n - **one** — 1 - **two** — 2`\n```md\nA\\n - **one** — 1 - **two** — 2\n```\nAfter",
);
});
test("leaves ordinary inline hyphens and single suggestions alone", () => {
const text = "Use the well-known provider - or choose **Custom**. Try - **Boards** — list boards.";
expect(normalizeAssistantMarkdown(text)).toBe(text);
});
});
@@ -0,0 +1,41 @@
const protectedMarkdownPattern = /```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`+[^`\n]*`+/gu;
const inlineStrongListMarker = / - (?=\*\*[^*\n]{1,80}\*\*\s+(?:[—–:-]\s+)?)/gu;
const trailingQuestion = /\s+((?:Are|Can|Could|Do|Does|How|Is|What|When|Where|Which|Why|Would)\b[^?\n]*\?)$/u;
const normalizeProse = (text: string) => {
const withLineBreaks = text.replaceAll("\\r\\n", "\n").replaceAll("\\n", "\n");
return withLineBreaks
.split("\n")
.map((line) => {
const markers = [...line.matchAll(inlineStrongListMarker)];
if (markers.length < 2) return line;
let markerIndex = 0;
return line
.replaceAll(inlineStrongListMarker, () => (markerIndex++ === 0 ? "\n\n- " : "\n- "))
.replace(trailingQuestion, "\n\n$1");
})
.join("\n");
};
/**
* Makes common streamed model Markdown readable without changing code samples.
*
* Models occasionally emit escaped line breaks or an entire bold-label list on
* one line. The assistant-ui renderer receives this function through its
* streaming-safe `preprocess` hook, before Markdown is parsed.
*/
export const normalizeAssistantMarkdown = (text: string) => {
let offset = 0;
let normalized = "";
for (const match of text.matchAll(protectedMarkdownPattern)) {
const index = match.index;
normalized += normalizeProse(text.slice(offset, index));
normalized += match[0];
offset = index + match[0].length;
}
return normalized + normalizeProse(text.slice(offset));
};
@@ -152,6 +152,153 @@
.messageText {
line-height: 1.6;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.messageMarkdown {
line-height: 1.65;
overflow-wrap: anywhere;
}
.messageMarkdown > :first-child {
margin-top: 0;
}
.messageMarkdown > :last-child {
margin-bottom: 0;
}
.messageMarkdown p {
margin-block: 0.55rem;
}
.messageMarkdown h1,
.messageMarkdown h2,
.messageMarkdown h3,
.messageMarkdown h4 {
margin: 1rem 0 0.4rem;
line-height: 1.25;
}
.messageMarkdown h1 {
font-size: var(--mantine-font-size-xl);
}
.messageMarkdown h2 {
font-size: var(--mantine-font-size-lg);
}
.messageMarkdown h3,
.messageMarkdown h4 {
font-size: var(--mantine-font-size-md);
}
.messageMarkdown ul,
.messageMarkdown ol {
margin-block: 0.55rem;
padding-inline-start: 1.35rem;
}
.messageMarkdown li {
padding-inline-start: 0.18rem;
}
.messageMarkdown li + li {
margin-top: 0.3rem;
}
.messageMarkdown li > p {
margin-block: 0.2rem;
}
.messageMarkdown a {
color: var(--mantine-primary-color-filled);
text-decoration: underline;
text-underline-offset: 0.16em;
}
.messageMarkdown blockquote {
margin: 0.75rem 0;
padding: 0.45rem 0.75rem;
border-inline-start: 3px solid var(--mantine-color-red-filled);
color: var(--mantine-color-dimmed);
background: color-mix(in srgb, var(--mantine-color-default) 60%, transparent);
}
.messageMarkdown code {
padding: 0.08rem 0.28rem;
border-radius: var(--mantine-radius-xs);
background: var(--mantine-color-default);
font-family: var(--mantine-font-family-monospace);
font-size: 0.88em;
}
.messageMarkdown pre {
max-width: 100%;
margin-block: 0.75rem;
overflow: auto;
padding: 0.75rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-default);
line-height: 1.5;
}
.messageMarkdown pre code {
padding: 0;
background: transparent;
font-size: var(--mantine-font-size-xs);
}
.messageMarkdown hr {
margin-block: 1rem;
border: 0;
border-top: 1px solid var(--mantine-color-default-border);
}
.messageMarkdown img {
display: block;
max-width: 100%;
height: auto;
margin-block: 0.65rem;
border-radius: var(--mantine-radius-md);
}
.messageMarkdown input[type="checkbox"] {
margin-inline: -1.1rem 0.45rem;
accent-color: var(--mantine-primary-color-filled);
}
.markdownTableWrap {
max-width: 100%;
margin-block: 0.75rem;
overflow-x: auto;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
}
.markdownTableWrap table {
width: 100%;
min-width: 24rem;
border-collapse: collapse;
font-size: var(--mantine-font-size-sm);
}
.markdownTableWrap th,
.markdownTableWrap td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--mantine-color-default-border);
text-align: start;
vertical-align: top;
}
.markdownTableWrap th {
background: var(--mantine-color-default);
font-weight: 650;
}
.markdownTableWrap tr:last-child td {
border-bottom: 0;
}
.messageQuote {
@@ -189,6 +336,49 @@
text-align: start;
}
.toolResultText,
.toolResultProperties,
.toolResultCollection {
margin-top: 0.65rem;
}
.toolResultText {
padding: 0.5rem 0.6rem;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
white-space: pre-wrap;
}
.toolResultProperties {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
gap: 0.4rem;
}
.toolResultProperty,
.toolResultItem {
min-width: 0;
padding: 0.5rem 0.6rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.toolResultCollection {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
gap: 0.4rem;
}
.toolResultIdentity {
min-width: 0;
}
.toolResultMore {
align-self: center;
justify-self: center;
}
.disclosureIcon {
flex: 0 0 auto;
transition: transform 150ms ease;
@@ -1,5 +1,6 @@
"use client";
import type { ComponentPropsWithoutRef } from "react";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import type {
FileMessagePartProps,
@@ -27,6 +28,7 @@ import {
unstable_useMentionAdapter,
unstable_useSlashCommandAdapter,
} from "@assistant-ui/react";
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
import {
ActionIcon,
Anchor,
@@ -83,12 +85,16 @@ import {
IconTrash,
IconX,
} from "@tabler/icons-react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { clientApi } from "@homarr/api/client";
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 { getToolResultPresentation } from "./assistant-tool-result";
interface AssistantPanelProps {
opened: boolean;
@@ -102,7 +108,34 @@ interface AssistantPanelProps {
latestStatus: MessageStatus | undefined;
}
const TextPart = () => <MessagePartPrimitive.Text className={classes.messageText} />;
const markdownRemarkPlugins = [remarkGfm, remarkBreaks];
const MarkdownLink = ({ href, children, ...props }: ComponentPropsWithoutRef<"a">) => {
const external = href !== undefined && /^https?:\/\//iu.test(href);
return (
<a href={href} target={external ? "_blank" : undefined} rel={external ? "noreferrer" : undefined} {...props}>
{children}
</a>
);
};
const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => (
<div className={classes.markdownTableWrap}>
<table {...props}>{children}</table>
</div>
);
const AssistantTextPart = () => (
<MarkdownTextPrimitive
className={classes.messageMarkdown}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={{ a: MarkdownLink, table: MarkdownTable }}
defer
/>
);
const UserTextPart = () => <MessagePartPrimitive.Text className={classes.messageText} />;
const Attachment = ({ removable = false }: { removable?: boolean }) => {
const t = useScopedI18n("common.assistant");
@@ -205,6 +238,87 @@ const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
return <Box component="img" src={source} alt={filename ?? t("attachedImage")} className={classes.messageImage} />;
};
const formatToolResultValue = (value: string | number | boolean) =>
typeof value === "number" ? value.toLocaleString() : String(value);
const ToolResultPreview = ({ result }: { result: unknown }) => {
const presentation = getToolResultPresentation(result);
if (!presentation) return null;
if (presentation.type === "text") {
return (
<Text size="sm" className={classes.toolResultText}>
{presentation.text}
</Text>
);
}
if (presentation.type === "properties") {
return (
<Box className={classes.toolResultProperties}>
{presentation.fields.map((field) => (
<Box key={field.label} className={classes.toolResultProperty}>
<Text size="xs" c="dimmed" lineClamp={1}>
{field.label}
</Text>
<Text size="sm" fw={600} lineClamp={2}>
{formatToolResultValue(field.value)}
</Text>
</Box>
))}
</Box>
);
}
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
return (
<Box className={classes.toolResultCollection}>
{presentation.items.map((item, index) => (
<Box key={`${item.title}-${index}`} className={classes.toolResultItem}>
<Group gap="xs" justify="space-between" wrap="nowrap">
<Box className={classes.toolResultIdentity}>
<Text size="sm" fw={650} lineClamp={1}>
{item.title}
</Text>
{item.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
)}
</Box>
{item.badges.length > 0 && (
<Group gap={4} wrap="nowrap">
{item.badges.map((badge) => (
<Badge key={badge} size="xs" variant="light" color="gray">
{badge}
</Badge>
))}
</Group>
)}
</Group>
{item.fields.length > 0 && (
<Group gap="xs" mt={6} wrap="wrap">
{item.fields.map((field) => (
<Text key={field.label} size="xs" c="dimmed">
{field.label}:{" "}
<Text component="span" inherit c="var(--mantine-color-text)" fw={600}>
{formatToolResultValue(field.value)}
</Text>
</Text>
))}
</Group>
)}
</Box>
))}
{hiddenCount > 0 && (
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
+{hiddenCount}
</Badge>
)}
</Box>
);
};
const ToolPart = ({
toolName,
args,
@@ -286,6 +400,7 @@ const ToolPart = ({
</Group>
</Stack>
)}
{successful && result !== undefined && <ToolResultPreview result={result} />}
<Collapse expanded={opened || awaitingApproval}>
<Stack gap="xs" mt="sm">
<Box>
@@ -452,7 +567,7 @@ const UserMessage = () => {
)}
</MessagePrimitive.Quote>
<MessagePrimitive.Attachments components={{ Attachment: SentAttachment }} />
<MessagePrimitive.Parts components={{ Text: TextPart, File: FilePart, Image: ImagePart }} />
<MessagePrimitive.Parts components={{ Text: UserTextPart, File: FilePart, Image: ImagePart }} />
</Box>
<Group justify="flex-end" gap="xs">
<BranchPicker />
@@ -683,7 +798,7 @@ const AssistantMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.Parts
components={{
Text: TextPart,
Text: AssistantTextPart,
Reasoning: ReasoningPart,
Source: SourcePart,
File: FilePart,
@@ -0,0 +1,93 @@
import { describe, expect, test } from "vitest";
import { getToolResultPresentation, humanizeToolResultKey } from "./assistant-tool-result";
describe("getToolResultPresentation", () => {
test("presents common widget collections as compact cards", () => {
expect(
getToolResultPresentation({
containers: [
{ id: "one", name: "Plex", state: "running", image: "plex:latest", cpuUsage: 2.5 },
{ id: "two", name: "Sonarr", state: "stopped", image: "sonarr:latest", memoryUsage: 128 },
],
timestamp: "2026-07-30T10:00:00.000Z",
}),
).toEqual({
type: "collection",
totalCount: 2,
items: [
{
title: "Plex",
description: "plex:latest",
badges: ["running"],
fields: [{ label: "Cpu Usage", value: 2.5 }],
},
{
title: "Sonarr",
description: "sonarr:latest",
badges: ["stopped"],
fields: [{ label: "Memory Usage", value: 128 }],
},
],
});
});
test("uses integration identity and nested widget summary values", () => {
expect(
getToolResultPresentation([
{
integration: { id: "abc", name: "Pi-hole", kind: "pihole" },
summary: { queries: 1000, blocked: 125, token: "never render this" },
},
]),
).toEqual({
type: "collection",
totalCount: 1,
items: [
{
title: "Pi-hole",
badges: ["pihole"],
fields: [
{ label: "Queries", value: 1000 },
{ label: "Blocked", value: 125 },
],
},
],
});
});
test("never includes secret-looking values in an inline presentation", () => {
expect(
getToolResultPresentation({
version: "1.2.3",
apiKey: "secret",
access_token: "secret",
enabled: true,
}),
).toEqual({
type: "properties",
fields: [
{ label: "Version", value: "1.2.3" },
{ label: "Enabled", value: true },
],
});
});
test("limits large collections while preserving their total", () => {
const result = getToolResultPresentation({
items: Array.from({ length: 8 }, (_, index) => ({ name: `Item ${index + 1}` })),
totalCount: 42,
});
expect(result?.type).toBe("collection");
if (result?.type !== "collection") return;
expect(result.items).toHaveLength(6);
expect(result.totalCount).toBe(42);
});
test("humanizes camel, snake, and kebab case labels", () => {
expect(humanizeToolResultKey("memoryUsage")).toBe("Memory Usage");
expect(humanizeToolResultKey("cached_input")).toBe("Cached input");
expect(humanizeToolResultKey("response-time")).toBe("Response time");
});
});
@@ -0,0 +1,166 @@
const collectionKeys = [
"items",
"results",
"data",
"containers",
"apps",
"boards",
"integrations",
"widgets",
"users",
"requests",
"downloads",
"events",
] as const;
const titleKeys = ["name", "title", "label", "displayName", "hostname", "containerName", "id"] as const;
const descriptionKeys = ["description", "url", "href", "image", "version"] as const;
const badgeKeys = ["status", "state", "kind", "category", "type"] as const;
const excludedKeys = new Set([
...titleKeys,
...descriptionKeys,
...badgeKeys,
...collectionKeys,
"icon",
"iconUrl",
"logoImageUrl",
"imageUrl",
]);
const sensitiveKeyPattern = /(?:api.?key|authorization|cookie|credential|password|secret|token)/iu;
const maximumItems = 6;
const maximumFields = 6;
type ToolResultPrimitive = string | number | boolean;
export interface ToolResultField {
label: string;
value: ToolResultPrimitive;
}
export interface ToolResultItem {
title: string;
description?: string;
badges: string[];
fields: ToolResultField[];
}
export type ToolResultPresentation =
| {
type: "collection";
items: ToolResultItem[];
totalCount: number;
}
| {
type: "properties";
fields: ToolResultField[];
}
| {
type: "text";
text: string;
};
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
const toDisplayValue = (value: unknown): ToolResultPrimitive | undefined => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.length > 160) return undefined;
return trimmed;
}
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "boolean") return value;
return undefined;
};
export const humanizeToolResultKey = (key: string) =>
key
.replaceAll(/([a-z0-9])([A-Z])/gu, "$1 $2")
.replaceAll(/[_-]+/gu, " ")
.replace(/^./u, (character) => character.toUpperCase());
const getFirstString = (record: Record<string, unknown>, keys: readonly string[]) => {
for (const key of keys) {
const value = toDisplayValue(record[key]);
if (typeof value === "string") return value;
}
return undefined;
};
const getFields = (record: Record<string, unknown>, excludeIdentity = true) => {
const fields: ToolResultField[] = [];
const addEntries = (source: Record<string, unknown>) => {
for (const [key, value] of Object.entries(source)) {
if (fields.length >= maximumFields) return;
if ((excludeIdentity && excludedKeys.has(key)) || sensitiveKeyPattern.test(key)) continue;
const displayValue = toDisplayValue(value);
if (displayValue === undefined) continue;
fields.push({ label: humanizeToolResultKey(key), value: displayValue });
}
};
addEntries(record);
for (const nestedKey of ["data", "summary", "healthInfo", "metrics"] as const) {
const nested = asRecord(record[nestedKey]);
if (nested) addEntries(nested);
}
return fields;
};
const toItem = (value: unknown, index: number): ToolResultItem | undefined => {
const primitive = toDisplayValue(value);
if (primitive !== undefined) {
return { title: String(primitive), badges: [], fields: [] };
}
const record = asRecord(value);
if (!record) return undefined;
const integration = asRecord(record.integration);
const identity = integration ?? record;
const title = getFirstString(record, titleKeys) ?? getFirstString(identity, titleKeys) ?? `Result ${index + 1}`;
const description = getFirstString(record, descriptionKeys) ?? getFirstString(identity, descriptionKeys);
const badges = badgeKeys
.flatMap((key) => {
const badgeValue = toDisplayValue(record[key] ?? identity[key]);
return badgeValue === undefined ? [] : [String(badgeValue)];
})
.filter((badgeValue, badgeIndex, allBadges) => allBadges.indexOf(badgeValue) === badgeIndex)
.slice(0, 3);
return { title, description, badges, fields: getFields(record) };
};
const findCollection = (record: Record<string, unknown>) => {
for (const key of collectionKeys) {
const value = record[key];
if (Array.isArray(value)) return value;
}
return undefined;
};
export const getToolResultPresentation = (result: unknown): ToolResultPresentation | undefined => {
if (typeof result === "string") {
const text = result.trim();
if (text.length === 0) return undefined;
return { type: "text", text: text.length > 280 ? `${text.slice(0, 277)}…` : text };
}
const record = asRecord(result);
const collection = Array.isArray(result) ? result : record ? findCollection(record) : undefined;
if (collection) {
const items = collection
.slice(0, maximumItems)
.map(toItem)
.filter((item): item is ToolResultItem => item !== undefined);
if (items.length === 0) return undefined;
const reportedTotal = record ? toDisplayValue(record.totalCount) : undefined;
return {
type: "collection",
items,
totalCount: typeof reportedTotal === "number" ? reportedTotal : collection.length,
};
}
if (!record || "error" in record) return undefined;
const fields = getFields(record, false);
return fields.length > 0 ? { type: "properties", fields } : undefined;
};
+3
View File
@@ -217,10 +217,13 @@ catalog:
zod-validation-error: ^5.0.0
"@assistant-ui/react": ^0.14.27
"@assistant-ui/react-ai-sdk": ^1.3.41
"@assistant-ui/react-markdown": ^0.14.7
"@ai-sdk/react": ^4.0.40
ai: ^7.0.37
assistant-stream: ^0.3.26
"@ai-sdk/openai-compatible": 3.0.14
remark-breaks: ^4.0.0
remark-gfm: ^4.0.1
catalogMode: strict
nodeLinker: hoisted