feat: render assistant content

This commit is contained in:
ajnart committed 2026-08-13 19:56:21 +02:00
1 parent 26ede97407
commit df874dd2db
9 files changed
+662 -4

No files matched your search

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