show assistant icon search previews
This commit is contained in:
1 parent
59bed6b995
commit
6a98036c50
5 files changed
+214
-7
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user