feat: render assistant content
This commit is contained in:
1 parent
1656890b77
commit
6eb75ca559
9 files changed
+662
-4
No files matched your search
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user