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