feat: custom tool renderers
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
export type StoredToolValue = Readonly<{ type: string; value: string }> | null | undefined;
|
||||
|
||||
export const parseToolValue = (stored: StoredToolValue): unknown => {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
if (stored.type !== "json") {
|
||||
return stored.value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(stored.value);
|
||||
} catch {
|
||||
// Tool arguments may still be streaming, or come from older records.
|
||||
return stored.value;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatToolValue = (stored: StoredToolValue): string => {
|
||||
const value = parseToolValue(stored);
|
||||
return typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "";
|
||||
};
|
||||
|
||||
export interface SearchSource {
|
||||
title: string;
|
||||
url: string;
|
||||
hostname: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export const readSearchSources = (value: unknown): SearchSource[] | null => {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const sources: SearchSource[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of value) {
|
||||
if (typeof item !== "object" || item === null || !("link" in item) || typeof item.link !== "string") {
|
||||
continue;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(item.link);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
continue;
|
||||
}
|
||||
url.hash = "";
|
||||
if (seen.has(url.href)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(url.href);
|
||||
sources.push({
|
||||
title: "title" in item && typeof item.title === "string" ? item.title : url.hostname,
|
||||
url: url.href,
|
||||
hostname: url.hostname,
|
||||
snippet: "snippet" in item && typeof item.snippet === "string" ? item.snippet : "",
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
};
|
||||
export interface ToolDisplayInfo {
|
||||
icon: string;
|
||||
verb: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export const formatDuration = (start?: Date | string | number | null, end?: Date | string | number | null): string | null => {
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
const startTime = typeof start === "number" ? start : new Date(start).getTime();
|
||||
const endTime = typeof end === "number" ? end : new Date(end).getTime();
|
||||
if (Number.isNaN(startTime) || Number.isNaN(endTime) || endTime <= startTime) {
|
||||
return null;
|
||||
}
|
||||
const seconds = (endTime - startTime) / 1000;
|
||||
if (seconds < 0.05) {
|
||||
return null;
|
||||
}
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const extractField = (obj: unknown, fields: string[]): string => {
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
return typeof obj === "string" ? obj : "";
|
||||
}
|
||||
for (const field of fields) {
|
||||
if (field in obj) {
|
||||
const val = (obj as Record<string, unknown>)[field];
|
||||
if (typeof val === "string" && val.length > 0) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const DOMAIN_COLORS = [
|
||||
"#eab308", // amber / yellow
|
||||
"#16a34a", // emerald / green
|
||||
"#0284c7", // sky / blue
|
||||
"#ea580c", // orange
|
||||
"#dc2626", // red
|
||||
"#7c3aed", // violet
|
||||
"#0d9488", // teal
|
||||
"#db2777", // pink
|
||||
"#4f46e5", // indigo
|
||||
];
|
||||
|
||||
export const getDomainColor = (domain: string): string => {
|
||||
const clean = domain.replace(/^www\./, "").toLowerCase();
|
||||
let hash = 0;
|
||||
for (let i = 0; i < clean.length; i++) {
|
||||
hash = (hash << 5) - hash + clean.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
const index = Math.abs(hash) % DOMAIN_COLORS.length;
|
||||
return DOMAIN_COLORS[index] ?? "#eab308";
|
||||
};
|
||||
|
||||
export const domainInitial = (domain: string): string => {
|
||||
const clean = domain.replace(/^www\./, "").trim();
|
||||
return clean ? clean.charAt(0).toUpperCase() : "?";
|
||||
};
|
||||
|
||||
export const getFaviconUrl = (hostname: string): string => {
|
||||
const clean = hostname.replace(/^www\./, "").trim();
|
||||
return `https://icons.duckduckgo.com/ip3/${clean}.ico`;
|
||||
};
|
||||
|
||||
export const getToolDisplayInfo = (toolCall: {
|
||||
toolName: string;
|
||||
status: string;
|
||||
input?: StoredToolValue;
|
||||
output?: StoredToolValue;
|
||||
error?: StoredToolValue;
|
||||
}): ToolDisplayInfo => {
|
||||
const rawInput = parseToolValue(toolCall.input);
|
||||
const rawOutput = parseToolValue(toolCall.output);
|
||||
const rawError = parseToolValue(toolCall.error);
|
||||
|
||||
const isPending = toolCall.status === "pending";
|
||||
const isFailed = toolCall.status === "failed";
|
||||
const isCancelled = toolCall.status === "cancelled";
|
||||
|
||||
const name = toolCall.toolName.toLowerCase();
|
||||
|
||||
const errorSummary = typeof rawError === "string"
|
||||
? (rawError.trim().split("\n")[0] ?? "")
|
||||
: extractField(rawError, ["message", "error"]);
|
||||
|
||||
if (name === "search" || name.includes("search")) {
|
||||
const query = extractField(rawInput, ["query", "q"]);
|
||||
const target = query ? `"${query}"` : "";
|
||||
const icon = "i-mynaui-search";
|
||||
if (isPending) {
|
||||
return { icon, verb: "Searching the web", target };
|
||||
}
|
||||
if (isFailed) {
|
||||
return { icon: "i-mynaui-danger-triangle", verb: "Search failed", target: target || errorSummary };
|
||||
}
|
||||
if (isCancelled) {
|
||||
return { icon, verb: "Search cancelled", target };
|
||||
}
|
||||
const sources = readSearchSources(rawOutput);
|
||||
const count = sources?.length ?? 0;
|
||||
const verb = count > 0 ? `Searched ${count} ${count === 1 ? "site" : "sites"}` : "Searched the web";
|
||||
return { icon, verb, target };
|
||||
}
|
||||
|
||||
if (name === "fetchurl" || name === "fetch_url" || name.includes("fetch") || name.includes("scrape")) {
|
||||
const url = extractField(rawInput, ["url", "link", "uri", "href"]);
|
||||
const icon = "i-mynaui-link";
|
||||
if (isPending) {
|
||||
return { icon, verb: "Fetching URL", target: url };
|
||||
}
|
||||
if (isFailed) {
|
||||
return { icon: "i-mynaui-danger-triangle", verb: "Fetch failed", target: url || errorSummary };
|
||||
}
|
||||
if (isCancelled) {
|
||||
return { icon, verb: "Fetch cancelled", target: url };
|
||||
}
|
||||
return { icon, verb: "Fetched URL", target: url };
|
||||
}
|
||||
|
||||
if (name === "python" || name.includes("python") || name.includes("pyeval")) {
|
||||
const code = extractField(rawInput, ["code", "script", "command"]);
|
||||
const firstLine = code.trim().split("\n")[0] ?? "";
|
||||
const icon = "i-mynaui-terminal";
|
||||
if (isPending) {
|
||||
return { icon, verb: "Running Python", target: firstLine };
|
||||
}
|
||||
if (isFailed) {
|
||||
return { icon: "i-mynaui-danger-triangle", verb: "Python failed", target: firstLine || errorSummary };
|
||||
}
|
||||
if (isCancelled) {
|
||||
return { icon, verb: "Python cancelled", target: firstLine };
|
||||
}
|
||||
return { icon, verb: "Ran Python", target: firstLine };
|
||||
}
|
||||
|
||||
if (name.includes("read") || name.includes("cat") || name.includes("view")) {
|
||||
const path = extractField(rawInput, ["path", "file", "filePath", "filename"]);
|
||||
const icon = "i-mynaui-file";
|
||||
if (isPending) {
|
||||
return { icon, verb: "Reading", target: path };
|
||||
}
|
||||
if (isFailed) {
|
||||
return { icon: "i-mynaui-danger-triangle", verb: "Read failed", target: path || errorSummary };
|
||||
}
|
||||
if (isCancelled) {
|
||||
return { icon, verb: "Read cancelled", target: path };
|
||||
}
|
||||
return { icon, verb: "Read", target: path };
|
||||
}
|
||||
|
||||
if (name.includes("edit") || name.includes("write") || name.includes("modify") || name.includes("patch") || name.includes("save")) {
|
||||
const path = extractField(rawInput, ["path", "file", "filePath", "filename"]);
|
||||
const icon = "i-mynaui-edit";
|
||||
if (isPending) {
|
||||
return { icon, verb: "Editing", target: path };
|
||||
}
|
||||
if (isFailed) {
|
||||
return { icon: "i-mynaui-danger-triangle", verb: "Edit failed", target: path || errorSummary };
|
||||
}
|
||||
if (isCancelled) {
|
||||
return { icon, verb: "Edit cancelled", target: path };
|
||||
}
|
||||
return { icon, verb: "Edited", target: path };
|
||||
}
|
||||
|
||||
if (name.includes("query") || name.includes("sql") || name.includes("database") || name.includes("db")) {
|
||||
const query = extractField(rawInput, ["query", "sql", "statement", "command"]);
|
||||
const icon = "i-mynaui-database";
|
||||
if (isPending) {
|
||||
return { icon, verb: "Querying", target: query };
|
||||
}
|
||||
if (isFailed) {
|
||||
const target = query || errorSummary;
|
||||
return { icon: "i-mynaui-database", verb: "Query failed", target };
|
||||
}
|
||||
if (isCancelled) {
|
||||
return { icon, verb: "Query cancelled", target: query };
|
||||
}
|
||||
return { icon, verb: "Queried", target: query };
|
||||
}
|
||||
|
||||
const formattedName = toolCall.toolName
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/[-_]/g, " ")
|
||||
.trim();
|
||||
const capitalized = formattedName.charAt(0).toUpperCase() + formattedName.slice(1);
|
||||
|
||||
const genericTarget = extractField(rawInput, ["query", "path", "command", "prompt", "url"])
|
||||
|| (isFailed ? errorSummary : "");
|
||||
|
||||
if (isPending) {
|
||||
return { icon: "i-mynaui-tool", verb: `Running ${capitalized}`, target: genericTarget };
|
||||
}
|
||||
if (isFailed) {
|
||||
return { icon: "i-mynaui-danger-triangle", verb: `${capitalized} failed`, target: genericTarget || errorSummary };
|
||||
}
|
||||
if (isCancelled) {
|
||||
return { icon: "i-mynaui-tool", verb: `${capitalized} cancelled`, target: genericTarget };
|
||||
}
|
||||
return { icon: "i-mynaui-tool", verb: capitalized, target: genericTarget };
|
||||
};
|
||||
Reference in New Issue
Block a user