]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/blob
67a66d7da8a1a5d91cc6181fbffd716ec90631f9
[pkg/ggml/sources/llama.cpp] /
1 <script lang="ts">
2 import { Globe, Loader2 } from '@lucide/svelte';
3 import { CollapsibleContentBlock } from '$lib/components/app';
4 import * as HoverCard from '$lib/components/ui/hover-card';
5 import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants';
6 import { AgenticSectionType } from '$lib/enums';
7 import { mcpStore } from '$lib/stores/mcp.svelte';
8 import type { AgenticSection, SearchResult } from '$lib/types';
9 import {
10 extractSearchQuery,
11 extractSearchResults,
12 faviconForUrl,
13 sanitizeExternalUrl
14 } from '$lib/utils';
15
16 interface Props {
17 section: AgenticSection;
18 open?: boolean;
19 isStreaming?: boolean;
20 onToggle?: () => void;
21 }
22
23 let { isStreaming = false, onToggle, open = $bindable(false), section }: Props = $props();
24
25 const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
26 const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
27 const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
28
29 const results = $derived(extractSearchResults(section.toolResult));
30 const query = $derived(extractSearchQuery(section.toolArgs));
31
32 // Same icon-resolution chain as ChatMessageToolCallBlockDefault so
33 // MCP-server branding is consistent across both views. Spinner wins
34 // while the call is in flight so the user sees execution status.
35 const iconUrl = $derived(showSpinner ? null : mcpStore.getServerFaviconForTool(section.toolName));
36 const icon = $derived(showSpinner ? Loader2 : undefined);
37 const iconClass = $derived(showSpinner ? ICON_CLASS_SPIN : ICON_CLASS_DEFAULT);
38
39 // Verb reflects state: "Searching" while the call is in flight, "Searched"
40 // once results (or a definitive empty response) have arrived. Lets the
41 // heading read as a live progress indicator rather than a completed
42 // retrospective.
43 const title = $derived.by(() => {
44 const verb = showSpinner ? 'Searching' : 'Searched';
45
46 return query ? `${verb} web for "${query}"` : `${verb} web`;
47 });
48
49 function hideBrokenIcon(event: Event) {
50 (event.currentTarget as HTMLImageElement).style.display = 'none';
51 }
52
53 function formatPublishDate(iso: string | undefined): string | null {
54 if (!iso) return null;
55
56 try {
57 const date = new Date(iso);
58
59 if (Number.isNaN(date.getTime())) return iso;
60
61 return date.toLocaleDateString(undefined, {
62 day: 'numeric',
63 month: 'short',
64 year: 'numeric'
65 });
66 } catch {
67 return iso;
68 }
69 }
70
71 function hostFor(url: string): string | null {
72 try {
73 return new URL(url).host;
74 } catch {
75 return null;
76 }
77 }
78
79 function hasDetails(result: SearchResult): boolean {
80 return Boolean(result.highlights || result.published || result.author);
81 }
82 </script>
83
84 {#snippet pill(result: SearchResult)}
85 {@const faviconUrl = faviconForUrl(result.url)}
86 {@const safeUrl = sanitizeExternalUrl(result.url)}
87 {@const showHoverCard = safeUrl !== null && hasDetails(result)}
88 {#if safeUrl}
89 <HoverCard.Root openDelay={150} closeDelay={100}>
90 <HoverCard.Trigger
91 href={safeUrl}
92 target="_blank"
93 rel="noopener noreferrer"
94 class="hover:bg-muted/80 focus-visible:ring-ring inline-flex max-w-full items-center gap-1.5 rounded-full border bg-muted px-2.5 py-1 text-xs transition-colors outline-none focus-visible:ring-2"
95 >
96 {#if faviconUrl}
97 <img
98 src={faviconUrl}
99 alt=""
100 class="h-3 w-3 shrink-0 rounded-sm"
101 onerror={hideBrokenIcon}
102 />
103 {:else}
104 <Globe class="text-muted-foreground/70 h-3 w-3 shrink-0" />
105 {/if}
106 <span class="truncate font-medium text-foreground/80">{result.title}</span>
107 </HoverCard.Trigger>
108 {#if showHoverCard}
109 {@const publishDate = formatPublishDate(result.published)}
110 {@const host = hostFor(safeUrl)}
111 <HoverCard.Content
112 side="top"
113 align="start"
114 sideOffset={6}
115 class="bg-popover text-popover-foreground z-50 w-80 max-w-[90vw] rounded-lg border p-0 shadow-lg"
116 >
117 <div class="flex flex-col gap-2 p-3">
118 <a
119 href={safeUrl}
120 target="_blank"
121 rel="noopener noreferrer"
122 class="line-clamp-3 text-sm font-medium leading-snug hover:underline"
123 >{result.title}</a
124 >
125 {#if publishDate || result.author}
126 <div class="text-muted-foreground flex items-center gap-1.5 text-[11px]">
127 {#if publishDate}
128 <span>{publishDate}</span>
129 {/if}
130 {#if publishDate && result.author}
131 <span class="opacity-50">&middot;</span>
132 {/if}
133 {#if result.author}
134 <span class="truncate">{result.author}</span>
135 {/if}
136 </div>
137 {/if}
138 {#if result.highlights}
139 <p
140 class="text-popover-foreground/85 line-clamp-5 text-xs leading-relaxed whitespace-pre-line"
141 >
142 {result.highlights}
143 </p>
144 {/if}
145 {#if host}
146 <div class="text-muted-foreground/80 truncate text-[11px]">{host}</div>
147 {/if}
148 </div>
149 </HoverCard.Content>
150 {/if}
151 </HoverCard.Root>
152 {/if}
153 {/snippet}
154
155 <CollapsibleContentBlock {open} class="my-2" {icon} {iconClass} {iconUrl} {title} {onToggle}>
156 {#if results.length > 0}
157 <div class="flex flex-wrap items-center gap-2 pb-1">
158 {#each results as result (result.url)}
159 {@render pill(result)}
160 {/each}
161 </div>
162 {:else if showSpinner}
163 <div class="text-muted-foreground/70 flex items-center gap-2 py-1 text-xs italic">
164 <Loader2 class="h-3 w-3 animate-spin" />
165 <span>Searching...</span>
166 </div>
167 {:else}
168 <div class="text-muted-foreground/70 py-1 text-xs italic">No results</div>
169 {/if}
170 </CollapsibleContentBlock>