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