201 lines
7.7 KiB
Vue
201 lines
7.7 KiB
Vue
<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,
|
|
readSearchWarnings,
|
|
} from "~/utils/tool-rendering";
|
|
|
|
const props = defineProps<{
|
|
toolCall: Readonly<ToolCall>;
|
|
part?: MessagePart;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
retry: [];
|
|
}>();
|
|
|
|
const output = computed(() => parseToolValue(props.toolCall.output));
|
|
const sources = computed(() => readSearchSources(output.value));
|
|
const warnings = computed(() => readSearchWarnings(output.value));
|
|
const warningSummary = computed(() => warnings.value.map(warning => `${warning.engine}: ${warning.reason}`).join("; "));
|
|
|
|
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>
|
|
<Tooltip
|
|
v-if="warnings.length"
|
|
class="flex items-center text-amber-500 mr-2"
|
|
:text="`Some search engines failed: ${warningSummary}`"
|
|
>
|
|
<span
|
|
class="i-mynaui-danger-triangle w-4 h-4"
|
|
role="img"
|
|
:aria-label="`Some search engines failed: ${warningSummary}`"
|
|
></span>
|
|
</Tooltip>
|
|
<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 class="flex flex-col gap-2 max-h-80 overflow-y-auto scrollbar-thin p-3">
|
|
<p v-if="warnings.length" class="flex items-start gap-2 text-xs text-amber-500 m-0">
|
|
<span class="i-mynaui-danger-triangle w-4 h-4 shrink-0" aria-hidden="true"></span>
|
|
<span>Some search engines failed: {{ warningSummary }}</span>
|
|
</p>
|
|
<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>
|
|
<p v-if="!sources?.length" class="text-xs text-[var(--text-tertiary)] m-0">
|
|
{{ toolCall.status === "pending" ? "Waiting for results…" : "No usable sources returned." }}
|
|
</p>
|
|
</div>
|
|
</template>
|
|
</Disclosure>
|
|
</template>
|