feat: custom tool renderers
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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<ToolCall>` 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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Result<string, string>> => {
|
||||
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);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+13
-1
@@ -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' }],
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user