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
+1 -1
View File
@@ -14,7 +14,7 @@ const titleId = useId();
<template>
<div class="inline-flex items-center justify-center"
:style="{ width: `${size}px`, height: `${size}px` }">
:style="{ width: `${size}px`, height: `${size}px`, borderRadius: avatar ? '0.375rem' : undefined, overflow: avatar ? 'hidden' : undefined }">
<svg xmlns="http://www.w3.org/2000/svg" :width="size" :height="size"
viewBox="0 0 24 24" role="img" :aria-labelledby="titleId"
style="flex: none; line-height: 1;">
@@ -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>
+28 -8
View File
@@ -153,10 +153,12 @@ export const useChat = async (topicId: MaybeRef<string>, 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<string>, 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<string>, 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;
}
+269
View File
@@ -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 };
};