diff --git a/app/components/Logo/ThinkingMachines.vue b/app/components/Logo/ThinkingMachines.vue index 8c1f405..7ae78b7 100644 --- a/app/components/Logo/ThinkingMachines.vue +++ b/app/components/Logo/ThinkingMachines.vue @@ -14,7 +14,7 @@ const titleId = useId(); + + diff --git a/app/components/Message/Agent/Tool/Debug.vue b/app/components/Message/Agent/Tool/Debug.vue index 13f21f7..0025cb2 100644 --- a/app/components/Message/Agent/Tool/Debug.vue +++ b/app/components/Message/Agent/Tool/Debug.vue @@ -1,5 +1,6 @@ + + diff --git a/app/components/Message/Agent/Tool/Generic.vue b/app/components/Message/Agent/Tool/Generic.vue new file mode 100644 index 0000000..dd901dd --- /dev/null +++ b/app/components/Message/Agent/Tool/Generic.vue @@ -0,0 +1,17 @@ + + + diff --git a/app/components/Message/Agent/Tool/Search.vue b/app/components/Message/Agent/Tool/Search.vue new file mode 100644 index 0000000..e727d00 --- /dev/null +++ b/app/components/Message/Agent/Tool/Search.vue @@ -0,0 +1,181 @@ + + + diff --git a/app/components/Message/Agent/Tool/index.vue b/app/components/Message/Agent/Tool/index.vue index 763af85..85f41bb 100644 --- a/app/components/Message/Agent/Tool/index.vue +++ b/app/components/Message/Agent/Tool/index.vue @@ -1,44 +1,28 @@ diff --git a/app/components/Message/Agent/Tool/renderers.ts b/app/components/Message/Agent/Tool/renderers.ts new file mode 100644 index 0000000..1489cf1 --- /dev/null +++ b/app/components/Message/Agent/Tool/renderers.ts @@ -0,0 +1,15 @@ +import type { Component } from "vue"; +import Generic from "./Generic.vue"; +import Search from "./Search.vue"; + +// Renderers receive a readonly toolCall prop and own their presentation only. +const renderers: Readonly> = Object.freeze({ + search: Search, +}); + +export const resolveToolRenderer = (toolName: string): Component => { + if (Object.hasOwn(renderers, toolName)) { + return renderers[toolName] ?? Generic; + } + return Generic; +}; diff --git a/app/components/Message/Agent/index.vue b/app/components/Message/Agent/index.vue index c386c99..b2351db 100644 --- a/app/components/Message/Agent/index.vue +++ b/app/components/Message/Agent/index.vue @@ -28,7 +28,7 @@ const toggleTokenDropdown = () => {
- +
Unhandled part type: {{ part.type }} {{ part }}
diff --git a/app/composables/useChat.ts b/app/composables/useChat.ts index 3665046..6bdc2bb 100644 --- a/app/composables/useChat.ts +++ b/app/composables/useChat.ts @@ -153,10 +153,12 @@ export const useChat = async (topicId: MaybeRef, connect = true) => { case 'text-end': { const msg = data.value.messages.find(m => m.id === payload.messageId); const part = msg?.parts?.find(p => p.id === payload.partId); - if (part) { + if (part && part.type !== 'tool-call') { nextFlush(() => { part.content = payload.content; - part.lastUpdatedAt = payload.lastUpdatedAt; + if (payload.lastUpdatedAt) { + part.lastUpdatedAt = payload.lastUpdatedAt; + } part.finished = true; }); } @@ -166,8 +168,14 @@ export const useChat = async (topicId: MaybeRef, connect = true) => { case 'tool-call-start': { const msg = data.value.messages.find(m => m.id === payload.messageId); if (msg) { - if (!msg.parts) msg.parts = []; - msg.parts.push(payload.part); + if (!msg.parts) { + msg.parts = []; + } + const part = { ...payload.part }; + if (!part.createdAt) { + part.createdAt = new Date().toISOString(); + } + msg.parts.push(part); } break; } @@ -176,10 +184,22 @@ export const useChat = async (topicId: MaybeRef, connect = true) => { const msg = data.value.messages.find(m => m.id === payload.messageId); const part = msg?.parts?.find(p => p.toolCallId === payload.toolCallId); if (part?.toolCall) { - if (payload.input) part.toolCall.input = payload.input; - if (payload.output) part.toolCall.output = payload.output; - if (payload.error) part.toolCall.error = payload.error; - if (payload.status) part.toolCall.status = payload.status; + if (payload.input) { + part.toolCall.input = payload.input; + } + if (payload.output) { + part.toolCall.output = payload.output; + } + if (payload.error) { + part.toolCall.error = payload.error; + } + if (payload.status) { + part.toolCall.status = payload.status; + if (payload.status === 'completed' || payload.status === 'failed') { + part.lastUpdatedAt = payload.lastUpdatedAt || new Date().toISOString(); + part.finished = true; + } + } } break; } diff --git a/app/utils/tool-rendering.ts b/app/utils/tool-rendering.ts new file mode 100644 index 0000000..0997f1e --- /dev/null +++ b/app/utils/tool-rendering.ts @@ -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(); + 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)[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 }; +}; diff --git a/docs/tool-renderers.md b/docs/tool-renderers.md new file mode 100644 index 0000000..60bd8a1 --- /dev/null +++ b/docs/tool-renderers.md @@ -0,0 +1,14 @@ +# Tool renderers + +Tool presentation lives in `app/components/Message/Agent/Tool/`. + +To add a renderer: + +1. Create a Vue component accepting `toolCall: Readonly` and optional `part?: MessagePart`. +2. Register its exact tool name in `renderers.ts`. +3. Wrap your custom presentation with `Disclosure.vue`. Provide `title`, `subtitle`, `icon`, and `customTab` (specifying `id`, `label`, `count`, and optional `copyContent`). Place the custom UI in the named slot matching your tab ID. + +The expanded card seamlessly integrates custom renderer tabs alongside standard inspector tabs (`Input`, `Output`, `Trace`, and `Error` on failure). Line counts and duration are formatted automatically. Built-in `Copy` and `Retry` actions allow quick clipboard access and retry handling. Unknown tools fall back to `Generic.vue` with smart heuristics for common tool signatures (read, edit, query, fetch, python). + +Use `parseToolValue` from `app/utils/tool-rendering.ts` to decode stored values as `unknown`, then validate the tool-specific shape. Inputs and outputs can change while streaming, so derive presentation with computed values and handle pending, completed, failed, and cancelled calls. Never execute tool output as markup or code. + diff --git a/server/api/topic/[topicId]/chat/index.post.ts b/server/api/topic/[topicId]/chat/index.post.ts index 60a88a7..97562be 100644 --- a/server/api/topic/[topicId]/chat/index.post.ts +++ b/server/api/topic/[topicId]/chat/index.post.ts @@ -13,6 +13,8 @@ import { buildFocusedMessageTree, buildMessageTree, marshallMessages } from "~~/ import path from "path"; import { isRerankingProvider } from "~~/server/utils/ai-provider"; import { generateFileToken } from "~~/server/utils/file-token"; +import { serializeToolError } from "~~/utils/tool-errors"; +import { readFirecrawlResponse } from "~~/server/utils/firecrawl"; export default defineEventHandler(async (event) => { await protectRoute(event); @@ -490,11 +492,16 @@ const fetchUrlTool = tool({ }, body: JSON.stringify({ url, + formats: ["markdown"], }), }); - const content = await response.json(); + const result = await readFirecrawlResponse(response); + if (!result.ok) { + // The SDK needs a rejected execution to emit a tool-error, not a successful result. + return Promise.reject(new Error(result.error)); + } return { - content: content.data.markdown, + content: result.data, }; }, }); @@ -608,6 +615,7 @@ async function generateResponse( // Mark failed tool calls await Promise.all([...activeToolCalls].map(async id => { + const lastUpdatedAt = new Date(); await db.update(toolCalls).set({ status: 'failed', error: { type: ToolCallType.Text, value: 'Generation failed' } @@ -619,10 +627,12 @@ async function generateResponse( messageId: message.id, toolCallId: id, toolName: null, + status: 'failed', error: { type: ToolCallType.Text, value: 'Generation failed' - } + }, + lastUpdatedAt: lastUpdatedAt.toISOString(), } }) })); @@ -891,8 +901,6 @@ async function generateResponse( }) activeToolCalls.add(dbToolCallId); - - activeParts.set(key, { id: part.id, accumulatedContent: '' }); } break; case 'tool-call': { let inputType: ToolCallType = ToolCallType.Text; @@ -997,8 +1005,6 @@ async function generateResponse( }) activeToolCalls.add(dbToolCallId); - - activeParts.set(key, { id: part.id, accumulatedContent: '' }); } } break; case 'tool-result': { @@ -1028,6 +1034,7 @@ async function generateResponse( }) .where(eq(messageParts.toolCallId, dbToolCallId)); + const lastUpdatedAt = new Date(); await topicEvents.emit(topicId, { type: 'tool-call-delta', payload: { @@ -1039,6 +1046,7 @@ async function generateResponse( value: outputValue, }, status: 'failed', + lastUpdatedAt: lastUpdatedAt.toISOString(), } }) @@ -1048,6 +1056,7 @@ async function generateResponse( } if (dbToolCallId) { + const lastUpdatedAt = new Date(); await db.update(toolCalls).set({ status: 'completed', output: { @@ -1055,9 +1064,11 @@ async function generateResponse( value: outputValue, }, }).where(eq(toolCalls.id, dbToolCallId)); - if (token.providerMetadata) await db.update(messageParts) + await db.update(messageParts) .set({ - providerOptions: token.providerMetadata, + finished: true, + lastUpdatedAt, + ...(token.providerMetadata ? { providerOptions: token.providerMetadata } : {}), }) .where(eq(messageParts.toolCallId, dbToolCallId)); @@ -1072,6 +1083,7 @@ async function generateResponse( value: outputValue, }, status: 'completed', + lastUpdatedAt: lastUpdatedAt.toISOString(), } }) @@ -1092,8 +1104,8 @@ async function generateResponse( outputValue = token.error; break; case 'object': - outputType = ToolCallType.Json; - outputValue = JSON.stringify(token.error); + outputType = token.error instanceof Error ? ToolCallType.Text : ToolCallType.Json; + outputValue = serializeToolError(token.error); break; default: console.error('Unknown error type', token.error); @@ -1104,6 +1116,7 @@ async function generateResponse( const existingDbToolCallId = nativeToDbToolCallId.get(token.toolCallId); if (existingDbToolCallId && activeToolCalls.has(existingDbToolCallId)) { + const lastUpdatedAt = new Date(); await db.update(toolCalls).set({ status: 'failed', error: { @@ -1111,9 +1124,11 @@ async function generateResponse( value: outputValue as string, } }).where(eq(toolCalls.id, existingDbToolCallId)); - if (token.providerMetadata) await db.update(messageParts) + await db.update(messageParts) .set({ - providerOptions: token.providerMetadata, + finished: true, + lastUpdatedAt, + ...(token.providerMetadata ? { providerOptions: token.providerMetadata } : {}), }) .where(eq(messageParts.toolCallId, existingDbToolCallId)); @@ -1123,10 +1138,12 @@ async function generateResponse( messageId: message.id, toolCallId: existingDbToolCallId, toolName: token.toolName, + status: 'failed', error: { type: outputType as ToolCallType, value: outputValue as string, }, + lastUpdatedAt: lastUpdatedAt.toISOString(), } }) } else { @@ -1176,11 +1193,9 @@ async function generateResponse( type: 'tool-call-start', payload: { messageId: message.id, - part, + part: { ...part, toolCall }, } }) - - activeParts.set(key, { id: part.id, accumulatedContent: '' }); } if (existingDbToolCallId) { diff --git a/server/utils/firecrawl.ts b/server/utils/firecrawl.ts new file mode 100644 index 0000000..eda3b41 --- /dev/null +++ b/server/utils/firecrawl.ts @@ -0,0 +1,36 @@ +import type { Result } from "../../types/result"; +import { z } from "zod"; +import { Err, Ok } from "../../types/result"; + +const scrapeResponseSchema = z.object({ + success: z.boolean().optional(), + error: z.union([z.string(), z.object({ message: z.string() })]).optional(), + message: z.string().optional(), + data: z.unknown().optional(), +}); + +export const readFirecrawlResponse = async (response: Response): Promise> => { + const context = `Firecrawl scrape (HTTP ${response.status})`; + let body: unknown; + try { + body = await response.json(); + } catch { + return Err(`${context}: received a non-JSON or unreadable response.`); + } + + const parsed = scrapeResponseSchema.safeParse(body); + if (!parsed.success) { + return Err(`${context}: received an unexpected response format.`); + } + + const payload = parsed.data; + const providerError = typeof payload.error === "string" ? payload.error : payload.error?.message; + if (!response.ok || payload.success === false || providerError) { + return Err(`${context}: ${providerError || payload.message || response.statusText || "Scrape failed without an error message."}`); + } + const content = z.object({ markdown: z.string() }).safeParse(payload.data); + if (!content.success || content.data.markdown.trim() === "") { + return Err(`${context}: no Markdown content was returned for this URL.`); + } + return Ok(content.data.markdown); +}; diff --git a/tests/tool-errors.test.ts b/tests/tool-errors.test.ts new file mode 100644 index 0000000..e4e96de --- /dev/null +++ b/tests/tool-errors.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test"; +import { readStoredToolError, serializeToolError } from "../utils/tool-errors"; + +const validationError = "Invalid input for tool search: Type validation failed: Value: {\"query\":123}.\nError message: [{\"code\":\"custom\"}]"; + +test("tool Error instances persist their message, not an empty JSON object", () => { + expect(serializeToolError(new Error(validationError))).toBe(validationError); + expect(serializeToolError(validationError)).toBe(validationError); + expect(serializeToolError({ reason: "invalid input" })).toBe('{"reason":"invalid input"}'); +}); + +test("text validation errors remain text on history replay", () => { + expect(readStoredToolError({ type: "text", value: validationError })).toEqual({ + type: "error-text", + value: validationError, + }); +}); + +test("legacy mislabeled JSON errors cannot break subsequent prompts", () => { + expect(readStoredToolError({ type: "json", value: validationError })).toEqual({ + type: "error-text", + value: validationError, + }); +}); + +test("preserves actual JSON errors and handles missing errors", () => { + expect(readStoredToolError({ type: "json", value: '{"reason":"bad input"}' })).toEqual({ + type: "error-json", + value: { reason: "bad input" }, + }); + expect(readStoredToolError(null).type).toBe("error-text"); + expect(readStoredToolError(undefined).type).toBe("error-text"); +}); diff --git a/tests/tool-rendering.test.ts b/tests/tool-rendering.test.ts new file mode 100644 index 0000000..297fcb8 --- /dev/null +++ b/tests/tool-rendering.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; +import { + domainInitial, + formatDuration, + formatToolValue, + getDomainColor, + getFaviconUrl, + getToolDisplayInfo, + parseToolValue, + readSearchSources, +} from "../app/utils/tool-rendering"; + +describe("stored tool values", () => { + test("handles missing, text, valid JSON and streaming JSON", () => { + expect(parseToolValue(null)).toBeNull(); + expect(parseToolValue({ type: "text", value: "hello" })).toBe("hello"); + expect(parseToolValue({ type: "json", value: '{"query":"gold"}' })).toEqual({ query: "gold" }); + expect(formatToolValue({ type: "json", value: '{"query":' })).toBe('{"query":'); + }); +}); + +describe("search sources", () => { + test("distinguishes unavailable results from empty results", () => { + expect(readSearchSources(null)).toBeNull(); + expect(readSearchSources("partial")).toBeNull(); + expect(readSearchSources([])).toEqual([]); + }); + + test("rejects unsafe links, tolerates malformed entries and deduplicates pages", () => { + expect(readSearchSources([ + null, + { link: "javascript:alert(1)" }, + { link: "data:text/html,hello" }, + { link: "/relative" }, + { link: "https://example.com/page#one", title: "Example", snippet: "Evidence" }, + { link: "https://example.com/page#two", title: "Duplicate" }, + { link: "https://other.example/", title: 1 }, + ])).toEqual([ + { url: "https://example.com/page", hostname: "example.com", title: "Example", snippet: "Evidence" }, + { url: "https://other.example/", hostname: "other.example", title: "other.example", snippet: "" }, + ]); + }); +}); + +describe("duration formatting", () => { + test("formats duration in seconds with 1 decimal", () => { + const start = new Date("2026-09-25T12:00:00.000Z"); + const end = new Date("2026-09-25T12:00:02.100Z"); + expect(formatDuration(start, end)).toBe("2.1s"); + }); + + test("formats numeric timestamps", () => { + const now = 1700000000000; + expect(formatDuration(now, now + 1500)).toBe("1.5s"); + expect(formatDuration(new Date(now).toISOString(), now + 3200)).toBe("3.2s"); + }); + + test("formats short duration", () => { + const start = new Date("2026-09-25T12:00:00.000Z"); + const end = new Date("2026-09-25T12:00:00.400Z"); + expect(formatDuration(start, end)).toBe("0.4s"); + }); + + test("returns null for invalid or negative or sub-threshold duration", () => { + expect(formatDuration(null, null)).toBeNull(); + const start = new Date("2026-09-25T12:00:02.000Z"); + const end = new Date("2026-09-25T12:00:01.000Z"); + expect(formatDuration(start, end)).toBeNull(); + }); +}); + +describe("tool display info", () => { + test("handles search with results", () => { + const info = getToolDisplayInfo({ + toolName: "search", + status: "completed", + input: { type: "json", value: '{"query":"drizzle pg enum best practices"}' }, + output: { + type: "json", + value: JSON.stringify([ + { link: "https://orm.drizzle.team/docs/enum", title: "PostgreSQL enums | Drizzle ORM" }, + ]), + }, + }); + expect(info.verb).toBe("Searched 1 site"); + expect(info.target).toBe('"drizzle pg enum best practices"'); + expect(info.icon).toBe("i-mynaui-search"); + }); + + test("handles read file", () => { + const info = getToolDisplayInfo({ + toolName: "read_file", + status: "completed", + input: { type: "json", value: '{"path":"drizzle/schema.ts"}' }, + }); + expect(info.verb).toBe("Read"); + expect(info.target).toBe("drizzle/schema.ts"); + expect(info.icon).toBe("i-mynaui-file"); + }); + + test("handles file edit pending", () => { + const info = getToolDisplayInfo({ + toolName: "edit_file", + status: "pending", + input: { type: "json", value: '{"path":"server/api/chat/generate.ts"}' }, + }); + expect(info.verb).toBe("Editing"); + expect(info.target).toBe("server/api/chat/generate.ts"); + expect(info.icon).toBe("i-mynaui-edit"); + }); + + test("handles query failed", () => { + const info = getToolDisplayInfo({ + toolName: "query", + status: "failed", + input: { type: "json", value: '{"query":"select * from tool_calls"}' }, + error: { type: "text", value: 'relation "tool_calls" does not exist' }, + }); + expect(info.verb).toBe("Query failed"); + expect(info.target).toBe("select * from tool_calls"); + expect(info.icon).toBe("i-mynaui-database"); + }); + + test("handles fetch cancelled", () => { + const info = getToolDisplayInfo({ + toolName: "fetchUrl", + status: "cancelled", + input: { type: "json", value: '{"url":"https://orm.drizzle.team/docs/..."}' }, + }); + expect(info.verb).toBe("Fetch cancelled"); + expect(info.target).toBe("https://orm.drizzle.team/docs/..."); + expect(info.icon).toBe("i-mynaui-link"); + }); + + test("handles python execution", () => { + const info = getToolDisplayInfo({ + toolName: "python", + status: "completed", + input: { type: "json", value: '{"code":"2 + 2"}' }, + output: { type: "json", value: '{"output":"4"}' }, + }); + expect(info.verb).toBe("Ran Python"); + expect(info.target).toBe("2 + 2"); + expect(info.icon).toBe("i-mynaui-terminal"); + }); +}); + +describe("domain utilities", () => { + test("extracts domain initial correctly", () => { + expect(domainInitial("goldprice.org")).toBe("G"); + expect(domainInitial("www.kitco.com")).toBe("K"); + expect(domainInitial("apmex.com")).toBe("A"); + }); + + test("returns consistent domain colors", () => { + const color1 = getDomainColor("goldprice.org"); + const color2 = getDomainColor("goldprice.org"); + expect(color1).toBe(color2); + expect(typeof color1).toBe("string"); + expect(color1.startsWith("#")).toBe(true); + }); + + test("formats DuckDuckGo favicon service url", () => { + expect(getFaviconUrl("goldprice.org")).toBe("https://icons.duckduckgo.com/ip3/goldprice.org.ico"); + expect(getFaviconUrl("www.kitco.com")).toBe("https://icons.duckduckgo.com/ip3/kitco.com.ico"); + }); +}); + + diff --git a/uno.config.ts b/uno.config.ts index d79de6b..d9993a0 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -3,7 +3,19 @@ import { defineConfig, presetMini, presetIcons } from 'unocss'; export default defineConfig({ presets: [ presetMini(), - presetIcons() + presetIcons(), + ], + safelist: [ + "i-mynaui-search", + "i-mynaui-link", + "i-mynaui-globe", + "i-mynaui-terminal", + "i-mynaui-file", + "i-mynaui-edit", + "i-mynaui-database", + "i-mynaui-tool", + "i-mynaui-danger-triangle", + "i-svg-spinners-180-ring-with-bg", ], rules: [ ['object-cover', { 'object-fit': 'cover' }], diff --git a/utils/tool-errors.ts b/utils/tool-errors.ts new file mode 100644 index 0000000..8a1c011 --- /dev/null +++ b/utils/tool-errors.ts @@ -0,0 +1,36 @@ +import type { JSONValue } from "ai"; + +export type ToolErrorOutput = + | { type: "error-text"; value: string } + | { type: "error-json"; value: JSONValue }; + +export const serializeToolError = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + try { + return JSON.stringify(error) ?? "An unknown tool error occurred"; + } catch { + return "The tool returned an error that could not be serialized"; + } +}; + +export const readStoredToolError = ( + error: Readonly<{ type: string; value: string }> | null | undefined, +): ToolErrorOutput => { + if (!error) { + return { type: "error-text", value: "An unknown tool error occurred" }; + } + if (error.type === "json") { + try { + const value: JSONValue = JSON.parse(error.value); + return { type: "error-json", value }; + } catch { + // Older tool failures were saved as JSON even when their value was plain text. + } + } + return { type: "error-text", value: error.value }; +};