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

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