feat: custom tool renderers
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user