show assistant icon search previews

This commit is contained in:
ajnart committed 2026-08-03 15:36:10 +02:00
1 parent 59bed6b995
commit 6a98036c50
5 files changed
+214 -7

No files matched your search

+2 -1
View File
@@ -140,7 +140,8 @@ conversation before they execute. The approval appears as a large **Approve and
the assistant does not require a second typed confirmation first. Tool cards expose their status, timing,
validated input, result, and 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.
structured result remains available in the tool card details. Icon searches show the returned image variants,
file formats, and repositories instead of the size of the complete icon catalog.
Custom-widget tools let an administrator list, inspect, create, and update definitions. The custom-widget
form's **Design with AI** action sends a focused request into the current conversation, where the assistant
@@ -643,7 +643,8 @@
.toolResultText,
.toolResultProperties,
.toolResultCollection {
.toolResultCollection,
.toolResultIconCollection {
margin-top: 0.65rem;
}
@@ -675,6 +676,33 @@
gap: 0.4rem;
}
.toolResultIconCollection {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(7rem, 100%), 1fr));
gap: 0.4rem;
}
.toolResultIconItem {
min-width: 0;
padding: 0.5rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.toolResultIconPreview {
display: grid;
height: 3.25rem;
place-items: center;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-default-hover);
}
.toolResultIconImage {
width: 2.5rem;
height: 2.5rem;
}
.toolResultIdentity {
min-width: 0;
}
@@ -41,6 +41,7 @@ import {
Divider,
FocusTrap,
Group,
Image,
Loader,
Menu,
Popover,
@@ -268,8 +269,8 @@ const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
const formatToolResultValue = (value: string | number | boolean) =>
typeof value === "number" ? value.toLocaleString() : String(value);
const ToolResultPreview = ({ result }: { result: unknown }) => {
const presentation = getToolResultPresentation(result);
const ToolResultPreview = ({ result, toolName }: { result: unknown; toolName: string }) => {
const presentation = getToolResultPresentation(result, { toolName });
if (!presentation) return null;
if (presentation.type === "text") {
@@ -297,6 +298,47 @@ const ToolResultPreview = ({ result }: { result: unknown }) => {
);
}
if (presentation.type === "icons") {
if (presentation.items.length === 0) return null;
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
return (
<Box className={classes.toolResultIconCollection}>
{presentation.items.map((item) => (
<Box key={`${item.repository ?? "icon"}-${item.url}`} className={classes.toolResultIconItem}>
<Box className={classes.toolResultIconPreview}>
<Image
src={item.url}
alt={item.name}
className={classes.toolResultIconImage}
fit="contain"
loading="lazy"
/>
</Box>
<Group gap={4} mt="xs" wrap="nowrap" justify="space-between">
<Text size="xs" fw={650} lineClamp={1} title={item.name}>
{item.name}
</Text>
<Badge size="xs" variant="light" color="gray" flex="0 0 auto">
{item.variant}
</Badge>
</Group>
{item.repository && (
<Text size="xs" c="dimmed" lineClamp={1} title={item.repository}>
{item.repository}
</Text>
)}
</Box>
))}
{hiddenCount > 0 && (
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
+{hiddenCount}
</Badge>
)}
</Box>
);
}
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
return (
<Box className={classes.toolResultCollection}>
@@ -431,7 +473,7 @@ const ToolPart = ({
<Text size="sm" fw={600}>
{t("approvalDescription")}
</Text>
<ToolResultPreview result={args} />
<ToolResultPreview result={args} toolName={toolName} />
{autoApprovalInProgress ? (
<Group className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
<Loader type="bars" size="sm" color="green" />
@@ -472,7 +514,7 @@ const ToolPart = ({
)}
</Box>
)}
{successful && result !== undefined && <ToolResultPreview result={result} />}
{successful && result !== undefined && <ToolResultPreview result={result} toolName={toolName} />}
<Collapse expanded={opened}>
<Stack gap="xs" mt="sm">
<Box>
@@ -3,6 +3,73 @@ import { describe, expect, test } from "vitest";
import { getToolResultPresentation, humanizeToolResultKey } from "./assistant-tool-result";
describe("getToolResultPresentation", () => {
test("presents icon search variants as image previews instead of the catalog count", () => {
expect(
getToolResultPresentation(
{
icons: [
{
id: "repository-id",
slug: "homarr-labs/dashboard-icons",
icons: [
{
id: "svg-id",
name: "discord.svg",
url: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/discord.svg",
},
{
id: "png-id",
name: "discord.png",
url: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/discord.png",
},
],
},
{ id: "empty-repository", slug: "another/repository", icons: [] },
],
countIcons: 28_154,
},
{ toolName: "icon_findIcons" },
),
).toEqual({
type: "icons",
totalCount: 2,
items: [
{
name: "discord.svg",
url: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/discord.svg",
repository: "homarr-labs/dashboard-icons",
variant: "SVG",
},
{
name: "discord.png",
url: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/discord.png",
repository: "homarr-labs/dashboard-icons",
variant: "PNG",
},
],
});
});
test("does not render unsafe icon URLs", () => {
expect(
getToolResultPresentation(
{
icons: [
{
slug: "unsafe/repository",
icons: [
{ name: "script.svg", url: "javascript:alert(1)" },
{ name: "credential.svg", url: "https://user:password@example.com/icon.svg" },
],
},
],
countIcons: 2,
},
{ toolName: "icon_findIcons" },
),
).toEqual({ type: "icons", totalCount: 0, items: [] });
});
test("presents common widget collections as compact cards", () => {
expect(
getToolResultPresentation({
@@ -27,6 +27,7 @@ const excludedKeys = new Set([
]);
const sensitiveKeyPattern = /(?:api.?key|authorization|cookie|credential|password|secret|token)/iu;
const maximumItems = 6;
const maximumIconItems = 8;
const maximumFields = 6;
type ToolResultPrimitive = string | number | boolean;
@@ -54,7 +55,19 @@ export interface ToolResultItem {
fields: ToolResultField[];
}
export interface ToolResultIconItem {
name: string;
url: string;
repository?: string;
variant: string;
}
export type ToolResultPresentation =
| {
type: "icons";
items: ToolResultIconItem[];
totalCount: number;
}
| {
type: "collection";
items: ToolResultItem[];
@@ -69,6 +82,10 @@ export type ToolResultPresentation =
text: string;
};
interface ToolResultPresentationOptions {
toolName?: string;
}
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
@@ -148,7 +165,56 @@ const findCollection = (record: Record<string, unknown>) => {
return undefined;
};
export const getToolResultPresentation = (result: unknown): ToolResultPresentation | undefined => {
const getIconVariant = (name: string) => {
const extension = name.slice(name.lastIndexOf(".") + 1).trim();
return extension.length > 0 && extension !== name ? extension.toUpperCase() : "ICON";
};
const getSafeIconUrl = (value: unknown) => {
if (typeof value !== "string" || !URL.canParse(value)) return undefined;
const url = new URL(value);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return undefined;
return value;
};
const getIconPresentation = (record: Record<string, unknown>): ToolResultPresentation | undefined => {
const repositories = record.icons;
if (!Array.isArray(repositories)) return undefined;
const isIconSearchResult =
"countIcons" in record || repositories.some((value) => Array.isArray(asRecord(value)?.icons));
if (!isIconSearchResult) return undefined;
const items = repositories.flatMap((value) => {
const repository = asRecord(value);
if (!repository || !Array.isArray(repository.icons)) return [];
const repositoryName = toDisplayValue(repository.slug);
return repository.icons.flatMap((iconValue) => {
const icon = asRecord(iconValue);
if (!icon) return [];
const name = toDisplayValue(icon.name);
const url = getSafeIconUrl(icon.url);
if (typeof name !== "string" || !url) return [];
return [
{
name,
url,
repository: typeof repositoryName === "string" ? repositoryName : undefined,
variant: getIconVariant(name),
},
];
});
});
return { type: "icons", items: items.slice(0, maximumIconItems), totalCount: items.length };
};
export const getToolResultPresentation = (
result: unknown,
options: ToolResultPresentationOptions = {},
): ToolResultPresentation | undefined => {
if (typeof result === "string") {
const text = result.trim();
if (text.length === 0) return undefined;
@@ -156,6 +222,9 @@ export const getToolResultPresentation = (result: unknown): ToolResultPresentati
}
const record = asRecord(result);
const iconPresentation = record && options.toolName === "icon_findIcons" ? getIconPresentation(record) : undefined;
if (iconPresentation) return iconPresentation;
const collection = Array.isArray(result) ? result : record ? findCollection(record) : undefined;
if (collection) {
const items = collection