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