288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
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 readSearchWarnings = (value: unknown): { engine: string; reason: string }[] => {
|
|
if (typeof value !== "object" || value === null || !("warnings" in value) || !Array.isArray(value.warnings)) {
|
|
return [];
|
|
}
|
|
return value.warnings.flatMap((warning: unknown) => {
|
|
if (typeof warning !== "object" || warning === null
|
|
|| !("engine" in warning) || typeof warning.engine !== "string"
|
|
|| !("reason" in warning) || typeof warning.reason !== "string") {
|
|
return [];
|
|
}
|
|
return [{ engine: warning.engine, reason: warning.reason }];
|
|
});
|
|
};
|
|
|
|
export const readSearchSources = (value: unknown): SearchSource[] | null => {
|
|
// Older tool calls stored the results array directly.
|
|
if (typeof value === "object" && value !== null && "results" in value) {
|
|
value = value.results;
|
|
}
|
|
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 };
|
|
};
|