refactor: Componentize Chat Form Prompt Picker
This commit is contained in:
parent
ff291d3560
commit
13014bc16f
|
|
@ -2,11 +2,13 @@
|
||||||
import { mcpClient } from '$lib/clients/mcp.client';
|
import { mcpClient } from '$lib/clients/mcp.client';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { getFaviconUrl, debounce } from '$lib/utils';
|
import { debounce } from '$lib/utils';
|
||||||
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
|
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { SearchInput } from '$lib/components/app';
|
import ChatFormPromptPickerList from './ChatFormPromptPickerList.svelte';
|
||||||
|
import ChatFormPromptPickerHeader from './ChatFormPromptPickerHeader.svelte';
|
||||||
|
import ChatFormPromptPickerArgumentForm from './ChatFormPromptPickerArgumentForm.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|
@ -237,6 +239,18 @@
|
||||||
}, 150);
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleArgFocus(argName: string) {
|
||||||
|
if ((suggestions[argName]?.length ?? 0) > 0) {
|
||||||
|
activeAutocomplete = argName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelArgumentForm() {
|
||||||
|
selectedPrompt = null;
|
||||||
|
promptArgs = {};
|
||||||
|
promptError = null;
|
||||||
|
}
|
||||||
|
|
||||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||||
if (!isOpen) return false;
|
if (!isOpen) return false;
|
||||||
|
|
||||||
|
|
@ -316,202 +330,39 @@
|
||||||
<div class="overflow-hidden rounded-xl border border-border/50 bg-popover shadow-xl">
|
<div class="overflow-hidden rounded-xl border border-border/50 bg-popover shadow-xl">
|
||||||
{#if selectedPrompt}
|
{#if selectedPrompt}
|
||||||
{@const server = serverSettingsMap.get(selectedPrompt.serverName)}
|
{@const server = serverSettingsMap.get(selectedPrompt.serverName)}
|
||||||
{@const faviconUrl = server ? getFaviconUrl(server.url) : null}
|
{@const serverLabel = server ? mcpStore.getServerLabel(server) : selectedPrompt.serverName}
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex items-start gap-3">
|
<ChatFormPromptPickerHeader prompt={selectedPrompt} {server} {serverLabel} />
|
||||||
{#if faviconUrl}
|
|
||||||
<img
|
|
||||||
src={faviconUrl}
|
|
||||||
alt=""
|
|
||||||
class="mt-0.5 h-5 w-5 shrink-0 rounded"
|
|
||||||
onerror={(e) => {
|
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<ChatFormPromptPickerArgumentForm
|
||||||
<div class="text-xs text-muted-foreground">
|
prompt={selectedPrompt}
|
||||||
{server ? mcpStore.getServerLabel(server) : selectedPrompt.serverName}
|
{promptArgs}
|
||||||
</div>
|
{suggestions}
|
||||||
|
{loadingSuggestions}
|
||||||
<div class="flex items-center gap-2">
|
{activeAutocomplete}
|
||||||
<span class="font-medium">
|
{autocompleteIndex}
|
||||||
{selectedPrompt.title || selectedPrompt.name}
|
{promptError}
|
||||||
</span>
|
onArgInput={handleArgInput}
|
||||||
|
onArgKeydown={handleArgKeydown}
|
||||||
{#if selectedPrompt.arguments?.length}
|
onArgBlur={handleArgBlur}
|
||||||
<span class="rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
onArgFocus={handleArgFocus}
|
||||||
{selectedPrompt.arguments.length} arg{selectedPrompt.arguments.length > 1
|
onSelectSuggestion={selectSuggestion}
|
||||||
? 's'
|
onSubmit={handleArgumentSubmit}
|
||||||
: ''}
|
onCancel={handleCancelArgumentForm}
|
||||||
</span>
|
/>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if selectedPrompt.description}
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
|
||||||
{selectedPrompt.description}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onsubmit={handleArgumentSubmit} class="space-y-3 pt-4">
|
|
||||||
{#each selectedPrompt.arguments ?? [] as arg (arg.name)}
|
|
||||||
<div class="relative grid gap-1">
|
|
||||||
<label
|
|
||||||
for="arg-{arg.name}"
|
|
||||||
class="mb-1 flex items-center gap-2 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{arg.name}
|
|
||||||
{#if arg.required}<span class="text-destructive">*</span>{/if}
|
|
||||||
</span>
|
|
||||||
{#if loadingSuggestions[arg.name]}
|
|
||||||
<span class="text-xs text-muted-foreground/50">...</span>
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
id="arg-{arg.name}"
|
|
||||||
type="text"
|
|
||||||
value={promptArgs[arg.name] ?? ''}
|
|
||||||
oninput={(e) => handleArgInput(arg.name, e.currentTarget.value)}
|
|
||||||
onkeydown={(e) => handleArgKeydown(e, arg.name)}
|
|
||||||
onblur={() => handleArgBlur(arg.name)}
|
|
||||||
onfocus={() => {
|
|
||||||
if ((suggestions[arg.name]?.length ?? 0) > 0) {
|
|
||||||
activeAutocomplete = arg.name;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={arg.description || arg.name}
|
|
||||||
required={arg.required}
|
|
||||||
autocomplete="off"
|
|
||||||
class="w-full rounded-lg border border-border/50 bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if activeAutocomplete === arg.name && (suggestions[arg.name]?.length ?? 0) > 0}
|
|
||||||
<div
|
|
||||||
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
|
|
||||||
transition:fly={{ y: -5, duration: 100 }}
|
|
||||||
>
|
|
||||||
{#each suggestions[arg.name] ?? [] as suggestion, i (suggestion)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onmousedown={() => selectSuggestion(arg.name, suggestion)}
|
|
||||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i ===
|
|
||||||
autocompleteIndex
|
|
||||||
? 'bg-accent'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if promptError}
|
|
||||||
<div
|
|
||||||
class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<span class="shrink-0">⚠</span>
|
|
||||||
<span>{promptError}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
selectedPrompt = null;
|
|
||||||
promptArgs = {};
|
|
||||||
promptError = null;
|
|
||||||
}}
|
|
||||||
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Use Prompt
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div>
|
<ChatFormPromptPickerList
|
||||||
{#if showSearchInput}
|
prompts={filteredPrompts}
|
||||||
<div class="p-2 pb-0">
|
{isLoading}
|
||||||
<SearchInput placeholder="Search prompts..." bind:value={internalSearchQuery} />
|
{selectedIndex}
|
||||||
</div>
|
bind:searchQuery={internalSearchQuery}
|
||||||
{/if}
|
{showSearchInput}
|
||||||
|
{serverSettingsMap}
|
||||||
<div class="max-h-64 overflow-y-auto p-2">
|
getServerLabel={(server) => mcpStore.getServerLabel(server)}
|
||||||
{#if isLoading}
|
onPromptClick={handlePromptClick}
|
||||||
<div class="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
/>
|
||||||
Loading prompts...
|
|
||||||
</div>
|
|
||||||
{:else if filteredPrompts.length === 0}
|
|
||||||
<div class="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
{prompts.length === 0 ? 'No MCP prompts available' : 'No prompts found'}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each filteredPrompts as prompt, index (prompt.serverName + ':' + prompt.name)}
|
|
||||||
{@const server = serverSettingsMap.get(prompt.serverName)}
|
|
||||||
{@const faviconUrl = server ? getFaviconUrl(server.url) : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => handlePromptClick(prompt)}
|
|
||||||
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent {index ===
|
|
||||||
selectedIndex
|
|
||||||
? 'bg-accent'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
{#if faviconUrl}
|
|
||||||
<img
|
|
||||||
src={faviconUrl}
|
|
||||||
alt=""
|
|
||||||
class="h-3 w-3 shrink-0 rounded-sm"
|
|
||||||
onerror={(e) => {
|
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span>{server ? mcpStore.getServerLabel(server) : prompt.serverName}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium">{prompt.title || prompt.name}</span>
|
|
||||||
|
|
||||||
{#if prompt.arguments && prompt.arguments.length > 0}
|
|
||||||
<span class="text-xs text-muted-foreground">
|
|
||||||
({prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''})
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if prompt.description}
|
|
||||||
<p class="truncate text-sm text-muted-foreground">
|
|
||||||
{prompt.description}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { MCPPromptInfo } from '$lib/types';
|
||||||
|
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prompt: MCPPromptInfo;
|
||||||
|
promptArgs: Record<string, string>;
|
||||||
|
suggestions: Record<string, string[]>;
|
||||||
|
loadingSuggestions: Record<string, boolean>;
|
||||||
|
activeAutocomplete: string | null;
|
||||||
|
autocompleteIndex: number;
|
||||||
|
promptError: string | null;
|
||||||
|
onArgInput: (argName: string, value: string) => void;
|
||||||
|
onArgKeydown: (event: KeyboardEvent, argName: string) => void;
|
||||||
|
onArgBlur: (argName: string) => void;
|
||||||
|
onArgFocus: (argName: string) => void;
|
||||||
|
onSelectSuggestion: (argName: string, value: string) => void;
|
||||||
|
onSubmit: (event: SubmitEvent) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
prompt,
|
||||||
|
promptArgs,
|
||||||
|
suggestions,
|
||||||
|
loadingSuggestions,
|
||||||
|
activeAutocomplete,
|
||||||
|
autocompleteIndex,
|
||||||
|
promptError,
|
||||||
|
onArgInput,
|
||||||
|
onArgKeydown,
|
||||||
|
onArgBlur,
|
||||||
|
onArgFocus,
|
||||||
|
onSelectSuggestion,
|
||||||
|
onSubmit,
|
||||||
|
onCancel
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={onSubmit} class="space-y-3 pt-4">
|
||||||
|
{#each prompt.arguments ?? [] as arg (arg.name)}
|
||||||
|
<ChatFormPromptPickerArgumentInput
|
||||||
|
argument={arg}
|
||||||
|
value={promptArgs[arg.name] ?? ''}
|
||||||
|
suggestions={suggestions[arg.name] ?? []}
|
||||||
|
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
|
||||||
|
isAutocompleteActive={activeAutocomplete === arg.name}
|
||||||
|
autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
|
||||||
|
onInput={(value) => onArgInput(arg.name, value)}
|
||||||
|
onKeydown={(e) => onArgKeydown(e, arg.name)}
|
||||||
|
onBlur={() => onArgBlur(arg.name)}
|
||||||
|
onFocus={() => onArgFocus(arg.name)}
|
||||||
|
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if promptError}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<span class="shrink-0">⚠</span>
|
||||||
|
<span>{promptError}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onCancel}
|
||||||
|
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Use Prompt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { MCPPromptInfo } from '$lib/types';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
|
type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
argument: PromptArgument;
|
||||||
|
value: string;
|
||||||
|
suggestions?: string[];
|
||||||
|
isLoadingSuggestions?: boolean;
|
||||||
|
isAutocompleteActive?: boolean;
|
||||||
|
autocompleteIndex?: number;
|
||||||
|
onInput: (value: string) => void;
|
||||||
|
onKeydown: (event: KeyboardEvent) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
onSelectSuggestion: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
argument,
|
||||||
|
value = '',
|
||||||
|
suggestions = [],
|
||||||
|
isLoadingSuggestions = false,
|
||||||
|
isAutocompleteActive = false,
|
||||||
|
autocompleteIndex = 0,
|
||||||
|
onInput,
|
||||||
|
onKeydown,
|
||||||
|
onBlur,
|
||||||
|
onFocus,
|
||||||
|
onSelectSuggestion
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative grid gap-1">
|
||||||
|
<label
|
||||||
|
for="arg-{argument.name}"
|
||||||
|
class="mb-1 flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{argument.name}
|
||||||
|
|
||||||
|
{#if argument.required}
|
||||||
|
<span class="text-destructive">*</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if isLoadingSuggestions}
|
||||||
|
<span class="text-xs text-muted-foreground/50">...</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="arg-{argument.name}"
|
||||||
|
type="text"
|
||||||
|
{value}
|
||||||
|
oninput={(e) => onInput(e.currentTarget.value)}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
onblur={onBlur}
|
||||||
|
onfocus={onFocus}
|
||||||
|
placeholder={argument.description || argument.name}
|
||||||
|
required={argument.required}
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full rounded-lg border border-border/50 bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if isAutocompleteActive && suggestions.length > 0}
|
||||||
|
<div
|
||||||
|
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
|
||||||
|
transition:fly={{ y: -5, duration: 100 }}
|
||||||
|
>
|
||||||
|
{#each suggestions as suggestion, i (suggestion)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onmousedown={() => onSelectSuggestion(suggestion)}
|
||||||
|
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
|
||||||
|
? 'bg-accent'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||||
|
import { getFaviconUrl } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prompt: MCPPromptInfo;
|
||||||
|
server: MCPServerSettingsEntry | undefined;
|
||||||
|
serverLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { prompt, server, serverLabel }: Props = $props();
|
||||||
|
|
||||||
|
let faviconUrl = $derived(server ? getFaviconUrl(server.url) : null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
{#if faviconUrl}
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
class="mt-0.5 h-5 w-5 shrink-0 rounded"
|
||||||
|
onerror={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{serverLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium">
|
||||||
|
{prompt.title || prompt.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if prompt.arguments?.length}
|
||||||
|
<span class="rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||||
|
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if prompt.description}
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
{prompt.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||||
|
import { SearchInput } from '$lib/components/app';
|
||||||
|
import ChatFormPromptPickerListItem from './ChatFormPromptPickerListItem.svelte';
|
||||||
|
import ChatFormPromptPickerListItemSkeleton from './ChatFormPromptPickerListItemSkeleton.svelte';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prompts: MCPPromptInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
selectedIndex: number;
|
||||||
|
searchQuery: string;
|
||||||
|
showSearchInput: boolean;
|
||||||
|
serverSettingsMap: SvelteMap<string, MCPServerSettingsEntry>;
|
||||||
|
getServerLabel: (server: MCPServerSettingsEntry) => string;
|
||||||
|
onPromptClick: (prompt: MCPPromptInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
prompts,
|
||||||
|
isLoading,
|
||||||
|
selectedIndex,
|
||||||
|
searchQuery = $bindable(),
|
||||||
|
showSearchInput,
|
||||||
|
serverSettingsMap,
|
||||||
|
getServerLabel,
|
||||||
|
onPromptClick
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if showSearchInput}
|
||||||
|
<div class="p-2 pb-0">
|
||||||
|
<SearchInput placeholder="Search prompts..." bind:value={searchQuery} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="max-h-64 overflow-y-auto p-2">
|
||||||
|
{#if isLoading}
|
||||||
|
<ChatFormPromptPickerListItemSkeleton />
|
||||||
|
{:else if prompts.length === 0}
|
||||||
|
<div class="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{prompts.length === 0 ? 'No MCP prompts available' : 'No prompts found'}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each prompts as prompt, index (prompt.serverName + ':' + prompt.name)}
|
||||||
|
{@const server = serverSettingsMap.get(prompt.serverName)}
|
||||||
|
{@const serverLabel = server ? getServerLabel(server) : prompt.serverName}
|
||||||
|
|
||||||
|
<ChatFormPromptPickerListItem
|
||||||
|
{prompt}
|
||||||
|
{server}
|
||||||
|
{serverLabel}
|
||||||
|
isSelected={index === selectedIndex}
|
||||||
|
onClick={() => onPromptClick(prompt)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||||
|
import { getFaviconUrl } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prompt: MCPPromptInfo;
|
||||||
|
server: MCPServerSettingsEntry | undefined;
|
||||||
|
serverLabel: string;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { prompt, server, serverLabel, isSelected = false, onClick }: Props = $props();
|
||||||
|
|
||||||
|
let faviconUrl = $derived(server ? getFaviconUrl(server.url) : null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClick}
|
||||||
|
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent {isSelected
|
||||||
|
? 'bg-accent'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{#if faviconUrl}
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
class="h-3 w-3 shrink-0 rounded-sm"
|
||||||
|
onerror={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span>{serverLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium">{prompt.title || prompt.name}</span>
|
||||||
|
|
||||||
|
{#if prompt.arguments && prompt.arguments.length > 0}
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
({prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''})
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if prompt.description}
|
||||||
|
<p class="truncate text-sm text-muted-foreground">
|
||||||
|
{prompt.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
|
||||||
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
|
<!-- Server label skeleton -->
|
||||||
|
<div class="mb-2 flex items-center gap-1.5">
|
||||||
|
<div class="h-3 w-3 shrink-0 animate-pulse rounded-sm bg-muted"></div>
|
||||||
|
<div class="h-3 w-24 animate-pulse rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title skeleton -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-4 w-32 animate-pulse rounded bg-muted"></div>
|
||||||
|
<div class="h-4 w-12 animate-pulse rounded-full bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description skeleton -->
|
||||||
|
<div class="h-3 w-full animate-pulse rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -14,7 +14,13 @@ export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/Chat
|
||||||
export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
|
export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
|
||||||
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
|
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
|
||||||
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
|
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
|
||||||
export { default as ChatFormPromptPicker } from './chat/ChatForm/ChatFormPromptPicker.svelte';
|
export { default as ChatFormPromptPicker } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte';
|
||||||
|
export { default as ChatFormPromptPickerArgumentForm } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentForm.svelte';
|
||||||
|
export { default as ChatFormPromptPickerArgumentInput } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentInput.svelte';
|
||||||
|
export { default as ChatFormPromptPickerHeader } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerHeader.svelte';
|
||||||
|
export { default as ChatFormPromptPickerList } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerList.svelte';
|
||||||
|
export { default as ChatFormPromptPickerListItem } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerListItem.svelte';
|
||||||
|
export { default as ChatFormPromptPickerListItemSkeleton } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerListItemSkeleton.svelte';
|
||||||
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
|
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
|
||||||
|
|
||||||
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
|
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue