]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/blob
126d08744a9305040e9d1e9042a86e24e043ad2c
[pkg/ggml/sources/llama.cpp] /
1 <script lang="ts">
2 import {
3 ChatFormPickerItemHeader,
4 ChatFormPickerList,
5 ChatFormPickerListItem,
6 ChatFormPickerListItemSkeleton,
7 ChatFormPickerPopover,
8 ChatFormPromptPickerArgumentForm
9 } from '$lib/components/app/chat';
10 import Badge from '$lib/components/ui/badge/badge.svelte';
11 import { KeyboardKey } from '$lib/enums';
12 import { conversationsStore } from '$lib/stores/conversations.svelte';
13 import { mcpStore } from '$lib/stores/mcp.svelte';
14 import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
15 import { debounce, uuid } from '$lib/utils';
16 import { SvelteMap } from 'svelte/reactivity';
17
18 interface Props {
19 class?: string;
20 isOpen?: boolean;
21 searchQuery?: string;
22 onClose?: () => void;
23 onPromptLoadStart?: (
24 placeholderId: string,
25 promptInfo: MCPPromptInfo,
26 args?: Record<string, string>
27 ) => void;
28 onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
29 onPromptLoadError?: (placeholderId: string, error: string) => void;
30 }
31
32 let {
33 class: className = '',
34 isOpen = false,
35 onClose,
36 onPromptLoadComplete,
37 onPromptLoadError,
38 onPromptLoadStart,
39 searchQuery = ''
40 }: Props = $props();
41
42 let prompts = $state<MCPPromptInfo[]>([]);
43 let isLoading = $state(false);
44 let selectedPrompt = $state<MCPPromptInfo | null>(null);
45 let promptArgs = $state<Record<string, string>>({});
46 let selectedIndex = $state(0);
47 let internalSearchQuery = $state('');
48 // Bumped on ArrowUp/ArrowDown only, so the list scrolls on keyboard
49 // nav but not on hover or result changes.
50 let scrollTrigger = $state(0);
51 let promptError = $state<string | null>(null);
52 let selectedIndexBeforeArgumentForm = $state<number | null>(null);
53
54 let suggestions = $state<Record<string, string[]>>({});
55 let loadingSuggestions = $state<Record<string, boolean>>({});
56 let activeAutocomplete = $state<string | null>(null);
57 let autocompleteIndex = $state(0);
58
59 let serverSettingsMap = $derived.by(() => {
60 const servers = mcpStore.getServers();
61 const map = new SvelteMap<string, MCPServerSettingsEntry>();
62
63 for (const server of servers) {
64 map.set(server.id, server);
65 }
66
67 return map;
68 });
69
70 $effect(() => {
71 if (isOpen) {
72 loadPrompts();
73 selectedIndex = 0;
74 } else {
75 selectedPrompt = null;
76 promptArgs = {};
77 promptError = null;
78 }
79 });
80
81 $effect(() => {
82 if (filteredPrompts.length > 0 && selectedIndex >= filteredPrompts.length) {
83 selectedIndex = 0;
84 }
85 });
86
87 async function loadPrompts() {
88 isLoading = true;
89
90 try {
91 const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
92 const initialized = await mcpStore.ensureInitialized(perChatOverrides);
93
94 if (!initialized) {
95 prompts = [];
96
97 return;
98 }
99
100 prompts = await mcpStore.getAllPrompts();
101 } catch (error) {
102 console.error('[ChatFormPickerMcpPrompts] Failed to load prompts:', error);
103 prompts = [];
104 } finally {
105 isLoading = false;
106 }
107 }
108
109 function handlePromptClick(prompt: MCPPromptInfo) {
110 const args = prompt.arguments ?? [];
111
112 if (args.length > 0) {
113 selectedIndexBeforeArgumentForm = selectedIndex;
114 selectedPrompt = prompt;
115 promptArgs = {};
116 promptError = null;
117
118 requestAnimationFrame(() => {
119 const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
120
121 if (firstInput) {
122 firstInput.focus();
123 }
124 });
125 } else {
126 executePrompt(prompt, {});
127 }
128 }
129
130 async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
131 promptError = null;
132
133 const placeholderId = uuid();
134 const nonEmptyArgs = Object.fromEntries(
135 Object.entries(args).filter(([, value]) => value.trim() !== '')
136 );
137 const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
138
139 onPromptLoadStart?.(placeholderId, prompt, argsToPass);
140 onClose?.();
141
142 try {
143 const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
144
145 onPromptLoadComplete?.(placeholderId, result);
146 } catch (error) {
147 const errorMessage =
148 error instanceof Error ? error.message : 'Unknown error executing prompt';
149
150 onPromptLoadError?.(placeholderId, errorMessage);
151 }
152 }
153
154 function handleArgumentSubmit(event: SubmitEvent) {
155 event.preventDefault();
156
157 if (selectedPrompt) {
158 executePrompt(selectedPrompt, promptArgs);
159 }
160 }
161
162 const fetchCompletions = debounce(async (argName: string, value: string) => {
163 if (!selectedPrompt || value.length < 1) {
164 suggestions[argName] = [];
165
166 return;
167 }
168
169 if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
170 console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
171 argName,
172 promptName: selectedPrompt.name,
173 serverName: selectedPrompt.serverName,
174 value
175 });
176 }
177
178 loadingSuggestions[argName] = true;
179
180 try {
181 const result = await mcpStore.getPromptCompletions(
182 selectedPrompt.serverName,
183 selectedPrompt.name,
184 argName,
185 value
186 );
187
188 if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
189 console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
190 argName,
191 result,
192 suggestionsCount: result?.values.length ?? 0,
193 value
194 });
195 }
196
197 if (result && result.values.length > 0) {
198 // Filter out empty strings from suggestions
199 const filteredValues = result.values.filter((v) => v.trim() !== '');
200
201 if (filteredValues.length > 0) {
202 suggestions[argName] = filteredValues;
203 activeAutocomplete = argName;
204 autocompleteIndex = 0;
205 } else {
206 suggestions[argName] = [];
207 }
208 } else {
209 suggestions[argName] = [];
210 }
211 } catch (error) {
212 console.error('[ChatFormPickerMcpPrompts] Failed to fetch completions:', error);
213 suggestions[argName] = [];
214 } finally {
215 loadingSuggestions[argName] = false;
216 }
217 }, 200);
218
219 function handleArgInput(argName: string, value: string) {
220 promptArgs[argName] = value;
221 fetchCompletions(argName, value);
222 }
223
224 function selectSuggestion(argName: string, value: string) {
225 promptArgs[argName] = value;
226 suggestions[argName] = [];
227 activeAutocomplete = null;
228 }
229
230 function handleArgKeydown(event: KeyboardEvent, argName: string) {
231 const argSuggestions = suggestions[argName] ?? [];
232
233 // Handle Escape - return to prompt selection list
234 if (event.key === KeyboardKey.ESCAPE) {
235 event.preventDefault();
236 event.stopPropagation();
237 handleCancelArgumentForm();
238
239 return;
240 }
241
242 if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
243
244 if (event.key === KeyboardKey.ARROW_DOWN) {
245 event.preventDefault();
246 autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
247 } else if (event.key === KeyboardKey.ARROW_UP) {
248 event.preventDefault();
249 autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
250 } else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
251 event.preventDefault();
252 event.stopPropagation();
253 selectSuggestion(argName, argSuggestions[autocompleteIndex]);
254 }
255 }
256
257 function handleArgBlur(argName: string) {
258 // Delay to allow click on suggestion
259 setTimeout(() => {
260 if (activeAutocomplete === argName) {
261 suggestions[argName] = [];
262 activeAutocomplete = null;
263 }
264 }, 150);
265 }
266
267 function handleArgFocus(argName: string) {
268 if ((suggestions[argName]?.length ?? 0) > 0) {
269 activeAutocomplete = argName;
270 }
271 }
272
273 function handleCancelArgumentForm() {
274 // Restore the previously selected prompt index
275 if (selectedIndexBeforeArgumentForm !== null) {
276 selectedIndex = selectedIndexBeforeArgumentForm;
277 selectedIndexBeforeArgumentForm = null;
278 }
279
280 selectedPrompt = null;
281 promptArgs = {};
282 promptError = null;
283 }
284
285 export function handleKeydown(event: KeyboardEvent): boolean {
286 if (!isOpen) return false;
287
288 if (event.key === KeyboardKey.ESCAPE) {
289 event.preventDefault();
290
291 if (selectedPrompt) {
292 // Return to prompt selection list, keeping the selected prompt active
293 handleCancelArgumentForm();
294 } else {
295 onClose?.();
296 }
297
298 return true;
299 }
300
301 if (event.key === KeyboardKey.ARROW_DOWN) {
302 event.preventDefault();
303
304 if (filteredPrompts.length > 0) {
305 selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
306 scrollTrigger++;
307 }
308
309 return true;
310 }
311
312 if (event.key === KeyboardKey.ARROW_UP) {
313 event.preventDefault();
314
315 if (filteredPrompts.length > 0) {
316 selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
317 scrollTrigger++;
318 }
319
320 return true;
321 }
322
323 if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
324 event.preventDefault();
325
326 if (filteredPrompts[selectedIndex]) {
327 handlePromptClick(filteredPrompts[selectedIndex]);
328 }
329
330 return true;
331 }
332
333 return false;
334 }
335
336 let filteredPrompts = $derived.by(() => {
337 const sortedServers = mcpStore.getServers();
338 const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
339 const sortedPrompts = [...prompts].sort((a, b) => {
340 const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
341 const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
342
343 return orderA - orderB;
344 });
345 const query = (searchQuery || internalSearchQuery).toLowerCase();
346
347 if (!query) return sortedPrompts;
348
349 return sortedPrompts.filter(
350 (prompt) =>
351 prompt.name.toLowerCase().includes(query) ||
352 prompt.title?.toLowerCase().includes(query) ||
353 prompt.description?.toLowerCase().includes(query)
354 );
355 });
356
357 let showSearchInput = $derived(prompts.length > 3);
358 </script>
359
360 <ChatFormPickerPopover
361 bind:isOpen
362 class={className}
363 srLabel="Open prompt picker"
364 {onClose}
365 onKeydown={handleKeydown}
366 >
367 {#if selectedPrompt}
368 {@const prompt = selectedPrompt}
369 {@const server = serverSettingsMap.get(prompt.serverName)}
370 {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
371
372 <div class="p-4">
373 <ChatFormPickerItemHeader
374 {server}
375 {serverLabel}
376 title={prompt.title || prompt.name}
377 description={prompt.description}
378 >
379 {#snippet titleExtra()}
380 {#if prompt.arguments?.length}
381 <Badge variant="secondary">
382 {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
383 </Badge>
384 {/if}
385 {/snippet}
386 </ChatFormPickerItemHeader>
387
388 <ChatFormPromptPickerArgumentForm
389 prompt={selectedPrompt}
390 {promptArgs}
391 {suggestions}
392 {loadingSuggestions}
393 {activeAutocomplete}
394 {autocompleteIndex}
395 {promptError}
396 onArgInput={handleArgInput}
397 onArgKeydown={handleArgKeydown}
398 onArgBlur={handleArgBlur}
399 onArgFocus={handleArgFocus}
400 onSelectSuggestion={selectSuggestion}
401 onSubmit={handleArgumentSubmit}
402 onCancel={handleCancelArgumentForm}
403 />
404 </div>
405 {:else}
406 <ChatFormPickerList
407 items={filteredPrompts}
408 {isLoading}
409 {selectedIndex}
410 bind:searchQuery={internalSearchQuery}
411 {showSearchInput}
412 searchPlaceholder="Search prompts..."
413 emptyMessage="No MCP prompts available"
414 itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
415 {scrollTrigger}
416 >
417 {#snippet item(prompt, index, isSelected)}
418 {@const server = serverSettingsMap.get(prompt.serverName)}
419 {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
420
421 <ChatFormPickerListItem
422 dataIndex={index}
423 {isSelected}
424 onclick={() => handlePromptClick(prompt)}
425 >
426 <ChatFormPickerItemHeader
427 {server}
428 {serverLabel}
429 title={prompt.title || prompt.name}
430 description={prompt.description}
431 >
432 {#snippet titleExtra()}
433 {#if prompt.arguments?.length}
434 <Badge variant="secondary">
435 {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
436 </Badge>
437 {/if}
438 {/snippet}
439 </ChatFormPickerItemHeader>
440 </ChatFormPickerListItem>
441 {/snippet}
442
443 {#snippet skeleton()}
444 <ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
445 {/snippet}
446 </ChatFormPickerList>
447 {/if}
448 </ChatFormPickerPopover>