From 6a98036c503cf11cfff2e60b2ca2ca6b1f8c0f66 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 3 Aug 2026 15:36:10 +0200 Subject: [PATCH] show assistant icon search previews --- apps/docs/docs/management/assistant.mdx | 3 +- .../assistant/assistant-panel.module.css | 30 +++++++- .../components/assistant/assistant-panel.tsx | 50 +++++++++++-- .../assistant/assistant-tool-result.spec.ts | 67 +++++++++++++++++ .../assistant/assistant-tool-result.ts | 71 ++++++++++++++++++- 5 files changed, 214 insertions(+), 7 deletions(-) diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 8cd4af4fa..79b4a0ac8 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -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 diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 4690c2284..8dc7e413e 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -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; } diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 886a8dea4..41d541701 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -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 ( + + {presentation.items.map((item) => ( + + + {item.name} + + + + {item.name} + + + {item.variant} + + + {item.repository && ( + + {item.repository} + + )} + + ))} + {hiddenCount > 0 && ( + + +{hiddenCount} + + )} + + ); + } + const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length); return ( @@ -431,7 +473,7 @@ const ToolPart = ({ {t("approvalDescription")} - + {autoApprovalInProgress ? ( @@ -472,7 +514,7 @@ const ToolPart = ({ )} )} - {successful && result !== undefined && } + {successful && result !== undefined && } diff --git a/apps/nextjs/src/components/assistant/assistant-tool-result.spec.ts b/apps/nextjs/src/components/assistant/assistant-tool-result.spec.ts index ad441384f..f9f377c14 100644 --- a/apps/nextjs/src/components/assistant/assistant-tool-result.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-tool-result.spec.ts @@ -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({ diff --git a/apps/nextjs/src/components/assistant/assistant-tool-result.ts b/apps/nextjs/src/components/assistant/assistant-tool-result.ts index ffce65859..bec8138c7 100644 --- a/apps/nextjs/src/components/assistant/assistant-tool-result.ts +++ b/apps/nextjs/src/components/assistant/assistant-tool-result.ts @@ -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 | undefined => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined; @@ -148,7 +165,56 @@ const findCollection = (record: Record) => { 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): 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