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>
|
||||
|
||||
Reference in New Issue
Block a user