Files
homarr/apps/nextjs/src/components/assistant/assistant-tool-result.ts
T

178 lines
5.6 KiB
TypeScript

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;
const redactUrlCredentials = (value: string) => {
if (!URL.canParse(value)) return value;
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return value;
if (!url.username && !url.password && !url.search) return value;
url.username = "";
url.password = "";
url.search = "";
return url.toString();
};
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 redactUrlCredentials(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;
};