feat: MCP Prompts WIP
This commit is contained in:
parent
3d88d0b6b2
commit
8428741034
|
|
@ -25,6 +25,8 @@ import { toast } from 'svelte-sonner';
|
|||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
||||
import { getEnabledServersForConversation } from '$lib/utils/mcp';
|
||||
import { mcpClient } from '$lib/clients/mcp.client';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
|
||||
interface ConversationsStoreStateCallbacks {
|
||||
|
|
@ -159,6 +161,9 @@ export class ConversationsClient {
|
|||
this.store.setActiveMessages(messages);
|
||||
}
|
||||
|
||||
// Run MCP health checks for enabled servers in this conversation
|
||||
this.runMcpHealthChecksForConversation(conversation.mcpServerOverrides);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversation:', error);
|
||||
|
|
@ -166,6 +171,32 @@ export class ConversationsClient {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs MCP health checks for servers enabled in a conversation.
|
||||
* Runs asynchronously in the background without blocking conversation loading.
|
||||
* @param mcpServerOverrides - The conversation's MCP server overrides
|
||||
*/
|
||||
private runMcpHealthChecksForConversation(mcpServerOverrides?: McpServerOverride[]): void {
|
||||
if (!mcpServerOverrides?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledServers = getEnabledServersForConversation(config(), mcpServerOverrides);
|
||||
|
||||
if (enabledServers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ConversationsClient] Running health checks for ${enabledServers.length} MCP server(s)`
|
||||
);
|
||||
|
||||
// Run health checks in background (don't await)
|
||||
mcpClient.runHealthChecksForServers(enabledServers).catch((error) => {
|
||||
console.warn('[ConversationsClient] MCP health checks failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
|
|
|
|||
|
|
@ -40,11 +40,12 @@ import type {
|
|||
ClientCapabilities,
|
||||
MCPCapabilitiesInfo,
|
||||
MCPConnectionLog,
|
||||
MCPPromptInfo,
|
||||
GetPromptResult,
|
||||
Tool,
|
||||
Prompt
|
||||
} from '$lib/types';
|
||||
import type {
|
||||
ListChangedHandlers,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { MCPError } from '$lib/errors';
|
||||
|
|
@ -168,7 +169,7 @@ export class MCPClient {
|
|||
clientInfo,
|
||||
capabilities,
|
||||
undefined,
|
||||
listChangedHandlers,
|
||||
listChangedHandlers
|
||||
);
|
||||
|
||||
return { name, connection };
|
||||
|
|
@ -253,6 +254,18 @@ export class MCPClient {
|
|||
this.handleToolsListChanged(serverName, tools ?? []);
|
||||
}
|
||||
},
|
||||
prompts: {
|
||||
onChanged: (error: Error | null, prompts: Prompt[] | null) => {
|
||||
if (error) {
|
||||
console.warn(`[MCPClient][${serverName}] Prompts list changed error:`, error);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[MCPClient][${serverName}] Prompts list changed, ${prompts?.length ?? 0} prompts`
|
||||
);
|
||||
this.handlePromptsListChanged(serverName);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -292,6 +305,18 @@ export class MCPClient {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle prompts list changed notification from a server.
|
||||
* Triggers a refresh of the prompts cache if needed.
|
||||
*/
|
||||
private handlePromptsListChanged(serverName: string): void {
|
||||
// Prompts are fetched on-demand, so we just log the change
|
||||
// The UI will get fresh prompts on next getAllPrompts() call
|
||||
console.log(
|
||||
`[MCPClient][${serverName}] Prompts list updated - will be refreshed on next fetch`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a reference to MCP connections for an agentic flow.
|
||||
* Call this when starting an agentic flow to prevent premature shutdown.
|
||||
|
|
@ -489,6 +514,77 @@ export class MCPClient {
|
|||
return this.toolsIndex.get(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Prompts
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get all prompts from all connected servers that support prompts.
|
||||
*/
|
||||
async getAllPrompts(): Promise<MCPPromptInfo[]> {
|
||||
const results: MCPPromptInfo[] = [];
|
||||
|
||||
for (const [serverName, connection] of this.connections) {
|
||||
if (!connection.serverCapabilities?.prompts) continue;
|
||||
|
||||
const prompts = await MCPService.listPrompts(connection);
|
||||
for (const prompt of prompts) {
|
||||
results.push({
|
||||
name: prompt.name,
|
||||
description: prompt.description,
|
||||
title: prompt.title,
|
||||
serverName,
|
||||
arguments: prompt.arguments?.map((arg) => ({
|
||||
name: arg.name,
|
||||
description: arg.description,
|
||||
required: arg.required
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a prompt by name from a specific server.
|
||||
* Returns the prompt messages ready to be used in chat.
|
||||
* Throws an error if the server is not found or prompt execution fails.
|
||||
*/
|
||||
async getPrompt(
|
||||
serverName: string,
|
||||
promptName: string,
|
||||
args?: Record<string, string>
|
||||
): Promise<GetPromptResult> {
|
||||
const connection = this.connections.get(serverName);
|
||||
|
||||
if (!connection) {
|
||||
const errorMsg = `Server "${serverName}" not found for prompt "${promptName}"`;
|
||||
console.error(`[MCPClient] ${errorMsg}`);
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return MCPService.getPrompt(connection, promptName, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any connected server supports prompts.
|
||||
*/
|
||||
hasPromptsSupport(): boolean {
|
||||
for (const connection of this.connections.values()) {
|
||||
if (connection.serverCapabilities?.prompts) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
|
|
@ -583,6 +679,47 @@ export class MCPClient {
|
|||
throw new MCPError(`Invalid tool arguments type: ${typeof args}`, -32602);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Completions
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get completion suggestions for a prompt argument.
|
||||
* Used for autocompleting prompt argument values.
|
||||
*
|
||||
* @param serverName - Name of the server hosting the prompt
|
||||
* @param promptName - Name of the prompt
|
||||
* @param argumentName - Name of the argument being completed
|
||||
* @param argumentValue - Current partial value of the argument
|
||||
* @returns Completion suggestions or null if not supported/error
|
||||
*/
|
||||
async getPromptCompletions(
|
||||
serverName: string,
|
||||
promptName: string,
|
||||
argumentName: string,
|
||||
argumentValue: string
|
||||
): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
|
||||
const connection = this.connections.get(serverName);
|
||||
if (!connection) {
|
||||
console.warn(`[MCPClient] Server "${serverName}" is not connected`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!connection.serverCapabilities?.completions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MCPService.complete(
|
||||
connection,
|
||||
{ type: 'ref/prompt', name: promptName },
|
||||
{ name: argumentName, value: argumentValue }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
<script lang="ts">
|
||||
import { ChevronDown, ChevronUp, Eye, Pencil, Check, X } from '@lucide/svelte';
|
||||
import type { DatabaseMessageExtraMcpPrompt, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { getFaviconUrl, getMcpServerLabel } from '$lib/utils/mcp';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { Card } from '$lib/components/ui/card';
|
||||
import { MarkdownContent } from '$lib/components/app';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
prompt: DatabaseMessageExtraMcpPrompt;
|
||||
readonly?: boolean;
|
||||
isLoading?: boolean;
|
||||
loadError?: string;
|
||||
isEditingAllowed?: boolean;
|
||||
onRemove?: () => void;
|
||||
onArgumentsChange?: (args: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
prompt,
|
||||
readonly = false,
|
||||
isLoading = false,
|
||||
loadError,
|
||||
isEditingAllowed = false,
|
||||
onRemove,
|
||||
onArgumentsChange
|
||||
}: Props = $props();
|
||||
|
||||
let showArguments = $state(false);
|
||||
let showContent = $state(false);
|
||||
let isEditingArguments = $state(false);
|
||||
let editedArguments = $state<Record<string, string>>({});
|
||||
let hasArguments = $derived(prompt.arguments && Object.keys(prompt.arguments).length > 0);
|
||||
let hasContent = $derived(prompt.content && prompt.content.trim().length > 0);
|
||||
|
||||
const currentConfig = config();
|
||||
|
||||
let serverSettingsMap = $derived.by(() => {
|
||||
const servers = mcpStore.getServers();
|
||||
const map = new SvelteMap<string, MCPServerSettingsEntry>();
|
||||
|
||||
for (const server of servers) {
|
||||
map.set(server.id, server);
|
||||
}
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
function getServerFavicon(): string | null {
|
||||
const server = serverSettingsMap.get(prompt.serverName);
|
||||
return server ? getFaviconUrl(server.url) : null;
|
||||
}
|
||||
|
||||
function getServerDisplayName(): string {
|
||||
const server = serverSettingsMap.get(prompt.serverName);
|
||||
if (!server) return prompt.serverName;
|
||||
|
||||
const healthState = mcpStore.getHealthCheckState(server.id);
|
||||
return getMcpServerLabel(server, healthState);
|
||||
}
|
||||
|
||||
function toggleArguments(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
showArguments = !showArguments;
|
||||
}
|
||||
|
||||
function toggleContent(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
showContent = !showContent;
|
||||
}
|
||||
|
||||
function startEditingArguments(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
editedArguments = { ...(prompt.arguments ?? {}) };
|
||||
isEditingArguments = true;
|
||||
showArguments = true;
|
||||
}
|
||||
|
||||
function saveArguments(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
onArgumentsChange?.(editedArguments);
|
||||
isEditingArguments = false;
|
||||
}
|
||||
|
||||
function cancelEditingArguments(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
isEditingArguments = false;
|
||||
editedArguments = {};
|
||||
}
|
||||
|
||||
function updateArgument(key: string, value: string) {
|
||||
editedArguments = { ...editedArguments, [key]: value };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col {className}">
|
||||
<div
|
||||
class="group relative flex flex-col overflow-hidden rounded-lg border bg-gradient-to-br {loadError
|
||||
? 'border-destructive/50 from-destructive/10 to-destructive/5 dark:border-destructive/30 dark:from-destructive/20 dark:to-destructive/10'
|
||||
: 'border-purple-200 from-purple-50 to-indigo-50 dark:border-purple-800 dark:from-purple-950/50 dark:to-indigo-950/50'}"
|
||||
>
|
||||
<div class="flex items-start gap-2 p-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center gap-1.5 text-xs text-purple-600 dark:text-purple-400">
|
||||
{#if getServerFavicon()}
|
||||
<img
|
||||
src={getServerFavicon()}
|
||||
alt=""
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span>{getServerDisplayName()}</span>
|
||||
</div>
|
||||
|
||||
<div class="font-medium text-foreground">
|
||||
{prompt.name}
|
||||
|
||||
{#if isLoading}
|
||||
<span class="ml-1 text-xs font-normal text-muted-foreground">Loading...</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<p class="mt-1 text-xs text-destructive">{loadError}</p>
|
||||
{/if}
|
||||
|
||||
{#if hasContent}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleContent}
|
||||
class="mt-1 flex items-center gap-1 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
|
||||
>
|
||||
{#if showContent}
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
<span>Hide content</span>
|
||||
{:else}
|
||||
<Eye class="h-3 w-3" />
|
||||
<span>Show content</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if hasArguments}
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleArguments}
|
||||
class="flex items-center gap-1 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
|
||||
>
|
||||
{#if showArguments}
|
||||
<ChevronUp class="h-3 w-3" />
|
||||
<span>Hide arguments</span>
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
<span>Show arguments ({Object.keys(prompt.arguments ?? {}).length})</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isEditingAllowed && !isEditingArguments && onArgumentsChange}
|
||||
<button
|
||||
type="button"
|
||||
onclick={startEditingArguments}
|
||||
class="flex items-center gap-1 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
|
||||
aria-label="Edit arguments"
|
||||
>
|
||||
<Pencil class="h-3 w-3" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !readonly && onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.();
|
||||
}}
|
||||
class="hover:text-destructive-foreground absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-background/80 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive"
|
||||
aria-label="Remove prompt"
|
||||
>
|
||||
<span class="text-xs">×</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showArguments && hasArguments}
|
||||
<div
|
||||
class="border-t border-purple-200 bg-purple-50/50 px-3 py-2 dark:border-purple-800 dark:bg-purple-950/30"
|
||||
>
|
||||
{#if isEditingArguments}
|
||||
<div class="space-y-2">
|
||||
{#each Object.entries(editedArguments) as [key, value] (key)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label
|
||||
for="arg-{key}"
|
||||
class="text-xs font-medium text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
{key}
|
||||
</label>
|
||||
<input
|
||||
id="arg-{key}"
|
||||
type="text"
|
||||
{value}
|
||||
oninput={(e) => updateArgument(key, e.currentTarget.value)}
|
||||
class="w-full rounded border border-purple-300 bg-white px-2 py-1 text-xs text-foreground outline-none focus:border-purple-500 dark:border-purple-700 dark:bg-purple-950/50"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEditingArguments}
|
||||
class="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-purple-100 dark:hover:bg-purple-900/50"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveArguments}
|
||||
class="flex items-center gap-1 rounded bg-purple-500 px-2 py-1 text-xs text-white hover:bg-purple-600"
|
||||
>
|
||||
<Check class="h-3 w-3" />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each Object.entries(prompt.arguments ?? {}) as [key, value] (key)}
|
||||
<div class="flex gap-2 text-xs">
|
||||
<span class="shrink-0 font-medium text-purple-600 dark:text-purple-400">{key}:</span
|
||||
>
|
||||
<span class="truncate text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showContent && hasContent}
|
||||
<Card
|
||||
class="mt-2 max-h-64 overflow-y-auto rounded-[1.125rem] border-none bg-purple-500/10 px-3.75 py-2.5 text-foreground backdrop-blur-md dark:bg-purple-500/20"
|
||||
style="overflow-wrap: anywhere; word-break: break-word;"
|
||||
>
|
||||
{#if currentConfig.renderUserContentAsMarkdown}
|
||||
<div class="text-sm">
|
||||
<MarkdownContent class="markdown-user-content text-foreground" content={prompt.content} />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-sm whitespace-pre-wrap">
|
||||
{prompt.content}
|
||||
</span>
|
||||
{/if}
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
|
||||
import {
|
||||
ChatAttachmentMcpPrompt,
|
||||
ChatAttachmentThumbnailImage,
|
||||
ChatAttachmentThumbnailFile
|
||||
} from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
|
||||
import { getAttachmentDisplayItems } from '$lib/utils';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -22,6 +28,9 @@
|
|||
limitToSingleRow?: boolean;
|
||||
// For vision modality check
|
||||
activeModelId?: string;
|
||||
// For MCP prompt argument editing
|
||||
isEditingAllowed?: boolean;
|
||||
onMcpPromptArgumentsChange?: (fileId: string, args: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -36,7 +45,9 @@
|
|||
imageHeight = 'h-24',
|
||||
imageWidth = 'w-auto',
|
||||
limitToSingleRow = false,
|
||||
activeModelId
|
||||
activeModelId,
|
||||
isEditingAllowed = false,
|
||||
onMcpPromptArgumentsChange
|
||||
}: Props = $props();
|
||||
|
||||
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
|
||||
|
|
@ -124,7 +135,37 @@
|
|||
onscroll={updateScrollButtons}
|
||||
>
|
||||
{#each displayItems as item (item.id)}
|
||||
{#if item.isImage && item.preview}
|
||||
{#if item.isMcpPrompt}
|
||||
{@const mcpPrompt =
|
||||
item.attachment?.type === AttachmentType.MCP_PROMPT
|
||||
? (item.attachment as DatabaseMessageExtraMcpPrompt)
|
||||
: item.uploadedFile?.mcpPrompt
|
||||
? {
|
||||
type: AttachmentType.MCP_PROMPT as const,
|
||||
name: item.name,
|
||||
serverName: item.uploadedFile.mcpPrompt.serverName,
|
||||
promptName: item.uploadedFile.mcpPrompt.promptName,
|
||||
content: item.textContent ?? '',
|
||||
arguments: item.uploadedFile.mcpPrompt.arguments
|
||||
}
|
||||
: null}
|
||||
{#if mcpPrompt}
|
||||
<ChatAttachmentMcpPrompt
|
||||
class="max-w-[300px] min-w-[200px] flex-shrink-0 {limitToSingleRow
|
||||
? 'first:ml-4 last:mr-4'
|
||||
: ''}"
|
||||
prompt={mcpPrompt}
|
||||
{readonly}
|
||||
isLoading={item.isLoading}
|
||||
loadError={item.loadError}
|
||||
{isEditingAllowed}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
onArgumentsChange={onMcpPromptArgumentsChange
|
||||
? (args) => onMcpPromptArgumentsChange(item.id, args)
|
||||
: undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow
|
||||
? 'first:ml-4 last:mr-4'
|
||||
|
|
@ -185,7 +226,35 @@
|
|||
{:else}
|
||||
<div class="flex flex-wrap items-start justify-end gap-3">
|
||||
{#each displayItems as item (item.id)}
|
||||
{#if item.isImage && item.preview}
|
||||
{#if item.isMcpPrompt}
|
||||
{@const mcpPrompt =
|
||||
item.attachment?.type === AttachmentType.MCP_PROMPT
|
||||
? (item.attachment as DatabaseMessageExtraMcpPrompt)
|
||||
: item.uploadedFile?.mcpPrompt
|
||||
? {
|
||||
type: AttachmentType.MCP_PROMPT as const,
|
||||
name: item.name,
|
||||
serverName: item.uploadedFile.mcpPrompt.serverName,
|
||||
promptName: item.uploadedFile.mcpPrompt.promptName,
|
||||
content: item.textContent ?? '',
|
||||
arguments: item.uploadedFile.mcpPrompt.arguments
|
||||
}
|
||||
: null}
|
||||
{#if mcpPrompt}
|
||||
<ChatAttachmentMcpPrompt
|
||||
class="max-w-[300px] min-w-[200px]"
|
||||
prompt={mcpPrompt}
|
||||
{readonly}
|
||||
isLoading={item.isLoading}
|
||||
loadError={item.loadError}
|
||||
{isEditingAllowed}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
onArgumentsChange={onMcpPromptArgumentsChange
|
||||
? (args) => onMcpPromptArgumentsChange(item.id, args)
|
||||
: undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="cursor-pointer"
|
||||
id={item.id}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import {
|
||||
ChatAttachmentsList,
|
||||
ChatFormActions,
|
||||
ChatFormFileInputInvisible,
|
||||
ChatFormHelperText,
|
||||
ChatFormTextarea
|
||||
} from '$lib/components/app';
|
||||
import { INPUT_CLASSES } from '$lib/constants/css-classes';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { MimeTypeText } from '$lib/enums';
|
||||
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
|
||||
import {
|
||||
AudioRecorder,
|
||||
convertToWav,
|
||||
createAudioFile,
|
||||
isAudioRecordingSupported
|
||||
} from '$lib/utils/browser-only';
|
||||
import { ChatFormHelperText, ChatFormInputArea } from '$lib/components/app';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -50,190 +29,22 @@
|
|||
uploadedFiles = $bindable([])
|
||||
}: Props = $props();
|
||||
|
||||
let audioRecorder: AudioRecorder | undefined;
|
||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||
let currentConfig = $derived(config());
|
||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
||||
let isRecording = $state(false);
|
||||
let inputAreaRef: ChatFormInputArea | undefined = $state(undefined);
|
||||
let message = $state('');
|
||||
let pasteLongTextToFileLength = $derived.by(() => {
|
||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
||||
});
|
||||
let previousIsLoading = $state(isLoading);
|
||||
let recordingSupported = $state(false);
|
||||
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
|
||||
|
||||
// Check if model is selected (in ROUTER mode)
|
||||
let conversationModel = $derived(
|
||||
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
|
||||
);
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
|
||||
|
||||
// Get active model ID for capability detection
|
||||
let activeModelId = $derived.by(() => {
|
||||
const options = modelOptions();
|
||||
|
||||
if (!isRouter) {
|
||||
return options.length > 0 ? options[0].model : null;
|
||||
}
|
||||
|
||||
// First try user-selected model
|
||||
const selectedId = selectedModelId();
|
||||
if (selectedId) {
|
||||
const model = options.find((m) => m.id === selectedId);
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
// Fallback to conversation model
|
||||
if (conversationModel) {
|
||||
const model = options.find((m) => m.model === conversationModel);
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
function checkModelSelected(): boolean {
|
||||
if (!hasModelSelected) {
|
||||
// Open the model selector
|
||||
chatFormActionsRef?.openModelSelector();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleFileSelect(files: File[]) {
|
||||
onFileUpload?.(files);
|
||||
}
|
||||
|
||||
function handleFileUpload() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
|
||||
async function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
|
||||
event.preventDefault();
|
||||
|
||||
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
|
||||
|
||||
if (!checkModelSelected()) return;
|
||||
|
||||
const messageToSend = message.trim();
|
||||
const filesToSend = [...uploadedFiles];
|
||||
|
||||
message = '';
|
||||
uploadedFiles = [];
|
||||
|
||||
textareaRef?.resetHeight();
|
||||
|
||||
const success = await onSend?.(messageToSend, filesToSend);
|
||||
|
||||
if (!success) {
|
||||
message = messageToSend;
|
||||
uploadedFiles = filesToSend;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
if (!event.clipboardData) return;
|
||||
|
||||
const files = Array.from(event.clipboardData.items)
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onFileUpload?.(files);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
|
||||
|
||||
if (text.startsWith('"')) {
|
||||
const parsed = parseClipboardContent(text);
|
||||
|
||||
if (parsed.textAttachments.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
message = parsed.message;
|
||||
|
||||
const attachmentFiles = parsed.textAttachments.map(
|
||||
(att) =>
|
||||
new File([att.content], att.name, {
|
||||
type: MimeTypeText.PLAIN
|
||||
})
|
||||
);
|
||||
|
||||
onFileUpload?.(attachmentFiles);
|
||||
|
||||
setTimeout(() => {
|
||||
textareaRef?.focus();
|
||||
}, 10);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (
|
||||
text.length > 0 &&
|
||||
pasteLongTextToFileLength > 0 &&
|
||||
text.length > pasteLongTextToFileLength
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
const textFile = new File([text], 'Pasted', {
|
||||
type: MimeTypeText.PLAIN
|
||||
});
|
||||
|
||||
onFileUpload?.([textFile]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMicClick() {
|
||||
if (!audioRecorder || !recordingSupported) {
|
||||
console.warn('Audio recording not supported');
|
||||
|
||||
(!message.trim() && uploadedFiles.length === 0) ||
|
||||
disabled ||
|
||||
isLoading ||
|
||||
hasLoadingAttachments
|
||||
)
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
try {
|
||||
const audioBlob = await audioRecorder.stopRecording();
|
||||
const wavBlob = await convertToWav(audioBlob);
|
||||
const audioFile = createAudioFile(wavBlob);
|
||||
|
||||
onFileUpload?.([audioFile]);
|
||||
isRecording = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to stop recording:', error);
|
||||
isRecording = false;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await audioRecorder.startRecording();
|
||||
isRecording = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
onStop?.();
|
||||
}
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
|
||||
|
||||
// Check if model is selected first
|
||||
if (!checkModelSelected()) return;
|
||||
if (!inputAreaRef?.checkModelSelected()) return;
|
||||
|
||||
const messageToSend = message.trim();
|
||||
const filesToSend = [...uploadedFiles];
|
||||
|
|
@ -241,7 +52,7 @@
|
|||
message = '';
|
||||
uploadedFiles = [];
|
||||
|
||||
textareaRef?.resetHeight();
|
||||
inputAreaRef?.resetHeight();
|
||||
|
||||
const success = await onSend?.(messageToSend, filesToSend);
|
||||
|
||||
|
|
@ -251,68 +62,46 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleFilesAdd(files: File[]) {
|
||||
onFileUpload?.(files);
|
||||
}
|
||||
|
||||
function handleUploadedFileRemove(fileId: string) {
|
||||
onFileRemove?.(fileId);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(() => textareaRef?.focus(), 10);
|
||||
recordingSupported = isAudioRecordingSupported();
|
||||
audioRecorder = new AudioRecorder();
|
||||
setTimeout(() => inputAreaRef?.focus(), 10);
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
setTimeout(() => textareaRef?.focus(), 10);
|
||||
setTimeout(() => inputAreaRef?.focus(), 10);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (previousIsLoading && !isLoading) {
|
||||
setTimeout(() => textareaRef?.focus(), 10);
|
||||
setTimeout(() => inputAreaRef?.focus(), 10);
|
||||
}
|
||||
|
||||
previousIsLoading = isLoading;
|
||||
});
|
||||
</script>
|
||||
|
||||
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
|
||||
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: ''} {className}"
|
||||
data-slot="chat-form"
|
||||
>
|
||||
<ChatAttachmentsList
|
||||
<div class="relative mx-auto max-w-[48rem]">
|
||||
<ChatFormInputArea
|
||||
bind:this={inputAreaRef}
|
||||
bind:value={message}
|
||||
bind:uploadedFiles
|
||||
{onFileRemove}
|
||||
limitToSingleRow
|
||||
class="py-5"
|
||||
style="scroll-padding: 1rem;"
|
||||
activeModelId={activeModelId ?? undefined}
|
||||
class={className}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
showMcpPromptButton={true}
|
||||
onFilesAdd={handleFilesAdd}
|
||||
{onStop}
|
||||
onSubmit={handleSubmit}
|
||||
onSystemPromptClick={onSystemPromptAdd}
|
||||
onUploadedFileRemove={handleUploadedFileRemove}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex-column relative min-h-[48px] items-center rounded-3xl p-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!p-3"
|
||||
onpaste={handlePaste}
|
||||
>
|
||||
<ChatFormTextarea
|
||||
class="px-2 py-1 md:py-0"
|
||||
bind:value={message}
|
||||
onKeydown={handleKeydown}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<ChatFormActions
|
||||
bind:this={chatFormActionsRef}
|
||||
canSend={message.trim().length > 0 || uploadedFiles.length > 0}
|
||||
hasText={message.trim().length > 0}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
{isRecording}
|
||||
{uploadedFiles}
|
||||
onFileUpload={handleFileUpload}
|
||||
onMicClick={handleMicClick}
|
||||
onStop={handleStop}
|
||||
onSystemPromptClick={onSystemPromptAdd}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ChatFormHelperText show={showHelperText} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { Plus, MessageSquare } from '@lucide/svelte';
|
||||
import { Plus, MessageSquare, Zap } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
|
@ -11,8 +11,10 @@
|
|||
disabled?: boolean;
|
||||
hasAudioModality?: boolean;
|
||||
hasVisionModality?: boolean;
|
||||
hasMcpPromptsSupport?: boolean;
|
||||
onFileUpload?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
onMcpServersClick?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -21,13 +23,20 @@
|
|||
disabled = false,
|
||||
hasAudioModality = false,
|
||||
hasVisionModality = false,
|
||||
hasMcpPromptsSupport = false,
|
||||
onFileUpload,
|
||||
onSystemPromptClick,
|
||||
onMcpPromptClick,
|
||||
onMcpServersClick
|
||||
}: Props = $props();
|
||||
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
function handleMcpPromptClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpPromptClick?.();
|
||||
}
|
||||
|
||||
function handleMcpServersClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpServersClick?.();
|
||||
|
|
@ -137,7 +146,7 @@
|
|||
>
|
||||
<MessageSquare class="h-4 w-4" />
|
||||
|
||||
<span>System Prompt</span>
|
||||
<span>System Message</span>
|
||||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
|
|
@ -146,6 +155,25 @@
|
|||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
{#if hasMcpPromptsSupport}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpPromptClick}
|
||||
>
|
||||
<Zap class="h-4 w-4" />
|
||||
|
||||
<span>MCP Prompt</span>
|
||||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content usePortal={false}>
|
||||
<p>Insert a prompt from an MCP server</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@
|
|||
McpActiveServersAvatars,
|
||||
ModelsSelector
|
||||
} from '$lib/components/app';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { FileTypeCategory } from '$lib/enums';
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
|
||||
interface Props {
|
||||
canSend?: boolean;
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
onMicClick?: () => void;
|
||||
onStop?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -42,7 +44,8 @@
|
|||
onFileUpload,
|
||||
onMicClick,
|
||||
onStop,
|
||||
onSystemPromptClick
|
||||
onSystemPromptClick,
|
||||
onMcpPromptClick
|
||||
}: Props = $props();
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
|
|
@ -157,6 +160,11 @@
|
|||
}
|
||||
|
||||
let showMcpDialog = $state(false);
|
||||
|
||||
let hasMcpPromptsSupport = $derived.by(() => {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
return mcpStore.hasEnabledServers(perChatOverrides);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
|
||||
|
|
@ -165,8 +173,10 @@
|
|||
{disabled}
|
||||
{hasAudioModality}
|
||||
{hasVisionModality}
|
||||
{hasMcpPromptsSupport}
|
||||
{onFileUpload}
|
||||
{onSystemPromptClick}
|
||||
{onMcpPromptClick}
|
||||
onMcpServersClick={() => (showMcpDialog = true)}
|
||||
/>
|
||||
|
||||
|
|
@ -187,9 +197,10 @@
|
|||
<Button
|
||||
type="button"
|
||||
onclick={onStop}
|
||||
class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
|
||||
class="h-8 w-8 rounded-full bg-transparent p-0 hover:bg-destructive/20"
|
||||
>
|
||||
<span class="sr-only">Stop</span>
|
||||
|
||||
<Square class="h-8 w-8 fill-destructive stroke-destructive" />
|
||||
</Button>
|
||||
{:else if shouldShowRecordButton}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,423 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ChatAttachmentsList,
|
||||
ChatFormActions,
|
||||
ChatFormFileInputInvisible,
|
||||
ChatFormPromptPicker,
|
||||
ChatFormTextarea
|
||||
} from '$lib/components/app';
|
||||
import { INPUT_CLASSES } from '$lib/constants/css-classes';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import { MimeTypeText } from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
|
||||
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
|
||||
import {
|
||||
AudioRecorder,
|
||||
convertToWav,
|
||||
createAudioFile,
|
||||
isAudioRecordingSupported
|
||||
} from '$lib/utils/browser-only';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholder?: string;
|
||||
showMcpPromptButton?: boolean;
|
||||
uploadedFiles?: ChatUploadedFile[];
|
||||
value?: string;
|
||||
onAttachmentRemove?: (index: number) => void;
|
||||
onFilesAdd?: (files: File[]) => void;
|
||||
onMcpPromptArgumentsChange?: (fileId: string, args: Record<string, string>) => void;
|
||||
onStop?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onUploadedFileRemove?: (fileId: string) => void;
|
||||
onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
attachments = [],
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
placeholder = 'Type a message...',
|
||||
showMcpPromptButton = false,
|
||||
uploadedFiles = $bindable([]),
|
||||
value = $bindable(''),
|
||||
onAttachmentRemove,
|
||||
onFilesAdd,
|
||||
onMcpPromptArgumentsChange,
|
||||
onStop,
|
||||
onSubmit,
|
||||
onSystemPromptClick,
|
||||
onUploadedFileRemove,
|
||||
onUploadedFilesChange,
|
||||
onValueChange
|
||||
}: Props = $props();
|
||||
|
||||
let audioRecorder: AudioRecorder | undefined;
|
||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||
let currentConfig = $derived(config());
|
||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
||||
let isRecording = $state(false);
|
||||
let promptPickerRef: ChatFormPromptPicker | undefined = $state(undefined);
|
||||
let isPromptPickerOpen = $state(false);
|
||||
let promptSearchQuery = $state('');
|
||||
let recordingSupported = $state(false);
|
||||
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
|
||||
|
||||
let isRouter = $derived(isRouterMode());
|
||||
|
||||
let conversationModel = $derived(
|
||||
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
|
||||
);
|
||||
|
||||
let activeModelId = $derived.by(() => {
|
||||
const options = modelOptions();
|
||||
|
||||
if (!isRouter) {
|
||||
return options.length > 0 ? options[0].model : null;
|
||||
}
|
||||
|
||||
const selectedId = selectedModelId();
|
||||
if (selectedId) {
|
||||
const model = options.find((m) => m.id === selectedId);
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
if (conversationModel) {
|
||||
const model = options.find((m) => m.model === conversationModel);
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
let pasteLongTextToFileLength = $derived.by(() => {
|
||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
||||
});
|
||||
|
||||
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
|
||||
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
|
||||
let hasAttachments = $derived(
|
||||
(attachments && attachments.length > 0) || (uploadedFiles && uploadedFiles.length > 0)
|
||||
);
|
||||
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
|
||||
|
||||
export function focus() {
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
export function resetHeight() {
|
||||
textareaRef?.resetHeight();
|
||||
}
|
||||
|
||||
export function openModelSelector() {
|
||||
chatFormActionsRef?.openModelSelector();
|
||||
}
|
||||
|
||||
export function checkModelSelected(): boolean {
|
||||
if (!hasModelSelected) {
|
||||
chatFormActionsRef?.openModelSelector();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleFileSelect(files: File[]) {
|
||||
onFilesAdd?.(files);
|
||||
}
|
||||
|
||||
function handleFileUpload() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
|
||||
|
||||
if (value.startsWith('/') && hasServers) {
|
||||
isPromptPickerOpen = true;
|
||||
promptSearchQuery = value.slice(1);
|
||||
} else {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && isPromptPickerOpen) {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
|
||||
|
||||
onSubmit?.();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePromptLoadStart(
|
||||
placeholderId: string,
|
||||
promptInfo: MCPPromptInfo,
|
||||
args?: Record<string, string>
|
||||
) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
|
||||
const promptName = promptInfo.title || promptInfo.name;
|
||||
const placeholder: ChatUploadedFile = {
|
||||
id: placeholderId,
|
||||
name: promptName,
|
||||
size: 0,
|
||||
type: 'mcp-prompt',
|
||||
file: new File([], 'loading'),
|
||||
isLoading: true,
|
||||
mcpPrompt: {
|
||||
serverName: promptInfo.serverName,
|
||||
promptName: promptInfo.name,
|
||||
arguments: args ? { ...args } : undefined
|
||||
}
|
||||
};
|
||||
|
||||
uploadedFiles = [...uploadedFiles, placeholder];
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
|
||||
const promptText = result.messages
|
||||
?.map((msg) => {
|
||||
if (typeof msg.content === 'string') {
|
||||
return msg.content;
|
||||
}
|
||||
if (msg.content.type === 'text') {
|
||||
return msg.content.text;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
uploadedFiles = uploadedFiles.map((f) =>
|
||||
f.id === placeholderId
|
||||
? {
|
||||
...f,
|
||||
isLoading: false,
|
||||
textContent: promptText,
|
||||
size: promptText.length,
|
||||
file: new File([promptText], `${f.name}.txt`, { type: 'text/plain' })
|
||||
}
|
||||
: f
|
||||
);
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
}
|
||||
|
||||
function handlePromptLoadError(placeholderId: string, error: string) {
|
||||
uploadedFiles = uploadedFiles.map((f) =>
|
||||
f.id === placeholderId ? { ...f, isLoading: false, loadError: error } : f
|
||||
);
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
}
|
||||
|
||||
function handlePromptPickerClose() {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
function handleFileRemove(fileId: string) {
|
||||
if (fileId.startsWith('attachment-')) {
|
||||
const index = parseInt(fileId.replace('attachment-', ''), 10);
|
||||
if (!isNaN(index) && index >= 0 && index < attachments.length) {
|
||||
onAttachmentRemove?.(index);
|
||||
}
|
||||
} else {
|
||||
onUploadedFileRemove?.(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
if (!event.clipboardData) return;
|
||||
|
||||
const files = Array.from(event.clipboardData.items)
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onFilesAdd?.(files);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
|
||||
|
||||
if (text.startsWith('"')) {
|
||||
const parsed = parseClipboardContent(text);
|
||||
|
||||
if (parsed.textAttachments.length > 0) {
|
||||
event.preventDefault();
|
||||
value = parsed.message;
|
||||
onValueChange?.(parsed.message);
|
||||
|
||||
const attachmentFiles = parsed.textAttachments.map(
|
||||
(att) =>
|
||||
new File([att.content], att.name, {
|
||||
type: MimeTypeText.PLAIN
|
||||
})
|
||||
);
|
||||
|
||||
onFilesAdd?.(attachmentFiles);
|
||||
|
||||
setTimeout(() => {
|
||||
textareaRef?.focus();
|
||||
}, 10);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
text.length > 0 &&
|
||||
pasteLongTextToFileLength > 0 &&
|
||||
text.length > pasteLongTextToFileLength
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
const textFile = new File([text], 'Pasted', {
|
||||
type: MimeTypeText.PLAIN
|
||||
});
|
||||
|
||||
onFilesAdd?.([textFile]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMicClick() {
|
||||
if (!audioRecorder || !recordingSupported) {
|
||||
console.warn('Audio recording not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
try {
|
||||
const audioBlob = await audioRecorder.stopRecording();
|
||||
const wavBlob = await convertToWav(audioBlob);
|
||||
const audioFile = createAudioFile(wavBlob);
|
||||
|
||||
onFilesAdd?.([audioFile]);
|
||||
isRecording = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to stop recording:', error);
|
||||
isRecording = false;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await audioRecorder.startRecording();
|
||||
isRecording = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMcpPromptArgsChange(fileId: string, args: Record<string, string>) {
|
||||
uploadedFiles = uploadedFiles.map((f) =>
|
||||
f.id === fileId && f.mcpPrompt ? { ...f, mcpPrompt: { ...f.mcpPrompt, arguments: args } } : f
|
||||
);
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
onMcpPromptArgumentsChange?.(fileId, args);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
recordingSupported = isAudioRecordingSupported();
|
||||
audioRecorder = new AudioRecorder();
|
||||
});
|
||||
</script>
|
||||
|
||||
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
|
||||
|
||||
<div class="relative {className}">
|
||||
<ChatFormPromptPicker
|
||||
bind:this={promptPickerRef}
|
||||
isOpen={isPromptPickerOpen}
|
||||
searchQuery={promptSearchQuery}
|
||||
onClose={handlePromptPickerClose}
|
||||
onPromptLoadStart={handlePromptLoadStart}
|
||||
onPromptLoadComplete={handlePromptLoadComplete}
|
||||
onPromptLoadError={handlePromptLoadError}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: ''}"
|
||||
data-slot="input-area"
|
||||
>
|
||||
<ChatAttachmentsList
|
||||
{attachments}
|
||||
bind:uploadedFiles
|
||||
onFileRemove={handleFileRemove}
|
||||
limitToSingleRow
|
||||
class="py-5"
|
||||
style="scroll-padding: 1rem;"
|
||||
activeModelId={activeModelId ?? undefined}
|
||||
isEditingAllowed={true}
|
||||
onMcpPromptArgumentsChange={handleMcpPromptArgsChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex-column relative min-h-[48px] items-center rounded-3xl p-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!p-3"
|
||||
onpaste={handlePaste}
|
||||
>
|
||||
<ChatFormTextarea
|
||||
class="px-2 py-1 md:py-0"
|
||||
bind:this={textareaRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
/>
|
||||
|
||||
<ChatFormActions
|
||||
bind:this={chatFormActionsRef}
|
||||
canSend={canSubmit}
|
||||
hasText={value.trim().length > 0}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
{isRecording}
|
||||
{uploadedFiles}
|
||||
onFileUpload={handleFileUpload}
|
||||
onMicClick={handleMicClick}
|
||||
{onStop}
|
||||
{onSystemPromptClick}
|
||||
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,526 @@
|
|||
<script lang="ts">
|
||||
import { mcpClient } from '$lib/clients/mcp.client';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { getFaviconUrl, getMcpServerLabel } from '$lib/utils/mcp';
|
||||
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen?: boolean;
|
||||
searchQuery?: string;
|
||||
onClose?: () => void;
|
||||
onPromptLoadStart?: (
|
||||
placeholderId: string,
|
||||
promptInfo: MCPPromptInfo,
|
||||
args?: Record<string, string>
|
||||
) => void;
|
||||
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
|
||||
onPromptLoadError?: (placeholderId: string, error: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isOpen = false,
|
||||
searchQuery = '',
|
||||
onClose,
|
||||
onPromptLoadStart,
|
||||
onPromptLoadComplete,
|
||||
onPromptLoadError
|
||||
}: Props = $props();
|
||||
|
||||
let prompts = $state<MCPPromptInfo[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let selectedPrompt = $state<MCPPromptInfo | null>(null);
|
||||
let promptArgs = $state<Record<string, string>>({});
|
||||
let selectedIndex = $state(0);
|
||||
let internalSearchQuery = $state('');
|
||||
let promptError = $state<string | null>(null);
|
||||
|
||||
let suggestions = $state<Record<string, string[]>>({});
|
||||
let loadingSuggestions = $state<Record<string, boolean>>({});
|
||||
let activeAutocomplete = $state<string | null>(null);
|
||||
let autocompleteIndex = $state(0);
|
||||
|
||||
let serverSettingsMap = $derived.by(() => {
|
||||
const servers = mcpStore.getServers();
|
||||
const map = new SvelteMap<string, MCPServerSettingsEntry>();
|
||||
|
||||
for (const server of servers) {
|
||||
map.set(server.id, server);
|
||||
}
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
function getServerFavicon(serverId: string): string | null {
|
||||
const server = serverSettingsMap.get(serverId);
|
||||
|
||||
return server ? getFaviconUrl(server.url) : null;
|
||||
}
|
||||
|
||||
function getServerLabel(serverId: string): string {
|
||||
const server = serverSettingsMap.get(serverId);
|
||||
|
||||
if (!server) return serverId;
|
||||
|
||||
const healthState = mcpStore.getHealthCheckState(serverId);
|
||||
return getMcpServerLabel(server, healthState);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
loadPrompts();
|
||||
selectedIndex = 0;
|
||||
} else {
|
||||
selectedPrompt = null;
|
||||
promptArgs = {};
|
||||
promptError = null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (filteredPrompts.length > 0 && selectedIndex >= filteredPrompts.length) {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPrompts() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
|
||||
const initialized = await mcpClient.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
prompts = [];
|
||||
return;
|
||||
}
|
||||
|
||||
prompts = await mcpClient.getAllPrompts();
|
||||
} catch (error) {
|
||||
console.error('[ChatFormPromptPicker] Failed to load prompts:', error);
|
||||
prompts = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePromptClick(prompt: MCPPromptInfo) {
|
||||
const requiredArgs = prompt.arguments?.filter((arg) => arg.required) ?? [];
|
||||
|
||||
if (requiredArgs.length > 0) {
|
||||
selectedPrompt = prompt;
|
||||
promptArgs = {};
|
||||
promptError = null;
|
||||
} else {
|
||||
executePrompt(prompt, {});
|
||||
}
|
||||
}
|
||||
|
||||
async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
|
||||
promptError = null;
|
||||
|
||||
const placeholderId = crypto.randomUUID();
|
||||
|
||||
const nonEmptyArgs = Object.fromEntries(
|
||||
Object.entries(args).filter(([, value]) => value.trim() !== '')
|
||||
);
|
||||
const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
|
||||
|
||||
onPromptLoadStart?.(placeholderId, prompt, argsToPass);
|
||||
onClose?.();
|
||||
|
||||
try {
|
||||
const result = await mcpClient.getPrompt(prompt.serverName, prompt.name, args);
|
||||
onPromptLoadComplete?.(placeholderId, result);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error executing prompt';
|
||||
onPromptLoadError?.(placeholderId, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function handleArgumentSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (selectedPrompt) {
|
||||
executePrompt(selectedPrompt, promptArgs);
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCompletions = debounce(async (argName: string, value: string) => {
|
||||
if (!selectedPrompt || value.length < 1) {
|
||||
suggestions[argName] = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[ChatFormPromptPicker] Fetching completions for:', {
|
||||
serverName: selectedPrompt.serverName,
|
||||
promptName: selectedPrompt.name,
|
||||
argName,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
loadingSuggestions[argName] = true;
|
||||
|
||||
try {
|
||||
const result = await mcpClient.getPromptCompletions(
|
||||
selectedPrompt.serverName,
|
||||
selectedPrompt.name,
|
||||
argName,
|
||||
value
|
||||
);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[ChatFormPromptPicker] Autocomplete result:', {
|
||||
argName,
|
||||
value,
|
||||
result,
|
||||
suggestionsCount: result?.values.length ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
if (result && result.values.length > 0) {
|
||||
// Filter out empty strings from suggestions
|
||||
const filteredValues = result.values.filter((v) => v.trim() !== '');
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
suggestions[argName] = filteredValues;
|
||||
activeAutocomplete = argName;
|
||||
autocompleteIndex = 0;
|
||||
} else {
|
||||
suggestions[argName] = [];
|
||||
}
|
||||
} else {
|
||||
suggestions[argName] = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatFormPromptPicker] Failed to fetch completions:', error);
|
||||
suggestions[argName] = [];
|
||||
} finally {
|
||||
loadingSuggestions[argName] = false;
|
||||
}
|
||||
}, 200);
|
||||
|
||||
function handleArgInput(argName: string, value: string) {
|
||||
promptArgs[argName] = value;
|
||||
fetchCompletions(argName, value);
|
||||
}
|
||||
|
||||
function selectSuggestion(argName: string, value: string) {
|
||||
promptArgs[argName] = value;
|
||||
suggestions[argName] = [];
|
||||
activeAutocomplete = null;
|
||||
}
|
||||
|
||||
function handleArgKeydown(event: KeyboardEvent, argName: string) {
|
||||
const argSuggestions = suggestions[argName] ?? [];
|
||||
|
||||
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
|
||||
} else if (event.key === 'Enter' && argSuggestions[autocompleteIndex]) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
suggestions[argName] = [];
|
||||
activeAutocomplete = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleArgBlur(argName: string) {
|
||||
// Delay to allow click on suggestion
|
||||
setTimeout(() => {
|
||||
if (activeAutocomplete === argName) {
|
||||
suggestions[argName] = [];
|
||||
activeAutocomplete = null;
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!isOpen) return false;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (selectedPrompt) {
|
||||
selectedPrompt = null;
|
||||
promptArgs = {};
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
if (selectedIndex < filteredPrompts.length - 1) {
|
||||
selectedIndex++;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (selectedIndex > 0) {
|
||||
selectedIndex--;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !selectedPrompt) {
|
||||
event.preventDefault();
|
||||
if (filteredPrompts[selectedIndex]) {
|
||||
handlePromptClick(filteredPrompts[selectedIndex]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let filteredPrompts = $derived.by(() => {
|
||||
const sortedServers = mcpStore.getServersSorted();
|
||||
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
|
||||
|
||||
const sortedPrompts = [...prompts].sort((a, b) => {
|
||||
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const query = (searchQuery || internalSearchQuery).toLowerCase();
|
||||
if (!query) return sortedPrompts;
|
||||
|
||||
return sortedPrompts.filter(
|
||||
(prompt) =>
|
||||
prompt.name.toLowerCase().includes(query) ||
|
||||
prompt.title?.toLowerCase().includes(query) ||
|
||||
prompt.description?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
let showSearchInput = $derived(prompts.length > 3);
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute right-0 bottom-full left-0 z-50 mb-3 {className}"
|
||||
transition:fly={{ y: 10, duration: 150 }}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<div class="overflow-hidden rounded-xl border border-border/50 bg-popover shadow-xl">
|
||||
{#if selectedPrompt}
|
||||
<div class="p-4">
|
||||
<!-- Header matching list item style -->
|
||||
<div class="flex items-start gap-3">
|
||||
{#if getServerFavicon(selectedPrompt.serverName)}
|
||||
<img
|
||||
src={getServerFavicon(selectedPrompt.serverName)}
|
||||
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">
|
||||
{getServerLabel(selectedPrompt.serverName)}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">
|
||||
{selectedPrompt.title || selectedPrompt.name}
|
||||
</span>
|
||||
{#if selectedPrompt.arguments?.length}
|
||||
<span class="rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{selectedPrompt.arguments.length} arg{selectedPrompt.arguments.length > 1
|
||||
? 's'
|
||||
: ''}
|
||||
</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>
|
||||
{:else}
|
||||
<div>
|
||||
{#if showSearchInput}
|
||||
<div class="p-2 pb-0">
|
||||
<SearchInput placeholder="Search prompts..." bind:value={internalSearchQuery} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-64 overflow-y-auto p-2">
|
||||
{#if isLoading}
|
||||
<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)}
|
||||
<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 getServerFavicon(prompt.serverName)}
|
||||
<img
|
||||
src={getServerFavicon(prompt.serverName)}
|
||||
alt=""
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span>{getServerLabel(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}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
onInput?: () => void;
|
||||
onKeydown?: (event: KeyboardEvent) => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
placeholder?: string;
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
onInput,
|
||||
onKeydown,
|
||||
onPaste,
|
||||
placeholder = 'Ask anything...',
|
||||
|
|
@ -52,7 +54,10 @@
|
|||
class:cursor-not-allowed={disabled}
|
||||
{disabled}
|
||||
onkeydown={onKeydown}
|
||||
oninput={(event) => autoResizeTextarea(event.currentTarget)}
|
||||
oninput={(event) => {
|
||||
autoResizeTextarea(event.currentTarget);
|
||||
onInput?.();
|
||||
}}
|
||||
onpaste={onPaste}
|
||||
{placeholder}
|
||||
></textarea>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
|
||||
import ChatMessageUser from './ChatMessageUser.svelte';
|
||||
import ChatMessageSystem from './ChatMessageSystem.svelte';
|
||||
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -216,8 +217,8 @@
|
|||
return editedExtras;
|
||||
}
|
||||
|
||||
const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
|
||||
const result = await parseFilesToMessageExtras(editedUploadedFiles);
|
||||
const plainFiles = $state.snapshot(editedUploadedFiles);
|
||||
const result = await parseFilesToMessageExtras(plainFiles);
|
||||
const newExtras = result?.extras || [];
|
||||
|
||||
return [...editedExtras, ...newExtras];
|
||||
|
|
@ -251,7 +252,6 @@
|
|||
/>
|
||||
{:else if message.role === MessageRole.USER}
|
||||
<ChatMessageUser
|
||||
bind:textareaElement
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{editedContent}
|
||||
|
|
@ -264,7 +264,6 @@
|
|||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditKeydown={handleEditKeydown}
|
||||
onEditedContentChange={handleEditedContentChange}
|
||||
onEditedExtrasChange={handleEditedExtrasChange}
|
||||
onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
|
||||
import { X, AlertTriangle } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
|
||||
import { INPUT_CLASSES } from '$lib/constants/css-classes';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import { MimeTypeText } from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { ChatFormInputArea, DialogConfirmation } from '$lib/components/app';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { autoResizeTextarea, parseClipboardContent } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
editedContent: string;
|
||||
|
|
@ -21,11 +15,9 @@
|
|||
onCancelEdit: () => void;
|
||||
onSaveEdit: () => void;
|
||||
onSaveEditOnly?: () => void;
|
||||
onEditKeydown: (event: KeyboardEvent) => void;
|
||||
onEditedContentChange: (content: string) => void;
|
||||
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
|
||||
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
|
||||
textareaElement?: HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -38,24 +30,14 @@
|
|||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onSaveEditOnly,
|
||||
onEditKeydown,
|
||||
onEditedContentChange,
|
||||
onEditedExtrasChange,
|
||||
onEditedUploadedFilesChange,
|
||||
textareaElement = $bindable()
|
||||
onEditedUploadedFilesChange
|
||||
}: Props = $props();
|
||||
|
||||
let fileInputElement: HTMLInputElement | undefined = $state();
|
||||
let inputAreaRef: ChatFormInputArea | undefined = $state(undefined);
|
||||
let saveWithoutRegenerate = $state(false);
|
||||
let showDiscardDialog = $state(false);
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let currentConfig = $derived(config());
|
||||
|
||||
let pasteLongTextToFileLength = $derived.by(() => {
|
||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||
|
||||
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
||||
});
|
||||
|
||||
let hasUnsavedChanges = $derived.by(() => {
|
||||
if (editedContent !== originalContent) return true;
|
||||
|
|
@ -77,16 +59,6 @@
|
|||
|
||||
let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
|
||||
|
||||
function handleFileInputChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
const files = Array.from(input.files);
|
||||
|
||||
processNewFiles(files);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
|
|
@ -102,23 +74,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleRemoveExistingAttachment(index: number) {
|
||||
if (!onEditedExtrasChange) return;
|
||||
|
||||
const newExtras = [...editedExtras];
|
||||
|
||||
newExtras.splice(index, 1);
|
||||
onEditedExtrasChange(newExtras);
|
||||
}
|
||||
|
||||
function handleRemoveUploadedFile(fileId: string) {
|
||||
if (!onEditedUploadedFilesChange) return;
|
||||
|
||||
const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
|
||||
|
||||
onEditedUploadedFilesChange(newFiles);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
|
||||
|
|
@ -131,79 +86,36 @@
|
|||
saveWithoutRegenerate = false;
|
||||
}
|
||||
|
||||
async function processNewFiles(files: File[]) {
|
||||
function handleAttachmentRemove(index: number) {
|
||||
if (!onEditedExtrasChange) return;
|
||||
|
||||
const newExtras = [...editedExtras];
|
||||
newExtras.splice(index, 1);
|
||||
onEditedExtrasChange(newExtras);
|
||||
}
|
||||
|
||||
function handleUploadedFileRemove(fileId: string) {
|
||||
if (!onEditedUploadedFilesChange) return;
|
||||
|
||||
const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
|
||||
onEditedUploadedFilesChange(newFiles);
|
||||
}
|
||||
|
||||
async function handleFilesAdd(files: File[]) {
|
||||
if (!onEditedUploadedFilesChange) return;
|
||||
|
||||
const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
|
||||
const processed = await processFilesToChatUploaded(files);
|
||||
|
||||
onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
|
||||
onEditedUploadedFilesChange([...editedUploadedFiles, processed].flat());
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
if (!event.clipboardData) return;
|
||||
|
||||
const files = Array.from(event.clipboardData.items)
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
processNewFiles(files);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
|
||||
|
||||
if (text.startsWith('"')) {
|
||||
const parsed = parseClipboardContent(text);
|
||||
|
||||
if (parsed.textAttachments.length > 0) {
|
||||
event.preventDefault();
|
||||
onEditedContentChange(parsed.message);
|
||||
|
||||
const attachmentFiles = parsed.textAttachments.map(
|
||||
(att) =>
|
||||
new File([att.content], att.name, {
|
||||
type: MimeTypeText.PLAIN
|
||||
})
|
||||
);
|
||||
|
||||
processNewFiles(attachmentFiles);
|
||||
|
||||
setTimeout(() => {
|
||||
textareaElement?.focus();
|
||||
}, 10);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
text.length > 0 &&
|
||||
pasteLongTextToFileLength > 0 &&
|
||||
text.length > pasteLongTextToFileLength
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
const textFile = new File([text], 'Pasted', {
|
||||
type: MimeTypeText.PLAIN
|
||||
});
|
||||
|
||||
processNewFiles([textFile]);
|
||||
}
|
||||
function handleUploadedFilesChange(files: ChatUploadedFile[]) {
|
||||
onEditedUploadedFilesChange?.(files);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (textareaElement) {
|
||||
autoResizeTextarea(textareaElement);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
chatStore.setEditModeActive(processNewFiles);
|
||||
chatStore.setEditModeActive(handleFilesAdd);
|
||||
|
||||
return () => {
|
||||
chatStore.clearEditMode();
|
||||
|
|
@ -213,82 +125,20 @@
|
|||
|
||||
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||
|
||||
<input
|
||||
bind:this={fileInputElement}
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
|
||||
data-slot="edit-form"
|
||||
>
|
||||
<ChatAttachmentsList
|
||||
<div class="relative w-full max-w-[80%]">
|
||||
<ChatFormInputArea
|
||||
bind:this={inputAreaRef}
|
||||
value={editedContent}
|
||||
attachments={editedExtras}
|
||||
uploadedFiles={editedUploadedFiles}
|
||||
readonly={false}
|
||||
onFileRemove={(fileId) => {
|
||||
if (fileId.startsWith('attachment-')) {
|
||||
const index = parseInt(fileId.replace('attachment-', ''), 10);
|
||||
if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
|
||||
handleRemoveExistingAttachment(index);
|
||||
}
|
||||
} else {
|
||||
handleRemoveUploadedFile(fileId);
|
||||
}
|
||||
}}
|
||||
limitToSingleRow
|
||||
class="py-5"
|
||||
style="scroll-padding: 1rem;"
|
||||
placeholder="Edit your message..."
|
||||
onValueChange={onEditedContentChange}
|
||||
onAttachmentRemove={handleAttachmentRemove}
|
||||
onUploadedFileRemove={handleUploadedFileRemove}
|
||||
onUploadedFilesChange={handleUploadedFilesChange}
|
||||
onFilesAdd={handleFilesAdd}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<div class="relative min-h-[48px] px-5 py-3">
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value={editedContent}
|
||||
class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
|
||||
onkeydown={onEditKeydown}
|
||||
oninput={(e) => {
|
||||
autoResizeTextarea(e.currentTarget);
|
||||
onEditedContentChange(e.currentTarget.value);
|
||||
}}
|
||||
onpaste={handlePaste}
|
||||
placeholder="Edit your message..."
|
||||
></textarea>
|
||||
|
||||
<div class="flex w-full items-center gap-3" style="container-type: inline-size">
|
||||
<Button
|
||||
class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
|
||||
onclick={() => fileInputElement?.click()}
|
||||
type="button"
|
||||
title="Add attachment"
|
||||
>
|
||||
<span class="sr-only">Attach files</span>
|
||||
|
||||
<Paperclip class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if isRouter}
|
||||
<ModelsSelector forceForegroundText={true} useGlobalSelection={true} />
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
class="h-8 w-8 shrink-0 rounded-full p-0"
|
||||
onclick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
type="button"
|
||||
title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
|
||||
>
|
||||
<span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
|
||||
|
||||
<ArrowUp class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@
|
|||
onCancelEdit: () => void;
|
||||
onSaveEdit: () => void;
|
||||
onSaveEditOnly?: () => void;
|
||||
onEditKeydown: (event: KeyboardEvent) => void;
|
||||
onEditedContentChange: (content: string) => void;
|
||||
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
|
||||
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
|
||||
|
|
@ -34,7 +33,6 @@
|
|||
onConfirmDelete: () => void;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onShowDeleteDialogChange: (show: boolean) => void;
|
||||
textareaElement?: HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -50,7 +48,6 @@
|
|||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onSaveEditOnly,
|
||||
onEditKeydown,
|
||||
onEditedContentChange,
|
||||
onEditedExtrasChange,
|
||||
onEditedUploadedFilesChange,
|
||||
|
|
@ -59,8 +56,7 @@
|
|||
onDelete,
|
||||
onConfirmDelete,
|
||||
onNavigateToSibling,
|
||||
onShowDeleteDialogChange,
|
||||
textareaElement = $bindable()
|
||||
onShowDeleteDialogChange
|
||||
}: Props = $props();
|
||||
|
||||
let isMultiline = $state(false);
|
||||
|
|
@ -99,7 +95,6 @@
|
|||
>
|
||||
{#if isEditing}
|
||||
<ChatMessageEditForm
|
||||
bind:textareaElement
|
||||
{editedContent}
|
||||
{editedExtras}
|
||||
{editedUploadedFiles}
|
||||
|
|
@ -109,7 +104,6 @@
|
|||
{onCancelEdit}
|
||||
{onSaveEdit}
|
||||
{onSaveEditOnly}
|
||||
{onEditKeydown}
|
||||
{onEditedContentChange}
|
||||
{onEditedExtrasChange}
|
||||
{onEditedUploadedFilesChange}
|
||||
|
|
|
|||
|
|
@ -220,8 +220,9 @@
|
|||
}
|
||||
|
||||
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
|
||||
const result = files
|
||||
? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
|
||||
const plainFiles = files ? $state.snapshot(files) : undefined;
|
||||
const result = plainFiles
|
||||
? await parseFilesToMessageExtras(plainFiles, activeModelId ?? undefined)
|
||||
: undefined;
|
||||
|
||||
if (result?.emptyFiles && result.emptyFiles.length > 0) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Chat
|
||||
|
||||
export { default as ChatAttachmentMcpPrompt } from './chat/ChatAttachments/ChatAttachmentMcpPrompt.svelte';
|
||||
export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
|
||||
export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
|
||||
export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
|
||||
|
|
@ -13,6 +14,8 @@ export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/Chat
|
|||
export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
|
||||
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
|
||||
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
|
||||
export { default as ChatFormInputArea } from './chat/ChatForm/ChatFormInputArea.svelte';
|
||||
export { default as ChatFormPromptPicker } from './chat/ChatForm/ChatFormPromptPicker.svelte';
|
||||
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
|
||||
|
||||
export { default as AgenticContent } from './chat/ChatMessages/AgenticContent.svelte';
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@
|
|||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
// Use store methods for consistent sorting logic
|
||||
let rawServers = $derived(mcpStore.getServers());
|
||||
let servers = $derived(mcpStore.getServersSorted());
|
||||
let isLoading = $derived(mcpStore.isAnyServerLoading());
|
||||
|
||||
// New server form state
|
||||
let isAddingServer = $state(false);
|
||||
|
|
@ -115,13 +113,15 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rawServers.length > 0}
|
||||
{#if servers.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#if isLoading}
|
||||
<!-- Show skeleton cards while health checks are in progress -->
|
||||
{#each rawServers as server (server.id)}
|
||||
{#each servers as server (server.id)}
|
||||
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
||||
{@const isServerLoading =
|
||||
healthState.status === 'idle' || healthState.status === 'connecting'}
|
||||
|
||||
{#if isServerLoading}
|
||||
<Card.Root class="grid gap-3 p-4">
|
||||
<!-- Header: favicon + name + version ... toggle -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-5 w-5 rounded" />
|
||||
|
|
@ -131,32 +131,26 @@
|
|||
<Skeleton class="h-6 w-11 rounded-full" />
|
||||
</div>
|
||||
|
||||
<!-- Capability badges -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Skeleton class="h-5 w-14 rounded-full" />
|
||||
<Skeleton class="h-5 w-12 rounded-full" />
|
||||
<Skeleton class="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
|
||||
<!-- Tools & Connection info -->
|
||||
<div class="space-y-1.5">
|
||||
<Skeleton class="h-4 w-40" />
|
||||
<Skeleton class="h-4 w-52" />
|
||||
</div>
|
||||
|
||||
<!-- Protocol version -->
|
||||
<Skeleton class="h-3.5 w-36" />
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<Skeleton class="h-8 w-8 rounded" />
|
||||
<Skeleton class="h-8 w-8 rounded" />
|
||||
<Skeleton class="h-8 w-8 rounded" />
|
||||
</div>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each servers as server (server.id)}
|
||||
{:else}
|
||||
<McpServerCard
|
||||
{server}
|
||||
faviconUrl={getFaviconUrl(server.url)}
|
||||
|
|
@ -165,8 +159,8 @@
|
|||
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
|
||||
onDelete={() => mcpStore.removeServer(server.id)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div class={cn('overflow-y-auto', 'max-h-[10rem]')}>
|
||||
<div class={cn('overflow-y-auto', 'max-h-[16rem]')}>
|
||||
{@render children()}
|
||||
|
||||
{#if isEmpty}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
||||
outline:
|
||||
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
secondary:
|
||||
'dark:bg-secondary dark:text-secondary-foreground bg-background shadow-sm text-foreground hover:bg-muted-foreground/20',
|
||||
ghost: 'hover:text-accent-foreground hover:bg-muted-foreground/10',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
bind:checked
|
||||
data-slot="switch"
|
||||
class={cn(
|
||||
'peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
|
||||
'peer inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export const BOX_BORDER =
|
|||
'border border-border/30 focus-within:border-border dark:border-border/20 dark:focus-within:border-border';
|
||||
|
||||
export const INPUT_CLASSES = `
|
||||
bg-muted/50 dark:bg-muted/75
|
||||
bg-muted/60 dark:bg-muted/75
|
||||
${BOX_BORDER}
|
||||
shadow-sm
|
||||
outline-none
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
export enum AttachmentType {
|
||||
AUDIO = 'AUDIO',
|
||||
IMAGE = 'IMAGE',
|
||||
MCP_PROMPT = 'MCP_PROMPT',
|
||||
PDF = 'PDF',
|
||||
TEXT = 'TEXT',
|
||||
LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { getJsonHeaders } from '$lib/utils';
|
|||
import { AGENTIC_REGEX } from '$lib/constants/agentic';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type { ApiChatMessageContentPart } from '$lib/types/api';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
|
||||
/**
|
||||
* ChatService - Low-level API communication layer for Chat Completions
|
||||
|
|
@ -724,6 +725,18 @@ export class ChatService {
|
|||
}
|
||||
}
|
||||
|
||||
const mcpPrompts = message.extra.filter(
|
||||
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpPrompt =>
|
||||
extra.type === AttachmentType.MCP_PROMPT
|
||||
);
|
||||
|
||||
for (const mcpPrompt of mcpPrompts) {
|
||||
contentParts.push({
|
||||
type: 'text',
|
||||
text: `\n\n--- MCP Prompt: ${mcpPrompt.name} (${mcpPrompt.serverName}) ---\n${mcpPrompt.content}`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role as 'user' | 'assistant' | 'system',
|
||||
content: contentParts
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|||
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
|
||||
import type {
|
||||
Tool,
|
||||
ListChangedHandlers,
|
||||
Prompt,
|
||||
GetPromptResult,
|
||||
ListChangedHandlers
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type {
|
||||
|
|
@ -152,6 +154,7 @@ export class MCPService {
|
|||
* Connect to a single MCP server with detailed phase tracking.
|
||||
* Returns connection object with client, transport, discovered tools, and connection details.
|
||||
* @param onPhase - Optional callback for connection phase changes
|
||||
* @param listChangedHandlers - Optional handlers for list changed notifications
|
||||
*/
|
||||
static async connect(
|
||||
serverName: string,
|
||||
|
|
@ -159,7 +162,7 @@ export class MCPService {
|
|||
clientInfo?: Implementation,
|
||||
capabilities?: ClientCapabilities,
|
||||
onPhase?: MCPPhaseCallback,
|
||||
listChangedHandlers?: ListChangedHandlers,
|
||||
listChangedHandlers?: ListChangedHandlers
|
||||
): Promise<MCPConnection> {
|
||||
const startTime = performance.now();
|
||||
const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
|
||||
|
|
@ -296,10 +299,42 @@ export class MCPService {
|
|||
return result.tools ?? [];
|
||||
} catch (error) {
|
||||
console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List prompts from a connection.
|
||||
*/
|
||||
static async listPrompts(connection: MCPConnection): Promise<Prompt[]> {
|
||||
try {
|
||||
const result = await connection.client.listPrompts();
|
||||
return result.prompts ?? [];
|
||||
} catch (error) {
|
||||
console.warn(`[MCPService][${connection.serverName}] Failed to list prompts:`, error);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific prompt with arguments.
|
||||
*/
|
||||
static async getPrompt(
|
||||
connection: MCPConnection,
|
||||
name: string,
|
||||
args?: Record<string, string>
|
||||
): Promise<GetPromptResult> {
|
||||
try {
|
||||
return await connection.client.getPrompt({ name, arguments: args });
|
||||
} catch (error) {
|
||||
console.error(`[MCPService][${connection.serverName}] Failed to get prompt:`, error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool call on a connection.
|
||||
*/
|
||||
|
|
@ -369,4 +404,38 @@ export class MCPService {
|
|||
|
||||
return JSON.stringify(content);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Completions Operations
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Request completion suggestions from a server.
|
||||
* Used for autocompleting prompt arguments or resource URI templates.
|
||||
*
|
||||
* @param connection - The MCP connection to use
|
||||
* @param ref - Reference to the prompt or resource template
|
||||
* @param argument - The argument being completed (name and current value)
|
||||
* @returns Completion result with suggested values
|
||||
*/
|
||||
static async complete(
|
||||
connection: MCPConnection,
|
||||
ref: { type: 'ref/prompt'; name: string } | { type: 'ref/resource'; uri: string },
|
||||
argument: { name: string; value: string }
|
||||
): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
|
||||
try {
|
||||
const result = await connection.client.complete({
|
||||
ref,
|
||||
argument
|
||||
});
|
||||
return result.completion;
|
||||
} catch (error) {
|
||||
console.error(`[MCPService] Failed to get completions:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@
|
|||
*/
|
||||
|
||||
import { mcpClient } from '$lib/clients/mcp.client';
|
||||
import type { HealthCheckState, MCPServerSettingsEntry, McpServerUsageStats } from '$lib/types';
|
||||
import type {
|
||||
HealthCheckState,
|
||||
MCPServerSettingsEntry,
|
||||
McpServerUsageStats,
|
||||
MCPPromptInfo,
|
||||
GetPromptResult
|
||||
} from '$lib/types';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { buildMcpClientConfig, parseMcpServerSettings, getMcpServerLabel } from '$lib/utils/mcp';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
|
|
@ -179,7 +185,9 @@ class MCPStore {
|
|||
const servers = this.getServers();
|
||||
return servers.some((s) => {
|
||||
const state = this.getHealthCheckState(s.id);
|
||||
return state.status === HealthCheckStatus.Idle || state.status === HealthCheckStatus.Connecting;
|
||||
return (
|
||||
state.status === HealthCheckStatus.Idle || state.status === HealthCheckStatus.Connecting
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -189,12 +197,12 @@ class MCPStore {
|
|||
*/
|
||||
getServersSorted(): MCPServerSettingsEntry[] {
|
||||
const servers = this.getServers();
|
||||
|
||||
|
||||
// Don't sort while any server is still loading - prevents UI jumping
|
||||
if (this.isAnyServerLoading()) {
|
||||
return servers;
|
||||
}
|
||||
|
||||
|
||||
// Sort alphabetically by display label once all health checks are done
|
||||
return [...servers].sort((a, b) => {
|
||||
const labelA = getMcpServerLabel(a, this.getHealthCheckState(a.id));
|
||||
|
|
@ -286,6 +294,38 @@ class MCPStore {
|
|||
stats[serverId] = (stats[serverId] || 0) + 1;
|
||||
settingsStore.updateConfig('mcpServerUsageStats', JSON.stringify(stats));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Prompts
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if any connected server supports prompts
|
||||
*/
|
||||
hasPromptsSupport(): boolean {
|
||||
return mcpClient.hasPromptsSupport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all prompts from all connected servers
|
||||
*/
|
||||
async getAllPrompts(): Promise<MCPPromptInfo[]> {
|
||||
return mcpClient.getAllPrompts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific prompt by name from a server.
|
||||
* Throws an error if the server is not found or prompt execution fails.
|
||||
*/
|
||||
async getPrompt(
|
||||
serverName: string,
|
||||
promptName: string,
|
||||
args?: Record<string, string>
|
||||
): Promise<GetPromptResult> {
|
||||
return mcpClient.getPrompt(serverName, promptName, args);
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpStore = new MCPStore();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ export interface ChatUploadedFile {
|
|||
file: File;
|
||||
preview?: string;
|
||||
textContent?: string;
|
||||
mcpPrompt?: {
|
||||
serverName: string;
|
||||
promptName: string;
|
||||
arguments?: Record<string, string>;
|
||||
};
|
||||
isLoading?: boolean;
|
||||
loadError?: string;
|
||||
}
|
||||
|
||||
export interface ChatAttachmentDisplayItem {
|
||||
|
|
@ -14,6 +21,9 @@ export interface ChatAttachmentDisplayItem {
|
|||
size?: number;
|
||||
preview?: string;
|
||||
isImage: boolean;
|
||||
isMcpPrompt?: boolean;
|
||||
isLoading?: boolean;
|
||||
loadError?: string;
|
||||
uploadedFile?: ChatUploadedFile;
|
||||
attachment?: DatabaseMessageExtra;
|
||||
attachmentIndex?: number;
|
||||
|
|
|
|||
|
|
@ -52,11 +52,21 @@ export interface DatabaseMessageExtraTextFile {
|
|||
content: string;
|
||||
}
|
||||
|
||||
export interface DatabaseMessageExtraMcpPrompt {
|
||||
type: AttachmentType.MCP_PROMPT;
|
||||
name: string;
|
||||
serverName: string;
|
||||
promptName: string;
|
||||
content: string;
|
||||
arguments?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type DatabaseMessageExtra =
|
||||
| DatabaseMessageExtraImageFile
|
||||
| DatabaseMessageExtraTextFile
|
||||
| DatabaseMessageExtraAudioFile
|
||||
| DatabaseMessageExtraPdfFile
|
||||
| DatabaseMessageExtraMcpPrompt
|
||||
| DatabaseMessageExtraLegacyContext;
|
||||
|
||||
export interface DatabaseMessage {
|
||||
|
|
@ -66,8 +76,8 @@ export interface DatabaseMessage {
|
|||
timestamp: number;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
parent: string;
|
||||
thinking: string;
|
||||
parent: string | null;
|
||||
thinking?: string;
|
||||
toolCalls?: string;
|
||||
children: string[];
|
||||
extra?: DatabaseMessageExtra[];
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export type {
|
|||
DatabaseMessageExtraAudioFile,
|
||||
DatabaseMessageExtraImageFile,
|
||||
DatabaseMessageExtraLegacyContext,
|
||||
DatabaseMessageExtraMcpPrompt,
|
||||
DatabaseMessageExtraPdfFile,
|
||||
DatabaseMessageExtraTextFile,
|
||||
DatabaseMessageExtra,
|
||||
|
|
@ -79,6 +80,7 @@ export type {
|
|||
MCPServerInfo,
|
||||
MCPCapabilitiesInfo,
|
||||
MCPToolInfo,
|
||||
MCPPromptInfo,
|
||||
MCPConnectionDetails,
|
||||
MCPPhaseCallback,
|
||||
MCPConnection,
|
||||
|
|
@ -94,4 +96,7 @@ export type {
|
|||
ToolCallParams,
|
||||
ToolExecutionResult,
|
||||
Tool,
|
||||
Prompt,
|
||||
GetPromptResult,
|
||||
PromptMessage
|
||||
} from './mcp';
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ import type {
|
|||
ServerCapabilities as SDKServerCapabilities,
|
||||
Implementation as SDKImplementation,
|
||||
Tool,
|
||||
CallToolResult
|
||||
CallToolResult,
|
||||
Prompt,
|
||||
GetPromptResult,
|
||||
PromptMessage
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type { Tool, CallToolResult };
|
||||
export type { Tool, CallToolResult, Prompt, GetPromptResult, PromptMessage };
|
||||
export type ClientCapabilities = SDKClientCapabilities;
|
||||
export type ServerCapabilities = SDKServerCapabilities;
|
||||
export type Implementation = SDKImplementation;
|
||||
|
|
@ -64,6 +67,21 @@ export interface MCPToolInfo {
|
|||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt information for display
|
||||
*/
|
||||
export interface MCPPromptInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
serverName: string;
|
||||
arguments?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full connection details for visualization
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { FileTypeCategory } from '$lib/enums';
|
||||
import { AttachmentType, FileTypeCategory } from '$lib/enums';
|
||||
import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
|
||||
|
||||
export interface AttachmentDisplayItemsOptions {
|
||||
|
|
@ -6,6 +6,20 @@ export interface AttachmentDisplayItemsOptions {
|
|||
attachments?: DatabaseMessageExtra[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an uploaded file is an MCP prompt
|
||||
*/
|
||||
function isMcpPromptUpload(file: ChatUploadedFile): boolean {
|
||||
return file.type === 'mcp-prompt' && !!file.mcpPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an attachment is an MCP prompt
|
||||
*/
|
||||
function isMcpPromptAttachment(attachment: DatabaseMessageExtra): boolean {
|
||||
return attachment.type === AttachmentType.MCP_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file type category from an uploaded file, checking both MIME type and extension
|
||||
*/
|
||||
|
|
@ -37,6 +51,9 @@ export function getAttachmentDisplayItems(
|
|||
size: file.size,
|
||||
preview: file.preview,
|
||||
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
|
||||
isMcpPrompt: isMcpPromptUpload(file),
|
||||
isLoading: file.isLoading,
|
||||
loadError: file.loadError,
|
||||
uploadedFile: file,
|
||||
textContent: file.textContent
|
||||
});
|
||||
|
|
@ -45,12 +62,14 @@ export function getAttachmentDisplayItems(
|
|||
// Add stored attachments (ChatMessage)
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const isImage = isImageFile(attachment);
|
||||
const isMcpPrompt = isMcpPromptAttachment(attachment);
|
||||
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
|
||||
isImage,
|
||||
isMcpPrompt,
|
||||
attachment,
|
||||
attachmentIndex: index,
|
||||
textContent: 'content' in attachment ? attachment.content : undefined
|
||||
|
|
|
|||
|
|
@ -38,6 +38,18 @@ export async function parseFilesToMessageExtras(
|
|||
const emptyFiles: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.type === 'mcp-prompt' && file.mcpPrompt) {
|
||||
extras.push({
|
||||
type: AttachmentType.MCP_PROMPT,
|
||||
name: file.name,
|
||||
serverName: file.mcpPrompt.serverName,
|
||||
promptName: file.mcpPrompt.promptName,
|
||||
content: file.textContent ?? '',
|
||||
arguments: file.mcpPrompt.arguments
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
|
||||
if (file.preview) {
|
||||
let base64Url = file.preview;
|
||||
|
|
|
|||
|
|
@ -253,6 +253,25 @@ export function buildMcpClientConfig(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets enabled MCP servers for a conversation based on per-chat overrides.
|
||||
* Returns servers that are both globally enabled AND enabled for this chat.
|
||||
* @param config - Global settings configuration
|
||||
* @param perChatOverrides - Per-chat server overrides
|
||||
* @returns Array of enabled server settings entries
|
||||
*/
|
||||
export function getEnabledServersForConversation(
|
||||
config: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): MCPServerSettingsEntry[] {
|
||||
if (!perChatOverrides?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allServers = parseMcpServerSettings(config.mcpServers);
|
||||
return allServers.filter((server) => checkServerEnabled(server, perChatOverrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate icon component for a log level
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in New Issue