feat: custom tool renderers

This commit is contained in:
Zoe
2026-09-25 19:46:00 -05:00
parent 3f81f96bda
commit bb5795e00d
18 changed files with 1294 additions and 65 deletions
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useNuxtApp } from "#app";
const props = defineProps<{
code: string;
lang?: string;
}>();
const { $shiki } = useNuxtApp();
const html = ref("");
const targetLang = computed(() => props.lang || "json");
const lineNumberWidth = computed(() => {
const lines = props.code ? props.code.split("\n").length : 1;
return lines.toString().length;
});
watch(
[() => props.code, targetLang],
async ([newCode, newLang]) => {
if (!newCode) {
html.value = "";
return;
}
let resolvedLang = newLang;
try {
if ($shiki?.getLanguage) {
await $shiki.getLanguage(resolvedLang);
}
} catch {
resolvedLang = "text";
}
try {
if ($shiki?.codeToHtml) {
html.value = await $shiki.codeToHtml(newCode, {
lang: resolvedLang,
themes: {
dark: "vitesse-dark",
light: "vitesse-light",
},
});
} else {
html.value = "";
}
} catch {
html.value = "";
}
},
{ immediate: true },
);
</script>
<template>
<div
class="tool-code-container overflow-auto max-h-80 scrollbar-thin text-xs font-mono"
:style="`--line-number-width: ${lineNumberWidth}ch;`"
>
<div v-if="html" class="tool-code-highlight" v-html="html"></div>
<pre v-else class="tool-code-fallback m-0 p-3.5 text-[var(--text-secondary)] whitespace-pre-wrap break-words leading-relaxed">{{ code }}</pre>
</div>
</template>
<style scoped>
.tool-code-container {
background-color: var(--shiki-dark-bg, #121019);
}
:deep(.tool-code-highlight > pre) {
margin: 0;
padding: 0.875rem 1rem;
font-size: 0.75rem;
line-height: 1.625;
scrollbar-width: thin;
overflow-x: auto;
counter-reset: lines;
min-height: 100%;
box-sizing: border-box;
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
:deep(.tool-code-highlight > pre > code .line::before) {
counter-increment: lines;
content: counter(lines);
width: var(--line-number-width, 2ch);
margin-right: 1.25rem;
display: inline-block;
text-align: right;
color: rgba(255, 255, 255, 0.25);
user-select: none;
}
:deep(html.light .tool-code-container) {
background-color: #ffffff;
}
:deep(html.light .tool-code-highlight > pre > code .line::before) {
color: rgba(0, 0, 0, 0.25);
}
</style>
+7 -6
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ToolCall } from '~/composables/useChat';
import { formatToolValue, parseToolValue } from '~/utils/tool-rendering';
const props = defineProps<{
toolCall: Readonly<ToolCall>;
@@ -28,21 +29,21 @@ const input: ComputedRef<string> = computed(() => {
case 'input':
if (props.toolCall.input === null || props.toolCall.input === undefined) return '';
if (props.toolCall.input!.type === 'json') {
return JSON.stringify(JSON.parse(props.toolCall.input!.value), null, 2);
return formatToolValue(props.toolCall.input);
}
return props.toolCall.input!.value;
case 'output':
if (props.toolCall.output === null || props.toolCall.output === undefined) return '';
if (props.toolCall.output!.type === 'json') {
return JSON.stringify(JSON.parse(props.toolCall.output!.value), null, 2);
return formatToolValue(props.toolCall.output);
}
return props.toolCall.output!.value;
case 'error':
if (props.toolCall.error === null || props.toolCall.error === undefined) return '';
if (props.toolCall.error!.type === 'json') {
return JSON.stringify(JSON.parse(props.toolCall.error!.value), null, 2);
return formatToolValue(props.toolCall.error);
}
return props.toolCall.error!.value;
@@ -55,7 +56,7 @@ const input: ComputedRef<string> = computed(() => {
traceObj.input = traceObj.input.value;
break;
case 'json':
traceObj.input = JSON.parse(traceObj.input.value);
traceObj.input = parseToolValue(props.toolCall.input);
break;
}
@@ -64,7 +65,7 @@ const input: ComputedRef<string> = computed(() => {
traceObj.output = traceObj.output.value;
break;
case 'json':
traceObj.output = JSON.parse(traceObj.output.value);
traceObj.output = parseToolValue(props.toolCall.output);
break;
}
@@ -73,7 +74,7 @@ const input: ComputedRef<string> = computed(() => {
traceObj.error = traceObj.error.value;
break;
case 'json':
traceObj.error = JSON.parse(traceObj.error.value);
traceObj.error = parseToolValue(props.toolCall.error);
break;
}
@@ -0,0 +1,324 @@
<script setup lang="ts">
import type { MessagePart, ToolCall } from "~/composables/useChat";
import { computed, onUnmounted, ref, useId, watch } from "vue";
import CodeViewer from "./CodeViewer.vue";
import {
formatDuration,
formatToolValue,
getToolDisplayInfo,
parseToolValue,
} from "~/utils/tool-rendering";
export interface CustomToolTab {
id: string;
label: string;
count?: string;
copyContent?: () => string;
}
const props = defineProps<{
toolCall: Readonly<ToolCall>;
part?: MessagePart;
title?: string;
subtitle?: string;
icon?: string;
customTab?: CustomToolTab;
duration?: string;
}>();
const emit = defineEmits<{
retry: [];
}>();
const isOpen = ref(false);
const contentId = useId();
const copied = ref(false);
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
const displayInfo = computed(() => getToolDisplayInfo(props.toolCall));
const displayTitle = computed(() => props.title || displayInfo.value.verb);
const displaySubtitle = computed(() => props.subtitle !== undefined ? props.subtitle : displayInfo.value.target);
const displayIcon = computed(() => props.icon || displayInfo.value.icon);
const calculatedDuration = computed(() => {
if (props.duration) {
return props.duration;
}
if (props.toolCall.status === "pending") {
return null;
}
const start = props.part?.createdAt || props.toolCall.createdAt;
const end = props.part?.lastUpdatedAt;
return formatDuration(start, end);
});
interface TabItem {
id: string;
label: string;
}
const availableTabs = computed<TabItem[]>(() => {
const tabs: TabItem[] = [];
if (props.customTab) {
tabs.push({ id: props.customTab.id, label: props.customTab.label });
}
if (props.toolCall.status === "failed" || props.toolCall.error) {
tabs.push({ id: "error", label: "Error" });
}
if (props.toolCall.input) {
tabs.push({ id: "input", label: "Input" });
}
if (props.toolCall.output) {
tabs.push({ id: "output", label: "Output" });
}
tabs.push({ id: "trace", label: "Trace" });
return tabs;
});
const resolveDefaultTab = (): string => {
if (props.toolCall.status === "failed" || props.toolCall.error) {
return "error";
}
if (props.customTab && props.toolCall.status === "completed") {
return props.customTab.id;
}
if (props.toolCall.output) {
return "output";
}
if (props.toolCall.input) {
return "input";
}
return "trace";
};
const activeTab = ref(resolveDefaultTab());
watch(
() => props.toolCall.status,
(newStatus) => {
if (newStatus === "failed") {
activeTab.value = "error";
} else if (newStatus === "completed" && props.customTab) {
activeTab.value = props.customTab.id;
}
},
);
const formattedError = computed(() => {
if (!props.toolCall.error) {
return "";
}
const parsed = parseToolValue(props.toolCall.error);
if (typeof parsed === "string") {
return parsed;
}
return JSON.stringify(parsed, null, 2);
});
const currentTabCode = computed(() => {
switch (activeTab.value) {
case "input":
return formatToolValue(props.toolCall.input);
case "output":
return formatToolValue(props.toolCall.output);
case "error":
return formattedError.value;
case "trace": {
const traceObj = {
id: props.toolCall.id,
toolName: props.toolCall.toolName,
status: props.toolCall.status,
input: parseToolValue(props.toolCall.input),
output: parseToolValue(props.toolCall.output),
error: parseToolValue(props.toolCall.error),
createdAt: props.toolCall.createdAt,
};
return JSON.stringify(traceObj, null, 2);
}
default:
return "";
}
});
const currentTabLang = computed(() => {
if (activeTab.value === "trace") {
return "json";
}
if (activeTab.value === "input") {
return props.toolCall.input?.type === "json" ? "json" : "text";
}
if (activeTab.value === "output") {
return props.toolCall.output?.type === "json" ? "json" : "text";
}
return "text";
});
const footerInfo = computed(() => {
if (props.customTab && activeTab.value === props.customTab.id) {
return props.customTab.count || "";
}
const lines = currentTabCode.value ? currentTabCode.value.split("\n").length : 0;
const lineLabel = lines === 1 ? "line" : "lines";
const lang = currentTabLang.value;
return `${lines} ${lineLabel} · ${lang}`;
});
const canRetry = computed(() => props.toolCall.status === "failed");
const copyActiveTab = async () => {
let textToCopy = "";
if (props.customTab && activeTab.value === props.customTab.id && props.customTab.copyContent) {
textToCopy = props.customTab.copyContent();
} else {
textToCopy = currentTabCode.value;
}
if (!textToCopy) {
return;
}
try {
await navigator.clipboard.writeText(textToCopy);
copied.value = true;
if (copyTimeout) {
clearTimeout(copyTimeout);
}
copyTimeout = setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error("Failed to copy tool call content:", err);
}
};
const toggle = () => {
isOpen.value = !isOpen.value;
};
onUnmounted(() => {
if (copyTimeout) {
clearTimeout(copyTimeout);
}
});
</script>
<template>
<div class="tool-disclosure flex flex-col min-w-0 text-[var(--text-tertiary)]">
<button
type="button"
class="w-full @hover:bg-[var(--color-hover)] rounded-lg p-1 flex justify-between items-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] cursor-pointer select-none text-left"
:aria-expanded="isOpen"
:aria-controls="contentId"
@click="toggle"
>
<span class="flex items-center gap-1.5 min-w-0 overflow-hidden">
<span class="w-6 h-6 flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<span
:class="[
'w-3 h-3 text-3 transition-colors',
displayIcon,
toolCall.status === 'failed' ? 'text-red-400' : 'text-[var(--text-secondary)]'
]"
aria-hidden="true"
></span>
</span>
<span class="text-sm truncate">
<span :class="toolCall.status === 'failed' ? 'text-red-400' : 'text-[var(--text-secondary)]'">
{{ displayTitle }}
</span>
<span v-if="displaySubtitle" class="text-[var(--text-tertiary)] ml-1.5 font-normal truncate">
{{ displaySubtitle }}
</span>
</span>
</span>
<span class="flex items-center gap-2 shrink-0 ml-2">
<slot name="summary"></slot>
<span
v-if="toolCall.status === 'pending'"
class="i-svg-spinners-180-ring-with-bg text-3 text-[var(--color-accent)]"
aria-hidden="true"
></span>
<span v-else-if="toolCall.status === 'failed'" class="text-xs text-red-400 font-medium">Failed</span>
<span v-else-if="toolCall.status === 'cancelled'" class="text-xs text-[var(--text-tertiary)]">Cancelled</span>
<span v-else-if="calculatedDuration" class="text-xs text-[var(--text-tertiary)] font-mono">{{ calculatedDuration }}</span>
<span
class="i-mynaui-chevron-down w-4 h-4 text-[var(--text-secondary)] transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="isOpen ? '' : '-rotate-90'"
aria-hidden="true"
></span>
</span>
</button>
<div
v-show="isOpen"
:id="contentId"
class="mt-1 border border-[var(--color-border)] rounded-xl bg-[var(--bg-surface)] overflow-hidden text-xs"
>
<div class="flex items-center gap-1 px-3 pt-2.5 pb-2 overflow-x-auto scrollbar-none border-b border-[var(--color-border)]/40">
<button
v-for="tab in availableTabs"
:key="tab.id"
type="button"
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer shrink-0"
:class="[
activeTab === tab.id
? (tab.id === 'error'
? 'bg-red-500/15 text-red-300'
: 'bg-[var(--color-hover)] text-[var(--text-primary)]')
: 'text-[var(--text-tertiary)] @hover:text-[var(--text-secondary)] @hover:bg-[var(--color-hover)]/50'
]"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="min-h-0">
<div v-if="customTab && activeTab === customTab.id">
<slot :name="customTab.id">
<slot></slot>
</slot>
</div>
<div v-else-if="activeTab === 'error'">
<CodeViewer :code="formattedError" lang="json"></CodeViewer>
</div>
<div v-else-if="['input', 'output', 'trace'].includes(activeTab)">
<CodeViewer :code="currentTabCode" :lang="currentTabLang"></CodeViewer>
</div>
</div>
<div class="flex items-center justify-between px-3 py-2 border-t border-[var(--color-border)]/40 text-xs text-[var(--text-tertiary)] bg-[var(--color-hover)]/20">
<div>
<span class="font-mono text-[11px] text-[var(--text-tertiary)]">{{ footerInfo }}</span>
</div>
<div class="flex items-center gap-2">
<slot name="actions"></slot>
<button
v-if="canRetry"
type="button"
class="flex items-center gap-1.5 px-2 py-1 rounded text-[var(--text-tertiary)] @hover:text-red-400 @hover:bg-[var(--color-hover)] transition-colors cursor-pointer text-xs"
@click="emit('retry')"
>
<span class="i-mynaui-refresh w-3.5 h-3.5" aria-hidden="true"></span>
<span>Retry</span>
</button>
<button
type="button"
class="flex items-center gap-1.5 px-2 py-1 rounded text-[var(--text-tertiary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] transition-colors cursor-pointer text-xs"
@click="copyActiveTab"
>
<span :class="copied ? 'i-mynaui-check text-emerald-400' : 'i-mynaui-copy'" class="w-3.5 h-3.5" aria-hidden="true"></span>
<span>{{ copied ? 'Copied' : 'Copy' }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { MessagePart, ToolCall } from "~/composables/useChat";
import Disclosure from "./Disclosure.vue";
defineProps<{
toolCall: Readonly<ToolCall>;
part?: MessagePart;
}>();
const emit = defineEmits<{
retry: [];
}>();
</script>
<template>
<Disclosure :tool-call="toolCall" :part="part" @retry="emit('retry')"></Disclosure>
</template>
@@ -0,0 +1,181 @@
<script setup lang="ts">
import type { MessagePart, ToolCall } from "~/composables/useChat";
import { computed, ref } from "vue";
import Disclosure from "./Disclosure.vue";
import {
domainInitial,
getDomainColor,
getFaviconUrl,
parseToolValue,
readSearchSources,
} from "~/utils/tool-rendering";
const props = defineProps<{
toolCall: Readonly<ToolCall>;
part?: MessagePart;
}>();
const emit = defineEmits<{
retry: [];
}>();
const sources = computed(() => readSearchSources(parseToolValue(props.toolCall.output)));
const query = computed(() => {
const input = parseToolValue(props.toolCall.input);
if (typeof input === "object" && input !== null && "query" in input && typeof input.query === "string") {
return input.query;
}
return "";
});
const sitesCount = computed(() => {
return sources.value?.length ?? 0;
});
const topDomains = computed(() => {
return [...new Set((sources.value ?? []).map(s => s.hostname))].slice(0, 4);
});
const failedFavicons = ref(new Set<string>());
const onFaviconError = (domain: string) => {
failedFavicons.value.add(domain);
};
const getDomainBadgeStyle = (idx: number) => {
const zIndex = topDomains.value.length - idx;
if (idx === 0) {
return { zIndex };
}
return {
zIndex,
maskImage: "radial-gradient(circle 12.5px at -4px 50%, transparent 12.5px, #000 13px)",
WebkitMaskImage: "radial-gradient(circle 12.5px at -4px 50%, transparent 12.5px, #000 13px)",
};
};
const title = computed(() => {
if (props.toolCall.status === "pending") {
return "Searching the web…";
}
if (props.toolCall.status === "failed") {
return "Search failed";
}
if (props.toolCall.status === "cancelled") {
return "Search cancelled";
}
const count = sitesCount.value;
if (count > 0) {
return `Searched ${count} ${count === 1 ? "site" : "sites"}`;
}
return "Searched the web";
});
const formattedQuery = computed(() => {
return query.value ? `"${query.value}"` : "";
});
const customTab = computed(() => {
const count = sources.value?.length ?? 0;
return {
id: "sources",
label: "Sources",
count: `${count} ${count === 1 ? "source" : "sources"}`,
copyContent: () => {
if (!sources.value?.length) {
return "";
}
return sources.value.map(s => `${s.title}\n${s.url}`).join("\n\n");
},
};
});
</script>
<template>
<Disclosure
:tool-call="toolCall"
:part="part"
:title="title"
:subtitle="formattedQuery"
icon="i-mynaui-search"
:custom-tab="customTab"
@retry="emit('retry')"
>
<template #summary>
<div v-if="topDomains.length && toolCall.status !== 'pending'" class="flex items-center mr-1" aria-hidden="true">
<span
v-for="(domain, idx) in topDomains"
:key="domain"
class="relative w-[22px] h-[22px] rounded-full overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center shrink-0"
:class="idx > 0 ? '-ml-[7px]' : ''"
:style="getDomainBadgeStyle(idx)"
:title="domain"
>
<img
v-if="!failedFavicons.has(domain)"
:src="getFaviconUrl(domain)"
:alt="domain"
class="w-[15px] h-[15px] rounded-full object-contain"
loading="lazy"
@error="onFaviconError(domain)"
>
<span
v-else
class="w-full h-full flex items-center justify-center text-[10px] font-bold text-white"
:style="{ backgroundColor: getDomainColor(domain) }"
>
{{ domainInitial(domain) }}
</span>
</span>
</div>
</template>
<template #sources>
<div v-if="sources?.length" class="flex flex-col gap-2 max-h-80 overflow-y-auto scrollbar-thin p-3">
<a
v-for="source in sources"
:key="source.url"
:href="source.url"
target="_blank"
rel="noopener noreferrer"
class="flex flex-col gap-1.5 p-3 rounded-xl bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] transition-colors cursor-pointer text-left no-underline border border-transparent @hover:border-[var(--color-border)]"
>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-6 h-6 rounded-lg overflow-hidden flex items-center justify-center shrink-0 bg-[var(--bg-surface)]">
<img
v-if="!failedFavicons.has(source.hostname)"
:src="getFaviconUrl(source.hostname)"
:alt="source.hostname"
class="w-4 h-4 rounded-md object-contain"
loading="lazy"
@error="onFaviconError(source.hostname)"
>
<span
v-else
class="w-full h-full flex items-center justify-center text-xs font-bold text-white"
:style="{ backgroundColor: getDomainColor(source.hostname) }"
>
{{ domainInitial(source.hostname) }}
</span>
</span>
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
{{ source.title }}
</span>
</div>
<span class="text-[11px] font-mono text-[var(--text-dim)] truncate pl-0.5">
{{ source.url }}
</span>
</div>
<p v-if="source.snippet" class="text-xs text-[var(--text-secondary)] leading-relaxed m-0 line-clamp-2">
{{ source.snippet }}
</p>
</a>
</div>
<p v-else class="text-xs text-[var(--text-tertiary)] p-3 m-0">
{{ toolCall.status === "pending" ? "Waiting for results…" : "No usable sources returned." }}
</p>
</template>
</Disclosure>
</template>
+16 -32
View File
@@ -1,44 +1,28 @@
<script setup lang="ts">
import type { ToolCall } from '~/composables/useChat';
import Debug from './Debug.vue';
import type { MessagePart, ToolCall } from "~/composables/useChat";
import { computed } from "vue";
import Generic from "./Generic.vue";
import { resolveToolRenderer } from "./renderers";
const props = defineProps<{
toolCall: Readonly<ToolCall>;
part?: MessagePart;
}>();
const deubgToolCallOpen = ref(false);
const emit = defineEmits<{
retry: [];
}>();
const toggleDebugToolCall = () => {
deubgToolCallOpen.value = !deubgToolCallOpen.value;
};
const renderer = computed(() => resolveToolRenderer(props.toolCall.toolName));
</script>
<template>
<div class="flex flex-col gap-2">
<div
class="select-none w-full @hover:bg-[var(--color-hover)] group rounded-lg p-1 flex justify-between items-center text-[--text-tertiary] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-1">
<div
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<span class="w-3 h-3" :class="{
'i-svg-spinners-180-ring-with-bg': toolCall.status === 'pending',
'i-mynaui-x-solid text-[#ff3b3b]': toolCall.status === 'failed',
'i-mynaui-tool': toolCall.status !== 'pending' && toolCall.status !== 'failed',
}"></span>
</div>
{{ toolCall.toolName }}
</div>
<div
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<button @click="toggleDebugToolCall"
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden @hover:bg-[var(--color-hover)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-search w-3 h-3 text-[var(--text-secondary)]"></span>
</button>
</div>
</div>
</div>
<Debug v-if="deubgToolCallOpen" :toolCall="toolCall" />
<div class="flex flex-col min-w-0">
<NuxtErrorBoundary :key="toolCall.id + ':' + toolCall.toolName">
<component :is="renderer" :tool-call="toolCall" :part="part" @retry="emit('retry')"></component>
<template #error>
<Generic :tool-call="toolCall" :part="part" @retry="emit('retry')"></Generic>
</template>
</NuxtErrorBoundary>
</div>
</template>
@@ -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<Record<string, Component>> = Object.freeze({
search: Search,
});
export const resolveToolRenderer = (toolName: string): Component => {
if (Object.hasOwn(renderers, toolName)) {
return renderers[toolName] ?? Generic;
}
return Generic;
};
+1 -1
View File
@@ -28,7 +28,7 @@ const toggleTokenDropdown = () => {
<div v-for="part in message.parts?.filter(p => p.content?.trim() !== '' || p.toolCall)" :key="part.id">
<Reasoning v-if="part.type === 'reasoning'" :part="part" />
<Text v-else-if="part.type === 'text'" :part="part" />
<Tool v-else-if="part.type === 'tool-call'" :toolCall="part.toolCall!" />
<Tool v-else-if="part.type === 'tool-call'" :tool-call="part.toolCall!" :part="part"></Tool>
<div v-else>
Unhandled part type: {{ part.type }} {{ part }}
</div>