webui: Agentic Loop + MCP Client with support for Tools, Resources and Prompts (#18655)
This commit is contained in:
parent
2850bc6a13
commit
f6235a41ef
|
|
@ -2827,6 +2827,14 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
|
|||
params.webui_config_json = read_file(value);
|
||||
}
|
||||
).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_WEBUI_CONFIG_FILE"));
|
||||
add_opt(common_arg(
|
||||
{"--webui-mcp-proxy"},
|
||||
{"--no-webui-mcp-proxy"},
|
||||
string_format("experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: %s)", params.webui_mcp_proxy ? "enabled" : "disabled"),
|
||||
[](common_params & params, bool value) {
|
||||
params.webui_mcp_proxy = value;
|
||||
}
|
||||
).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_WEBUI_MCP_PROXY"));
|
||||
add_opt(common_arg(
|
||||
{"--webui"},
|
||||
{"--no-webui"},
|
||||
|
|
|
|||
|
|
@ -545,6 +545,7 @@ struct common_params {
|
|||
|
||||
// webui configs
|
||||
bool webui = true;
|
||||
bool webui_mcp_proxy = false;
|
||||
std::string webui_config_json;
|
||||
|
||||
// "advanced" endpoints are disabled by default for better security
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,56 @@
|
|||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
#include "http.h"
|
||||
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <list>
|
||||
#include <map>
|
||||
|
||||
#include "server-http.h"
|
||||
|
||||
static server_http_res_ptr proxy_request(const server_http_req & req, std::string method) {
|
||||
std::string target_url = req.get_param("url");
|
||||
common_http_url parsed_url = common_http_parse_url(target_url);
|
||||
|
||||
if (parsed_url.host.empty()) {
|
||||
throw std::runtime_error("invalid target URL: missing host");
|
||||
}
|
||||
|
||||
if (parsed_url.path.empty()) {
|
||||
parsed_url.path = "/";
|
||||
}
|
||||
|
||||
if (!parsed_url.password.empty()) {
|
||||
throw std::runtime_error("authentication in target URL is not supported");
|
||||
}
|
||||
|
||||
if (parsed_url.scheme != "http" && parsed_url.scheme != "https") {
|
||||
throw std::runtime_error("unsupported URL scheme in target URL: " + parsed_url.scheme);
|
||||
}
|
||||
|
||||
SRV_INF("proxying %s request to %s://%s%s\n", method.c_str(), parsed_url.scheme.c_str(), parsed_url.host.c_str(), parsed_url.path.c_str());
|
||||
|
||||
auto proxy = std::make_unique<server_http_proxy>(
|
||||
method,
|
||||
parsed_url.host,
|
||||
parsed_url.scheme == "http" ? 80 : 443,
|
||||
parsed_url.path,
|
||||
req.headers,
|
||||
req.body,
|
||||
req.should_stop,
|
||||
600, // timeout_read (default to 10 minutes)
|
||||
600 // timeout_write (default to 10 minutes)
|
||||
);
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
static server_http_context::handler_t proxy_handler_post = [](const server_http_req & req) -> server_http_res_ptr {
|
||||
return proxy_request(req, "POST");
|
||||
};
|
||||
|
||||
static server_http_context::handler_t proxy_handler_get = [](const server_http_req & req) -> server_http_res_ptr {
|
||||
return proxy_request(req, "GET");
|
||||
};
|
||||
|
|
@ -1089,11 +1089,20 @@ server_http_proxy::server_http_proxy(
|
|||
int32_t timeout_write
|
||||
) {
|
||||
// shared between reader and writer threads
|
||||
auto cli = std::make_shared<httplib::Client>(host, port);
|
||||
auto cli = std::make_shared<httplib::ClientImpl>(host, port);
|
||||
auto pipe = std::make_shared<pipe_t<msg_t>>();
|
||||
|
||||
if (port == 443) {
|
||||
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
|
||||
cli.reset(new httplib::SSLClient(host, port));
|
||||
#else
|
||||
throw std::runtime_error("HTTPS requested but CPPHTTPLIB_OPENSSL_SUPPORT is not defined");
|
||||
#endif
|
||||
}
|
||||
|
||||
// setup Client
|
||||
cli->set_connection_timeout(0, 200000); // 200 milliseconds
|
||||
cli->set_follow_location(true);
|
||||
cli->set_connection_timeout(5, 0); // 5 seconds
|
||||
cli->set_write_timeout(timeout_read, 0); // reversed for cli (client) vs srv (server)
|
||||
cli->set_read_timeout(timeout_write, 0);
|
||||
this->status = 500; // to be overwritten upon response
|
||||
|
|
@ -1142,7 +1151,15 @@ server_http_proxy::server_http_proxy(
|
|||
req.method = method;
|
||||
req.path = path;
|
||||
for (const auto & [key, value] : headers) {
|
||||
req.set_header(key, value);
|
||||
if (key == "Accept-Encoding") {
|
||||
// disable Accept-Encoding to avoid compressed responses
|
||||
continue;
|
||||
}
|
||||
if (key == "Host" || key == "host") {
|
||||
req.set_header(key, host);
|
||||
} else {
|
||||
req.set_header(key, value);
|
||||
}
|
||||
}
|
||||
req.body = body;
|
||||
req.response_handler = response_handler;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#include "server-context.h"
|
||||
#include "server-http.h"
|
||||
#include "server-models.h"
|
||||
#include "server-cors-proxy.h"
|
||||
|
||||
#include "arg.h"
|
||||
#include "common.h"
|
||||
|
|
@ -201,6 +202,15 @@ int main(int argc, char ** argv) {
|
|||
// Save & load slots
|
||||
ctx_http.get ("/slots", ex_wrapper(routes.get_slots));
|
||||
ctx_http.post("/slots/:id_slot", ex_wrapper(routes.post_slots));
|
||||
// CORS proxy (EXPERIMENTAL, only used by the Web UI for MCP)
|
||||
if (params.webui_mcp_proxy) {
|
||||
SRV_WRN("%s", "-----------------\n");
|
||||
SRV_WRN("%s", "CORS proxy is enabled, do not expose server to untrusted environments\n");
|
||||
SRV_WRN("%s", "This feature is EXPERIMENTAL and may be removed or changed in future versions\n");
|
||||
SRV_WRN("%s", "-----------------\n");
|
||||
ctx_http.get ("/cors-proxy", ex_wrapper(proxy_handler_get));
|
||||
ctx_http.post("/cors-proxy", ex_wrapper(proxy_handler_post));
|
||||
}
|
||||
|
||||
//
|
||||
// Start the server
|
||||
|
|
|
|||
|
|
@ -12,9 +12,13 @@ flowchart TB
|
|||
C_Form["ChatForm"]
|
||||
C_Messages["ChatMessages"]
|
||||
C_Message["ChatMessage"]
|
||||
C_ChatMessageAgenticContent["ChatMessageAgenticContent"]
|
||||
C_MessageEditForm["ChatMessageEditForm"]
|
||||
C_ModelsSelector["ModelsSelector"]
|
||||
C_Settings["ChatSettings"]
|
||||
C_McpSettings["McpServersSettings"]
|
||||
C_McpResourceBrowser["McpResourceBrowser"]
|
||||
C_McpServersSelector["McpServersSelector"]
|
||||
end
|
||||
|
||||
subgraph Hooks["🪝 Hooks"]
|
||||
|
|
@ -24,10 +28,13 @@ flowchart TB
|
|||
|
||||
subgraph Stores["🗄️ Stores"]
|
||||
S1["chatStore<br/><i>Chat interactions & streaming</i>"]
|
||||
S2["conversationsStore<br/><i>Conversation data & messages</i>"]
|
||||
SA["agenticStore<br/><i>Multi-turn agentic loop orchestration</i>"]
|
||||
S2["conversationsStore<br/><i>Conversation data, messages & MCP overrides</i>"]
|
||||
S3["modelsStore<br/><i>Model selection & loading</i>"]
|
||||
S4["serverStore<br/><i>Server props & role detection</i>"]
|
||||
S5["settingsStore<br/><i>User configuration</i>"]
|
||||
S5["settingsStore<br/><i>User configuration incl. MCP</i>"]
|
||||
S6["mcpStore<br/><i>MCP servers, tools, prompts</i>"]
|
||||
S7["mcpResourceStore<br/><i>MCP resources & attachments</i>"]
|
||||
end
|
||||
|
||||
subgraph Services["⚙️ Services"]
|
||||
|
|
@ -36,11 +43,12 @@ flowchart TB
|
|||
SV3["PropsService"]
|
||||
SV4["DatabaseService"]
|
||||
SV5["ParameterSyncService"]
|
||||
SV6["MCPService<br/><i>protocol operations</i>"]
|
||||
end
|
||||
|
||||
subgraph Storage["💾 Storage"]
|
||||
ST1["IndexedDB<br/><i>conversations, messages</i>"]
|
||||
ST2["LocalStorage<br/><i>config, userOverrides</i>"]
|
||||
ST2["LocalStorage<br/><i>config, userOverrides, mcpServers</i>"]
|
||||
end
|
||||
|
||||
subgraph APIs["🌐 llama-server API"]
|
||||
|
|
@ -50,15 +58,27 @@ flowchart TB
|
|||
API4["/v1/models"]
|
||||
end
|
||||
|
||||
subgraph ExternalMCP["🔌 External MCP Servers"]
|
||||
EXT1["MCP Server 1<br/><i>WebSocket/HTTP/SSE</i>"]
|
||||
EXT2["MCP Server N"]
|
||||
end
|
||||
|
||||
%% Routes → Components
|
||||
R1 & R2 --> C_Screen
|
||||
RL --> C_Sidebar
|
||||
|
||||
%% Layout runs MCP health checks
|
||||
RL --> S6
|
||||
|
||||
%% Component hierarchy
|
||||
C_Screen --> C_Form & C_Messages & C_Settings
|
||||
C_Messages --> C_Message
|
||||
C_Message --> C_ChatMessageAgenticContent
|
||||
C_Message --> C_MessageEditForm
|
||||
C_Form & C_MessageEditForm --> C_ModelsSelector
|
||||
C_Form --> C_McpServersSelector
|
||||
C_Settings --> C_McpSettings
|
||||
C_McpSettings --> C_McpResourceBrowser
|
||||
|
||||
%% Components → Hooks → Stores
|
||||
C_Form & C_Messages --> H1 & H2
|
||||
|
|
@ -70,6 +90,15 @@ flowchart TB
|
|||
C_Sidebar --> S2
|
||||
C_ModelsSelector --> S3 & S4
|
||||
C_Settings --> S5
|
||||
C_McpSettings --> S6
|
||||
C_McpResourceBrowser --> S6 & S7
|
||||
C_McpServersSelector --> S6
|
||||
C_Form --> S6
|
||||
|
||||
%% chatStore → agenticStore → mcpStore (agentic loop)
|
||||
S1 --> SA
|
||||
SA --> SV1
|
||||
SA --> S6
|
||||
|
||||
%% Stores → Services
|
||||
S1 --> SV1 & SV4
|
||||
|
|
@ -77,6 +106,8 @@ flowchart TB
|
|||
S3 --> SV2 & SV3
|
||||
S4 --> SV3
|
||||
S5 --> SV5
|
||||
S6 --> SV6
|
||||
S7 --> SV6
|
||||
|
||||
%% Services → Storage
|
||||
SV4 --> ST1
|
||||
|
|
@ -87,6 +118,9 @@ flowchart TB
|
|||
SV2 --> API3 & API4
|
||||
SV3 --> API2
|
||||
|
||||
%% MCP → External Servers
|
||||
SV6 --> EXT1 & EXT2
|
||||
|
||||
%% Styling
|
||||
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
|
||||
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
|
||||
|
|
@ -95,12 +129,17 @@ flowchart TB
|
|||
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
|
||||
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
|
||||
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
||||
classDef mcpStyle fill:#e0f2f1,stroke:#00695c,stroke-width:2px
|
||||
classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px
|
||||
classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
|
||||
|
||||
class R1,R2,RL routeStyle
|
||||
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle
|
||||
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_ChatMessageAgenticContent,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle
|
||||
class C_McpSettings,C_McpResourceBrowser,C_McpServersSelector componentStyle
|
||||
class H1,H2 hookStyle
|
||||
class S1,S2,S3,S4,S5 storeStyle
|
||||
class SV1,SV2,SV3,SV4,SV5 serviceStyle
|
||||
class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
|
||||
class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
|
||||
class ST1,ST2 storageStyle
|
||||
class API1,API2,API3,API4 apiStyle
|
||||
class EXT1,EXT2 externalStyle
|
||||
```
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ end
|
|||
C_ModelsSelector["ModelsSelector"]
|
||||
C_Settings["ChatSettings"]
|
||||
end
|
||||
subgraph MCPComponents["MCP UI"]
|
||||
C_McpSettings["McpServersSettings"]
|
||||
C_McpServerCard["McpServerCard"]
|
||||
C_McpResourceBrowser["McpResourceBrowser"]
|
||||
C_McpResourcePreview["McpResourcePreview"]
|
||||
C_McpServersSelector["McpServersSelector"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Hooks["🪝 Hooks"]
|
||||
|
|
@ -43,14 +50,20 @@ end
|
|||
S1Edit["<b>Editing:</b><br/>editAssistantMessage()<br/>editUserMessagePreserveResponses()<br/>editMessageWithBranching()<br/>clearEditMode()<br/>isEditModeActive()<br/>getAddFilesHandler()<br/>setEditModeActive()"]
|
||||
S1Utils["<b>Utilities:</b><br/>getApiOptions()<br/>parseTimingData()<br/>getOrCreateAbortController()<br/>getConversationModel()"]
|
||||
end
|
||||
subgraph SA["agenticStore"]
|
||||
SAState["<b>State:</b><br/>sessions (Map)<br/>isAnyRunning"]
|
||||
SASession["<b>Session Management:</b><br/>getSession()<br/>updateSession()<br/>clearSession()<br/>getActiveSessions()<br/>isRunning()<br/>currentTurn()<br/>totalToolCalls()<br/>lastError()<br/>streamingToolCall()"]
|
||||
SAConfig["<b>Configuration:</b><br/>getConfig()<br/>maxTurns, maxToolPreviewLines"]
|
||||
SAFlow["<b>Agentic Loop:</b><br/>runAgenticFlow()<br/>executeAgenticLoop()<br/>normalizeToolCalls()<br/>emitToolCallResult()<br/>extractBase64Attachments()"]
|
||||
end
|
||||
subgraph S2["conversationsStore"]
|
||||
S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>usedModalities<br/>isInitialized<br/>titleUpdateConfirmationCallback"]
|
||||
S2Modal["<b>Modalities:</b><br/>getModalitiesUpToMessage()<br/>calculateModalitiesFromMessages()"]
|
||||
S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>isInitialized<br/>pendingMcpServerOverrides<br/>titleUpdateConfirmationCallback"]
|
||||
S2Lifecycle["<b>Lifecycle:</b><br/>initialize()<br/>loadConversations()<br/>clearActiveConversation()"]
|
||||
S2ConvCRUD["<b>Conversation CRUD:</b><br/>createConversation()<br/>loadConversation()<br/>deleteConversation()<br/>updateConversationName()<br/>updateConversationTitleWithConfirmation()"]
|
||||
S2ConvCRUD["<b>Conversation CRUD:</b><br/>createConversation()<br/>loadConversation()<br/>deleteConversation()<br/>deleteAll()<br/>updateConversationName()<br/>updateConversationTitleWithConfirmation()"]
|
||||
S2MsgMgmt["<b>Message Management:</b><br/>refreshActiveMessages()<br/>addMessageToActive()<br/>updateMessageAtIndex()<br/>findMessageIndex()<br/>sliceActiveMessages()<br/>removeMessageAtIndex()<br/>getConversationMessages()"]
|
||||
S2Nav["<b>Navigation:</b><br/>navigateToSibling()<br/>updateCurrentNode()<br/>updateConversationTimestamp()"]
|
||||
S2Export["<b>Import/Export:</b><br/>downloadConversation()<br/>exportAllConversations()<br/>importConversations()<br/>triggerDownload()"]
|
||||
S2McpOverrides["<b>MCP Per-Chat Overrides:</b><br/>getMcpServerOverride()<br/>getAllMcpServerOverrides()<br/>setMcpServerOverride()<br/>toggleMcpServerForChat()<br/>removeMcpServerOverride()<br/>isMcpServerEnabledForChat()<br/>clearPendingMcpServerOverrides()"]
|
||||
S2Export["<b>Import/Export:</b><br/>downloadConversation()<br/>exportAllConversations()<br/>importConversations()<br/>importConversationsData()<br/>triggerDownload()"]
|
||||
S2Utils["<b>Utilities:</b><br/>setTitleUpdateConfirmationCallback()"]
|
||||
end
|
||||
subgraph S3["modelsStore"]
|
||||
|
|
@ -77,6 +90,21 @@ end
|
|||
S5Sync["<b>Server Sync:</b><br/>syncWithServerDefaults()<br/>forceSyncWithServerDefaults()"]
|
||||
S5Utils["<b>Utilities:</b><br/>getConfig()<br/>getAllConfig()<br/>getParameterInfo()<br/>getParameterDiff()<br/>getServerDefaults()<br/>clearAllUserOverrides()"]
|
||||
end
|
||||
subgraph S6["mcpStore"]
|
||||
S6State["<b>State:</b><br/>isInitializing, error<br/>toolCount, connectedServers<br/>healthChecks (Map)<br/>connections (Map)<br/>toolsIndex (Map)"]
|
||||
S6Lifecycle["<b>Lifecycle:</b><br/>ensureInitialized()<br/>initialize()<br/>shutdown()<br/>acquireConnection()<br/>releaseConnection()"]
|
||||
S6Health["<b>Health Checks:</b><br/>runHealthCheck()<br/>runHealthChecksForServers()<br/>updateHealthCheck()<br/>getHealthCheckState()<br/>clearHealthCheck()"]
|
||||
S6Servers["<b>Server Management:</b><br/>getServers()<br/>addServer()<br/>updateServer()<br/>removeServer()<br/>getServerById()<br/>getServerDisplayName()"]
|
||||
S6Tools["<b>Tool Operations:</b><br/>getToolDefinitionsForLLM()<br/>getToolNames()<br/>hasTool()<br/>getToolServer()<br/>executeTool()<br/>executeToolByName()"]
|
||||
S6Prompts["<b>Prompt Operations:</b><br/>getAllPrompts()<br/>getPrompt()<br/>hasPromptsCapability()<br/>getPromptCompletions()"]
|
||||
end
|
||||
subgraph S7["mcpResourceStore"]
|
||||
S7State["<b>State:</b><br/>serverResources (Map)<br/>cachedResources (Map)<br/>subscriptions (Map)<br/>attachments[]<br/>isLoading"]
|
||||
S7Resources["<b>Resource Discovery:</b><br/>setServerResources()<br/>getServerResources()<br/>getAllResourceInfos()<br/>getAllTemplateInfos()<br/>clearServerResources()"]
|
||||
S7Cache["<b>Caching:</b><br/>cacheResourceContent()<br/>getCachedContent()<br/>invalidateCache()<br/>clearCache()"]
|
||||
S7Subs["<b>Subscriptions:</b><br/>addSubscription()<br/>removeSubscription()<br/>isSubscribed()<br/>handleResourceUpdate()"]
|
||||
S7Attach["<b>Attachments:</b><br/>addAttachment()<br/>updateAttachmentContent()<br/>removeAttachment()<br/>clearAttachments()<br/>toMessageExtras()"]
|
||||
end
|
||||
|
||||
subgraph ReactiveExports["⚡ Reactive Exports"]
|
||||
direction LR
|
||||
|
|
@ -95,12 +123,19 @@ end
|
|||
RE9c["setEditModeActive()"]
|
||||
RE9d["clearEditMode()"]
|
||||
end
|
||||
subgraph AgenticExports["agenticStore"]
|
||||
REA1["agenticIsRunning()"]
|
||||
REA2["agenticCurrentTurn()"]
|
||||
REA3["agenticTotalToolCalls()"]
|
||||
REA4["agenticLastError()"]
|
||||
REA5["agenticStreamingToolCall()"]
|
||||
REA6["agenticIsAnyRunning()"]
|
||||
end
|
||||
subgraph ConvExports["conversationsStore"]
|
||||
RE10["conversations()"]
|
||||
RE11["activeConversation()"]
|
||||
RE12["activeMessages()"]
|
||||
RE13["isConversationsInitialized()"]
|
||||
RE14["usedModalities()"]
|
||||
end
|
||||
subgraph ModelsExports["modelsStore"]
|
||||
RE15["modelOptions()"]
|
||||
|
|
@ -131,6 +166,13 @@ end
|
|||
RE36["theme()"]
|
||||
RE37["isInitialized()"]
|
||||
end
|
||||
subgraph MCPExports["mcpStore / mcpResourceStore"]
|
||||
RE38["mcpResources()"]
|
||||
RE39["mcpResourceAttachments()"]
|
||||
RE40["mcpHasResourceAttachments()"]
|
||||
RE41["mcpTotalResourceCount()"]
|
||||
RE42["mcpResourcesLoading()"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -138,9 +180,9 @@ end
|
|||
direction TB
|
||||
subgraph SV1["ChatService"]
|
||||
SV1Msg["<b>Messaging:</b><br/>sendMessage()"]
|
||||
SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>parseSSEChunk()"]
|
||||
SV1Convert["<b>Conversion:</b><br/>convertMessageToChatData()<br/>convertExtraToApiFormat()"]
|
||||
SV1Utils["<b>Utilities:</b><br/>extractReasoningContent()<br/>getServerProps()<br/>getModels()"]
|
||||
SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>handleNonStreamResponse()"]
|
||||
SV1Convert["<b>Conversion:</b><br/>convertDbMessageToApiChatMessageData()<br/>mergeToolCallDeltas()"]
|
||||
SV1Utils["<b>Utilities:</b><br/>stripReasoningContent()<br/>extractModelName()<br/>parseErrorResponse()"]
|
||||
end
|
||||
subgraph SV2["ModelsService"]
|
||||
SV2List["<b>Listing:</b><br/>list()<br/>listRouter()"]
|
||||
|
|
@ -152,7 +194,7 @@ end
|
|||
end
|
||||
subgraph SV4["DatabaseService"]
|
||||
SV4Conv["<b>Conversations:</b><br/>createConversation()<br/>getConversation()<br/>getAllConversations()<br/>updateConversation()<br/>deleteConversation()"]
|
||||
SV4Msg["<b>Messages:</b><br/>createMessageBranch()<br/>createRootMessage()<br/>getConversationMessages()<br/>updateMessage()<br/>deleteMessage()<br/>deleteMessageCascading()"]
|
||||
SV4Msg["<b>Messages:</b><br/>createMessageBranch()<br/>createRootMessage()<br/>createSystemMessage()<br/>getConversationMessages()<br/>updateMessage()<br/>deleteMessage()<br/>deleteMessageCascading()"]
|
||||
SV4Node["<b>Navigation:</b><br/>updateCurrentNode()"]
|
||||
SV4Import["<b>Import:</b><br/>importConversations()"]
|
||||
end
|
||||
|
|
@ -162,6 +204,19 @@ end
|
|||
SV5Info["<b>Info:</b><br/>getParameterInfo()<br/>canSyncParameter()<br/>getSyncableParameterKeys()<br/>validateServerParameter()"]
|
||||
SV5Diff["<b>Diff:</b><br/>createParameterDiff()"]
|
||||
end
|
||||
subgraph SV6["MCPService"]
|
||||
SV6Transport["<b>Transport:</b><br/>createTransport()<br/>WebSocket / StreamableHTTP / SSE"]
|
||||
SV6Conn["<b>Connection:</b><br/>connect()<br/>disconnect()"]
|
||||
SV6Tools["<b>Tools:</b><br/>listTools()<br/>callTool()"]
|
||||
SV6Prompts["<b>Prompts:</b><br/>listPrompts()<br/>getPrompt()"]
|
||||
SV6Resources["<b>Resources:</b><br/>listResources()<br/>listResourceTemplates()<br/>readResource()<br/>subscribeResource()<br/>unsubscribeResource()"]
|
||||
SV6Complete["<b>Completions:</b><br/>complete()"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph ExternalMCP["🔌 External MCP Servers"]
|
||||
EXT1["MCP Server 1<br/>(WebSocket/StreamableHTTP/SSE)"]
|
||||
EXT2["MCP Server N"]
|
||||
end
|
||||
|
||||
subgraph Storage["💾 Storage"]
|
||||
|
|
@ -171,6 +226,7 @@ end
|
|||
ST5["LocalStorage"]
|
||||
ST6["config"]
|
||||
ST7["userOverrides"]
|
||||
ST8["mcpServers"]
|
||||
end
|
||||
|
||||
subgraph APIs["🌐 llama-server API"]
|
||||
|
|
@ -185,6 +241,9 @@ end
|
|||
R2 --> C_Screen
|
||||
RL --> C_Sidebar
|
||||
|
||||
%% Layout runs MCP health checks on startup
|
||||
RL --> S6
|
||||
|
||||
%% Component hierarchy
|
||||
C_Screen --> C_Form & C_Messages & C_Settings
|
||||
C_Messages --> C_Message
|
||||
|
|
@ -194,8 +253,15 @@ end
|
|||
C_MessageEditForm --> C_Attach
|
||||
C_Form --> C_ModelsSelector
|
||||
C_Form --> C_Attach
|
||||
C_Form --> C_McpServersSelector
|
||||
C_Message --> C_Attach
|
||||
|
||||
%% MCP Components hierarchy
|
||||
C_Settings --> C_McpSettings
|
||||
C_McpSettings --> C_McpServerCard
|
||||
C_McpServerCard --> C_McpResourceBrowser
|
||||
C_McpResourceBrowser --> C_McpResourcePreview
|
||||
|
||||
%% Components use Hooks
|
||||
C_Form --> H1
|
||||
C_Message --> H1 & H2
|
||||
|
|
@ -210,17 +276,29 @@ end
|
|||
C_Screen --> S1 & S2
|
||||
C_Messages --> S2
|
||||
C_Message --> S1 & S2 & S3
|
||||
C_Form --> S1 & S3
|
||||
C_Form --> S1 & S3 & S6
|
||||
C_Sidebar --> S2
|
||||
C_ModelsSelector --> S3 & S4
|
||||
C_Settings --> S5
|
||||
C_McpSettings --> S6
|
||||
C_McpServerCard --> S6
|
||||
C_McpResourceBrowser --> S6 & S7
|
||||
C_McpServersSelector --> S6
|
||||
|
||||
%% Stores export Reactive State
|
||||
S1 -. exports .-> ChatExports
|
||||
SA -. exports .-> AgenticExports
|
||||
S2 -. exports .-> ConvExports
|
||||
S3 -. exports .-> ModelsExports
|
||||
S4 -. exports .-> ServerExports
|
||||
S5 -. exports .-> SettingsExports
|
||||
S6 -. exports .-> MCPExports
|
||||
S7 -. exports .-> MCPExports
|
||||
|
||||
%% chatStore → agenticStore (agentic loop orchestration)
|
||||
S1 --> SA
|
||||
SA --> SV1
|
||||
SA --> S6
|
||||
|
||||
%% Stores use Services
|
||||
S1 --> SV1 & SV4
|
||||
|
|
@ -228,28 +306,35 @@ end
|
|||
S3 --> SV2 & SV3
|
||||
S4 --> SV3
|
||||
S5 --> SV5
|
||||
S6 --> SV6
|
||||
S7 --> SV6
|
||||
|
||||
%% Services to Storage
|
||||
SV4 --> ST1
|
||||
ST1 --> ST2 & ST3
|
||||
SV5 --> ST5
|
||||
ST5 --> ST6 & ST7
|
||||
ST5 --> ST6 & ST7 & ST8
|
||||
|
||||
%% Services to APIs
|
||||
SV1 --> API1
|
||||
SV2 --> API3 & API4
|
||||
SV3 --> API2
|
||||
|
||||
%% MCP → External Servers
|
||||
SV6 --> EXT1 & EXT2
|
||||
|
||||
%% Styling
|
||||
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
|
||||
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
|
||||
classDef componentGroupStyle fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
|
||||
classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px
|
||||
classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
|
||||
classDef stateStyle fill:#ffe0b2,stroke:#e65100,stroke-width:1px
|
||||
classDef methodStyle fill:#ffecb3,stroke:#e65100,stroke-width:1px
|
||||
classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px
|
||||
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
|
||||
classDef serviceMStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
|
||||
classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
|
||||
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
|
||||
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
||||
|
||||
|
|
@ -257,23 +342,32 @@ end
|
|||
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageUser,C_MessageEditForm componentStyle
|
||||
class C_ModelsSelector,C_Settings componentStyle
|
||||
class C_Attach componentStyle
|
||||
class H1,H2,H3 methodStyle
|
||||
class LayoutComponents,ChatUIComponents componentGroupStyle
|
||||
class Hooks storeStyle
|
||||
class S1,S2,S3,S4,S5 storeStyle
|
||||
class S1State,S2State,S3State,S4State,S5State stateStyle
|
||||
class C_McpSettings,C_McpServerCard,C_McpResourceBrowser,C_McpResourcePreview,C_McpServersSelector componentStyle
|
||||
class H1,H2,H3 hookStyle
|
||||
class LayoutComponents,ChatUIComponents,MCPComponents componentGroupStyle
|
||||
class Hooks hookStyle
|
||||
classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px
|
||||
classDef agenticMethodStyle fill:#c5cae9,stroke:#283593,stroke-width:1px
|
||||
|
||||
class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
|
||||
class S1State,S2State,S3State,S4State,S5State,SAState,S6State,S7State stateStyle
|
||||
class S1Msg,S1Regen,S1Edit,S1Stream,S1LoadState,S1ProcState,S1Error,S1Utils methodStyle
|
||||
class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2Modal,S2Export,S2Utils methodStyle
|
||||
class SASession,SAConfig,SAFlow methodStyle
|
||||
class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2McpOverrides,S2Export,S2Utils methodStyle
|
||||
class S3Getters,S3Modal,S3Status,S3Fetch,S3Select,S3LoadUnload,S3Utils methodStyle
|
||||
class S4Getters,S4Data,S4Utils methodStyle
|
||||
class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle
|
||||
class ChatExports,ConvExports,ModelsExports,ServerExports,SettingsExports reactiveStyle
|
||||
class SV1,SV2,SV3,SV4,SV5 serviceStyle
|
||||
class S6Lifecycle,S6Health,S6Servers,S6Tools,S6Prompts methodStyle
|
||||
class S7Resources,S7Cache,S7Subs,S7Attach methodStyle
|
||||
class ChatExports,AgenticExports,ConvExports,ModelsExports,ServerExports,SettingsExports,MCPExports reactiveStyle
|
||||
class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
|
||||
class SV6Transport,SV6Conn,SV6Tools,SV6Prompts,SV6Resources,SV6Complete serviceMStyle
|
||||
class EXT1,EXT2 externalStyle
|
||||
class SV1Msg,SV1Stream,SV1Convert,SV1Utils serviceMStyle
|
||||
class SV2List,SV2LoadUnload,SV2Status serviceMStyle
|
||||
class SV3Fetch serviceMStyle
|
||||
class SV4Conv,SV4Msg,SV4Node,SV4Import serviceMStyle
|
||||
class SV5Extract,SV5Merge,SV5Info,SV5Diff serviceMStyle
|
||||
class ST1,ST2,ST3,ST5,ST6,ST7 storageStyle
|
||||
class ST1,ST2,ST3,ST5,ST6,ST7,ST8 storageStyle
|
||||
class API1,API2,API3,API4 apiStyle
|
||||
```
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
sequenceDiagram
|
||||
participant UI as 🧩 ChatForm / ChatMessage
|
||||
participant chatStore as 🗄️ chatStore
|
||||
participant agenticStore as 🗄️ agenticStore
|
||||
participant convStore as 🗄️ conversationsStore
|
||||
participant settingsStore as 🗄️ settingsStore
|
||||
participant mcpStore as 🗄️ mcpStore
|
||||
participant ChatSvc as ⚙️ ChatService
|
||||
participant DbSvc as ⚙️ DatabaseService
|
||||
participant API as 🌐 /v1/chat/completions
|
||||
|
|
@ -25,6 +27,9 @@ sequenceDiagram
|
|||
Note over convStore: → see conversations-flow.mmd
|
||||
end
|
||||
|
||||
chatStore->>mcpStore: consumeResourceAttachmentsAsExtras()
|
||||
Note right of mcpStore: Converts pending MCP resource<br/>attachments into message extras
|
||||
|
||||
chatStore->>chatStore: addMessage("user", content, extras)
|
||||
chatStore->>DbSvc: createMessageBranch(userMsg, parentId)
|
||||
chatStore->>convStore: addMessageToActive(userMsg)
|
||||
|
|
@ -38,7 +43,7 @@ sequenceDiagram
|
|||
deactivate chatStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,API: 🌊 STREAMING
|
||||
Note over UI,API: 🌊 STREAMING (with agentic flow detection)
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
activate chatStore
|
||||
|
|
@ -52,10 +57,17 @@ sequenceDiagram
|
|||
chatStore->>chatStore: getApiOptions()
|
||||
Note right of chatStore: Merge from settingsStore.config:<br/>temperature, max_tokens, top_p, etc.
|
||||
|
||||
chatStore->>ChatSvc: sendMessage(messages, options, signal)
|
||||
alt agenticConfig.enabled && mcpStore has connected servers
|
||||
chatStore->>agenticStore: runAgenticFlow(convId, messages, assistantMsg, options, signal)
|
||||
Note over agenticStore: Multi-turn agentic loop:<br/>1. Call ChatService.sendMessage()<br/>2. If response has tool_calls → execute via mcpStore<br/>3. Append tool results as messages<br/>4. Loop until no more tool_calls or maxTurns<br/>→ see agentic flow details below
|
||||
agenticStore-->>chatStore: final response with timings
|
||||
else standard (non-agentic) flow
|
||||
chatStore->>ChatSvc: sendMessage(messages, options, signal)
|
||||
end
|
||||
|
||||
activate ChatSvc
|
||||
|
||||
ChatSvc->>ChatSvc: convertMessageToChatData(messages)
|
||||
ChatSvc->>ChatSvc: convertDbMessageToApiChatMessageData(messages)
|
||||
Note right of ChatSvc: DatabaseMessage[] → ApiChatMessageData[]<br/>Process attachments (images, PDFs, audio)
|
||||
|
||||
ChatSvc->>API: POST /v1/chat/completions
|
||||
|
|
@ -63,7 +75,7 @@ sequenceDiagram
|
|||
|
||||
loop SSE chunks
|
||||
API-->>ChatSvc: data: {"choices":[{"delta":{...}}]}
|
||||
ChatSvc->>ChatSvc: parseSSEChunk(line)
|
||||
ChatSvc->>ChatSvc: handleStreamResponse(response)
|
||||
|
||||
alt content chunk
|
||||
ChatSvc-->>chatStore: onChunk(content)
|
||||
|
|
@ -154,12 +166,15 @@ sequenceDiagram
|
|||
Note over UI,API: ✏️ EDIT USER MESSAGE
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
UI->>chatStore: editUserMessagePreserveResponses(msgId, newContent)
|
||||
UI->>chatStore: editMessageWithBranching(msgId, newContent, extras)
|
||||
activate chatStore
|
||||
chatStore->>chatStore: Get parent of target message
|
||||
chatStore->>DbSvc: createMessageBranch(editedMsg, parentId)
|
||||
chatStore->>convStore: refreshActiveMessages()
|
||||
Note right of chatStore: Creates new branch, original preserved
|
||||
chatStore->>chatStore: createAssistantMessage(editedMsg.id)
|
||||
chatStore->>chatStore: streamChatCompletion(...)
|
||||
Note right of chatStore: Automatically regenerates response
|
||||
deactivate chatStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -171,4 +186,43 @@ sequenceDiagram
|
|||
Note right of chatStore: errorDialogState = {type: 'timeout'|'server', message}
|
||||
chatStore->>convStore: removeMessageAtIndex(failedMsgIdx)
|
||||
chatStore->>DbSvc: deleteMessage(failedMsgId)
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,API: 🤖 AGENTIC LOOP (when agenticConfig.enabled)
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Note over agenticStore: agenticStore.runAgenticFlow(convId, messages, assistantMsg, options, signal)
|
||||
activate agenticStore
|
||||
agenticStore->>agenticStore: getSession(convId) or create new
|
||||
agenticStore->>agenticStore: updateSession(turn: 0, running: true)
|
||||
|
||||
loop executeAgenticLoop (until no tool_calls or maxTurns)
|
||||
agenticStore->>agenticStore: turn++
|
||||
agenticStore->>ChatSvc: sendMessage(messages, options, signal)
|
||||
ChatSvc->>API: POST /v1/chat/completions
|
||||
API-->>ChatSvc: response with potential tool_calls
|
||||
ChatSvc-->>agenticStore: onComplete(content, reasoning, timings, toolCalls)
|
||||
|
||||
alt response has tool_calls
|
||||
agenticStore->>agenticStore: normalizeToolCalls(toolCalls)
|
||||
loop for each tool_call
|
||||
agenticStore->>agenticStore: updateSession(streamingToolCall)
|
||||
agenticStore->>mcpStore: executeTool(mcpCall, signal)
|
||||
mcpStore-->>agenticStore: tool result
|
||||
agenticStore->>agenticStore: extractBase64Attachments(result)
|
||||
agenticStore->>agenticStore: emitToolCallResult(convId, ...)
|
||||
agenticStore->>convStore: addMessageToActive(toolResultMsg)
|
||||
agenticStore->>DbSvc: createMessageBranch(toolResultMsg)
|
||||
end
|
||||
agenticStore->>agenticStore: Create new assistantMsg for next turn
|
||||
Note right of agenticStore: Continue loop with updated messages
|
||||
else no tool_calls (final response)
|
||||
agenticStore->>agenticStore: buildFinalTimings(allTurns)
|
||||
Note right of agenticStore: Break loop, return final response
|
||||
end
|
||||
end
|
||||
|
||||
agenticStore->>agenticStore: updateSession(running: false)
|
||||
agenticStore-->>chatStore: final content, timings, model
|
||||
deactivate agenticStore
|
||||
```
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ sequenceDiagram
|
|||
participant DbSvc as ⚙️ DatabaseService
|
||||
participant IDB as 💾 IndexedDB
|
||||
|
||||
Note over convStore: State:<br/>conversations: DatabaseConversation[]<br/>activeConversation: DatabaseConversation | null<br/>activeMessages: DatabaseMessage[]<br/>isInitialized: boolean<br/>usedModalities: $derived({vision, audio})
|
||||
Note over convStore: State:<br/>conversations: DatabaseConversation[]<br/>activeConversation: DatabaseConversation | null<br/>activeMessages: DatabaseMessage[]<br/>isInitialized: boolean<br/>pendingMcpServerOverrides: Map<string, McpServerOverride>
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,IDB: 🚀 INITIALIZATION
|
||||
|
|
@ -37,6 +37,13 @@ sequenceDiagram
|
|||
convStore->>convStore: conversations.unshift(conversation)
|
||||
convStore->>convStore: activeConversation = $state(conversation)
|
||||
convStore->>convStore: activeMessages = $state([])
|
||||
|
||||
alt pendingMcpServerOverrides has entries
|
||||
loop each pending override
|
||||
convStore->>DbSvc: Store MCP server override for new conversation
|
||||
end
|
||||
convStore->>convStore: clearPendingMcpServerOverrides()
|
||||
end
|
||||
deactivate convStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -58,8 +65,7 @@ sequenceDiagram
|
|||
Note right of convStore: Filter to show only current branch path
|
||||
convStore->>convStore: activeMessages = $state(filtered)
|
||||
|
||||
convStore->>chatStore: syncLoadingStateForChat(convId)
|
||||
Note right of chatStore: Sync isLoading/currentResponse if streaming
|
||||
Note right of convStore: Route (+page.svelte) then calls:<br/>chatStore.syncLoadingStateForChat(convId)
|
||||
deactivate convStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -121,16 +127,36 @@ sequenceDiagram
|
|||
end
|
||||
deactivate convStore
|
||||
|
||||
UI->>convStore: deleteAll()
|
||||
activate convStore
|
||||
convStore->>DbSvc: Delete all conversations and messages
|
||||
convStore->>convStore: conversations = []
|
||||
convStore->>convStore: clearActiveConversation()
|
||||
deactivate convStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,IDB: 📊 MODALITY TRACKING
|
||||
Note over UI,IDB: <EFBFBD> MCP SERVER PER-CHAT OVERRIDES
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Note over convStore: usedModalities = $derived.by(() => {<br/> calculateModalitiesFromMessages(activeMessages)<br/>})
|
||||
Note over convStore: Conversations can override which MCP servers are enabled.
|
||||
Note over convStore: Uses pendingMcpServerOverrides before conversation<br/>is created, then persists to conversation metadata.
|
||||
|
||||
Note over convStore: Scans activeMessages for attachments:<br/>- IMAGE → vision: true<br/>- PDF (processedAsImages) → vision: true<br/>- AUDIO → audio: true
|
||||
UI->>convStore: setMcpServerOverride(convId, serverName, override)
|
||||
Note right of convStore: override = {enabled: boolean}
|
||||
|
||||
UI->>convStore: getModalitiesUpToMessage(msgId)
|
||||
Note right of convStore: Used for regeneration validation<br/>Only checks messages BEFORE target
|
||||
UI->>convStore: toggleMcpServerForChat(convId, serverName, enabled)
|
||||
activate convStore
|
||||
convStore->>convStore: setMcpServerOverride(convId, serverName, {enabled})
|
||||
deactivate convStore
|
||||
|
||||
UI->>convStore: isMcpServerEnabledForChat(convId, serverName)
|
||||
Note right of convStore: Check override → fall back to global MCP config
|
||||
|
||||
UI->>convStore: getAllMcpServerOverrides(convId)
|
||||
Note right of convStore: Returns all overrides for a conversation
|
||||
|
||||
UI->>convStore: removeMcpServerOverride(convId, serverName)
|
||||
UI->>convStore: getMcpServerOverride(convId, serverName)
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,IDB: 📤 EXPORT / 📥 IMPORT
|
||||
|
|
@ -148,8 +174,10 @@ sequenceDiagram
|
|||
UI->>convStore: importConversations(file)
|
||||
activate convStore
|
||||
convStore->>convStore: Parse JSON file
|
||||
convStore->>convStore: importConversationsData(parsed)
|
||||
convStore->>DbSvc: importConversations(parsed)
|
||||
DbSvc->>IDB: Bulk INSERT conversations + messages
|
||||
Note right of DbSvc: Skips duplicate conversations<br/>(checks existing by ID)
|
||||
DbSvc->>IDB: INSERT conversations + messages (skip existing)
|
||||
convStore->>convStore: loadConversations()
|
||||
deactivate convStore
|
||||
```
|
||||
|
|
|
|||
|
|
@ -66,6 +66,14 @@ sequenceDiagram
|
|||
DbSvc-->>Store: rootMessageId
|
||||
deactivate DbSvc
|
||||
|
||||
Store->>DbSvc: createSystemMessage(convId, content, parentId)
|
||||
activate DbSvc
|
||||
DbSvc->>DbSvc: Create message {role: "system", parent: parentId}
|
||||
DbSvc->>Dexie: db.messages.add(systemMsg)
|
||||
Dexie->>IDB: INSERT
|
||||
DbSvc-->>Store: DatabaseMessage
|
||||
deactivate DbSvc
|
||||
|
||||
Store->>DbSvc: createMessageBranch(message, parentId)
|
||||
activate DbSvc
|
||||
DbSvc->>DbSvc: Generate UUID for new message
|
||||
|
|
@ -116,6 +124,13 @@ sequenceDiagram
|
|||
end
|
||||
DbSvc->>Dexie: db.messages.delete(msgId)
|
||||
Dexie->>IDB: DELETE target message
|
||||
|
||||
alt target message has a parent
|
||||
DbSvc->>Dexie: db.messages.get(parentId)
|
||||
DbSvc->>DbSvc: parent.children.filter(id !== msgId)
|
||||
DbSvc->>Dexie: db.messages.update(parentId, {children})
|
||||
Note right of DbSvc: Remove deleted message from parent's children[]
|
||||
end
|
||||
deactivate DbSvc
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -125,12 +140,16 @@ sequenceDiagram
|
|||
Store->>DbSvc: importConversations(data)
|
||||
activate DbSvc
|
||||
loop each conversation in data
|
||||
DbSvc->>DbSvc: Generate new UUIDs (avoid conflicts)
|
||||
DbSvc->>Dexie: db.conversations.add(conversation)
|
||||
Dexie->>IDB: INSERT conversation
|
||||
loop each message
|
||||
DbSvc->>Dexie: db.messages.add(message)
|
||||
Dexie->>IDB: INSERT message
|
||||
DbSvc->>Dexie: db.conversations.get(conv.id)
|
||||
alt conversation already exists
|
||||
Note right of DbSvc: Skip duplicate (keep existing)
|
||||
else conversation is new
|
||||
DbSvc->>Dexie: db.conversations.add(conversation)
|
||||
Dexie->>IDB: INSERT conversation
|
||||
loop each message
|
||||
DbSvc->>Dexie: db.messages.add(message)
|
||||
Dexie->>IDB: INSERT message
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate DbSvc
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as 🧩 McpServersSettings / ChatForm
|
||||
participant chatStore as 🗄️ chatStore
|
||||
participant mcpStore as 🗄️ mcpStore
|
||||
participant mcpResStore as 🗄️ mcpResourceStore
|
||||
participant convStore as 🗄️ conversationsStore
|
||||
participant MCPSvc as ⚙️ MCPService
|
||||
participant LS as 💾 LocalStorage
|
||||
participant ExtMCP as 🔌 External MCP Server
|
||||
|
||||
Note over mcpStore: State:<br/>isInitializing, error<br/>toolCount, connectedServers<br/>healthChecks (Map)<br/>connections (Map)<br/>toolsIndex (Map)<br/>serverConfigs (Map)
|
||||
|
||||
Note over mcpResStore: State:<br/>serverResources (Map)<br/>cachedResources (Map)<br/>subscriptions (Map)<br/>attachments[]
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,ExtMCP: 🚀 INITIALIZATION (App Startup)
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
UI->>mcpStore: ensureInitialized()
|
||||
activate mcpStore
|
||||
|
||||
mcpStore->>LS: get(MCP_SERVERS_LOCALSTORAGE_KEY)
|
||||
LS-->>mcpStore: MCPServerSettingsEntry[]
|
||||
|
||||
mcpStore->>mcpStore: parseServerSettings(servers)
|
||||
Note right of mcpStore: Filter enabled servers<br/>Build MCPServerConfig objects<br/>Per-chat overrides checked via convStore
|
||||
|
||||
loop For each enabled server
|
||||
mcpStore->>mcpStore: runHealthCheck(serverId)
|
||||
mcpStore->>mcpStore: updateHealthCheck(id, CONNECTING)
|
||||
|
||||
mcpStore->>MCPSvc: connect(serverName, config, clientInfo, capabilities, onPhase)
|
||||
activate MCPSvc
|
||||
|
||||
MCPSvc->>MCPSvc: createTransport(config)
|
||||
Note right of MCPSvc: WebSocket / StreamableHTTP / SSE<br/>with optional CORS proxy
|
||||
|
||||
MCPSvc->>ExtMCP: Transport handshake
|
||||
ExtMCP-->>MCPSvc: Connection established
|
||||
|
||||
MCPSvc->>ExtMCP: Initialize request
|
||||
Note right of ExtMCP: Exchange capabilities<br/>Server info, protocol version
|
||||
|
||||
ExtMCP-->>MCPSvc: InitializeResult (serverInfo, capabilities)
|
||||
|
||||
MCPSvc->>ExtMCP: listTools()
|
||||
ExtMCP-->>MCPSvc: Tool[]
|
||||
|
||||
MCPSvc-->>mcpStore: MCPConnection
|
||||
deactivate MCPSvc
|
||||
|
||||
mcpStore->>mcpStore: connections.set(serverName, connection)
|
||||
mcpStore->>mcpStore: indexTools(connection.tools, serverName)
|
||||
Note right of mcpStore: toolsIndex.set(toolName, serverName)<br/>Handle name conflicts with prefixes
|
||||
|
||||
mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS)
|
||||
mcpStore->>mcpStore: _connectedServers.push(serverName)
|
||||
|
||||
alt Server supports resources
|
||||
mcpStore->>MCPSvc: listAllResources(connection)
|
||||
MCPSvc->>ExtMCP: listResources()
|
||||
ExtMCP-->>MCPSvc: MCPResource[]
|
||||
MCPSvc-->>mcpStore: resources
|
||||
|
||||
mcpStore->>MCPSvc: listAllResourceTemplates(connection)
|
||||
MCPSvc->>ExtMCP: listResourceTemplates()
|
||||
ExtMCP-->>MCPSvc: MCPResourceTemplate[]
|
||||
MCPSvc-->>mcpStore: templates
|
||||
|
||||
mcpStore->>mcpResStore: setServerResources(serverName, resources, templates)
|
||||
end
|
||||
end
|
||||
|
||||
mcpStore->>mcpStore: _isInitializing = false
|
||||
deactivate mcpStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,ExtMCP: 🔧 TOOL EXECUTION (Chat with Tools)
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
UI->>mcpStore: executeTool(mcpCall: MCPToolCall, signal?)
|
||||
activate mcpStore
|
||||
|
||||
mcpStore->>mcpStore: toolsIndex.get(mcpCall.function.name)
|
||||
Note right of mcpStore: Resolve serverName from toolsIndex<br/>MCPToolCall = {id, type, function: {name, arguments}}
|
||||
|
||||
mcpStore->>mcpStore: acquireConnection()
|
||||
Note right of mcpStore: activeFlowCount++<br/>Prevent shutdown during execution
|
||||
|
||||
mcpStore->>mcpStore: connection = connections.get(serverName)
|
||||
|
||||
mcpStore->>MCPSvc: callTool(connection, {name, arguments}, signal)
|
||||
activate MCPSvc
|
||||
|
||||
MCPSvc->>MCPSvc: throwIfAborted(signal)
|
||||
MCPSvc->>ExtMCP: callTool(name, arguments)
|
||||
|
||||
alt Tool execution success
|
||||
ExtMCP-->>MCPSvc: ToolCallResult (content, isError)
|
||||
MCPSvc->>MCPSvc: formatToolResult(result)
|
||||
Note right of MCPSvc: Handle text, image (base64),<br/>embedded resource content
|
||||
MCPSvc-->>mcpStore: ToolExecutionResult
|
||||
else Tool execution error
|
||||
ExtMCP-->>MCPSvc: Error
|
||||
MCPSvc-->>mcpStore: throw Error
|
||||
else Aborted
|
||||
MCPSvc-->>mcpStore: throw AbortError
|
||||
end
|
||||
|
||||
deactivate MCPSvc
|
||||
|
||||
mcpStore->>mcpStore: releaseConnection()
|
||||
Note right of mcpStore: activeFlowCount--
|
||||
|
||||
mcpStore-->>UI: ToolExecutionResult
|
||||
deactivate mcpStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,ExtMCP: <20> RESOURCE ATTACHMENT CONSUMPTION
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
chatStore->>mcpStore: consumeResourceAttachmentsAsExtras()
|
||||
activate mcpStore
|
||||
mcpStore->>mcpResStore: getAttachments()
|
||||
mcpResStore-->>mcpStore: MCPResourceAttachment[]
|
||||
mcpStore->>mcpStore: Convert attachments to message extras
|
||||
mcpStore->>mcpResStore: clearAttachments()
|
||||
mcpStore-->>chatStore: MessageExtra[] (for user message)
|
||||
deactivate mcpStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,ExtMCP: <20>📝 PROMPT OPERATIONS
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
UI->>mcpStore: getAllPrompts()
|
||||
activate mcpStore
|
||||
|
||||
loop For each connected server with prompts capability
|
||||
mcpStore->>MCPSvc: listPrompts(connection)
|
||||
MCPSvc->>ExtMCP: listPrompts()
|
||||
ExtMCP-->>MCPSvc: Prompt[]
|
||||
MCPSvc-->>mcpStore: prompts
|
||||
end
|
||||
|
||||
mcpStore-->>UI: MCPPromptInfo[] (with serverName)
|
||||
deactivate mcpStore
|
||||
|
||||
UI->>mcpStore: getPrompt(serverName, promptName, args?)
|
||||
activate mcpStore
|
||||
|
||||
mcpStore->>MCPSvc: getPrompt(connection, name, args)
|
||||
MCPSvc->>ExtMCP: getPrompt({name, arguments})
|
||||
ExtMCP-->>MCPSvc: GetPromptResult (messages)
|
||||
MCPSvc-->>mcpStore: GetPromptResult
|
||||
|
||||
mcpStore-->>UI: GetPromptResult
|
||||
deactivate mcpStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,ExtMCP: 📁 RESOURCE OPERATIONS
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
UI->>mcpResStore: addAttachment(resourceInfo)
|
||||
activate mcpResStore
|
||||
mcpResStore->>mcpResStore: Create MCPResourceAttachment (loading: true)
|
||||
mcpResStore-->>UI: attachment
|
||||
|
||||
UI->>mcpStore: readResource(serverName, uri)
|
||||
activate mcpStore
|
||||
|
||||
mcpStore->>MCPSvc: readResource(connection, uri)
|
||||
MCPSvc->>ExtMCP: readResource({uri})
|
||||
ExtMCP-->>MCPSvc: MCPReadResourceResult (contents)
|
||||
MCPSvc-->>mcpStore: contents
|
||||
|
||||
mcpStore-->>UI: MCPResourceContent[]
|
||||
deactivate mcpStore
|
||||
|
||||
UI->>mcpResStore: updateAttachmentContent(attachmentId, content)
|
||||
mcpResStore->>mcpResStore: cacheResourceContent(resource, content)
|
||||
deactivate mcpResStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,ExtMCP: 🔄 AUTO-RECONNECTION
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Note over mcpStore: On WebSocket close or connection error:
|
||||
mcpStore->>mcpStore: autoReconnect(serverName, attempt)
|
||||
activate mcpStore
|
||||
|
||||
mcpStore->>mcpStore: Calculate backoff delay
|
||||
Note right of mcpStore: delay = min(30s, 1s * 2^attempt)
|
||||
|
||||
mcpStore->>mcpStore: Wait for delay
|
||||
mcpStore->>mcpStore: reconnectServer(serverName)
|
||||
|
||||
alt Reconnection success
|
||||
mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS)
|
||||
else Max attempts reached
|
||||
mcpStore->>mcpStore: updateHealthCheck(id, ERROR)
|
||||
end
|
||||
deactivate mcpStore
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
Note over UI,ExtMCP: 🛑 SHUTDOWN
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
UI->>mcpStore: shutdown()
|
||||
activate mcpStore
|
||||
|
||||
mcpStore->>mcpStore: Wait for activeFlowCount == 0
|
||||
|
||||
loop For each connection
|
||||
mcpStore->>MCPSvc: disconnect(connection)
|
||||
MCPSvc->>MCPSvc: transport.onclose = undefined
|
||||
MCPSvc->>ExtMCP: close()
|
||||
end
|
||||
|
||||
mcpStore->>mcpStore: connections.clear()
|
||||
mcpStore->>mcpStore: toolsIndex.clear()
|
||||
mcpStore->>mcpStore: _connectedServers = []
|
||||
|
||||
mcpStore->>mcpResStore: clear()
|
||||
deactivate mcpStore
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -79,6 +79,7 @@
|
|||
"vitest-browser-svelte": "^0.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pdfjs-dist": "^5.4.54",
|
||||
|
|
@ -90,6 +91,7 @@
|
|||
"remark-html": "^16.0.1",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,21 +6,22 @@
|
|||
id: string;
|
||||
onRemove?: (id: string) => void;
|
||||
class?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
let { id, onRemove, class: className = '' }: Props = $props();
|
||||
let { id, onRemove, class: className = '', iconSize = 3 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}"
|
||||
size="icon-sm"
|
||||
class="bg-white/20 p-0 hover:bg-white/30 {className}"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.(id);
|
||||
}}
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
<X class="h-{iconSize} w-{iconSize}" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { ChatMessageMcpPromptContent, ActionIconRemove } from '$lib/components/app';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
import { McpPromptVariant } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
prompt: DatabaseMessageExtraMcpPrompt;
|
||||
readonly?: boolean;
|
||||
isLoading?: boolean;
|
||||
loadError?: string;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
prompt,
|
||||
readonly = false,
|
||||
isLoading = false,
|
||||
loadError,
|
||||
onRemove
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="group relative {className}">
|
||||
<ChatMessageMcpPromptContent
|
||||
{prompt}
|
||||
variant={McpPromptVariant.ATTACHMENT}
|
||||
{isLoading}
|
||||
{loadError}
|
||||
/>
|
||||
|
||||
{#if !readonly && onRemove}
|
||||
<div
|
||||
class="absolute top-10 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<ActionIconRemove id={prompt.name} onRemove={() => onRemove?.()} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<script lang="ts">
|
||||
import { Loader2, AlertCircle } from '@lucide/svelte';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { MCPResourceAttachment } from '$lib/types';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ActionIconRemove } from '$lib/components/app';
|
||||
import { getResourceIcon, getResourceDisplayName } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
attachment: MCPResourceAttachment;
|
||||
onRemove?: (attachmentId: string) => void;
|
||||
onClick?: () => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { attachment, onRemove, onClick, class: className }: Props = $props();
|
||||
|
||||
function getStatusClass(attachment: MCPResourceAttachment): string {
|
||||
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
|
||||
if (attachment.loading) return 'border-border/50 bg-muted/30';
|
||||
return 'border-border/50 bg-muted/30';
|
||||
}
|
||||
|
||||
const ResourceIcon = $derived(
|
||||
getResourceIcon(attachment.resource.mimeType, attachment.resource.uri)
|
||||
);
|
||||
const serverName = $derived(mcpStore.getServerDisplayName(attachment.resource.serverName));
|
||||
const favicon = $derived(mcpStore.getServerFavicon(attachment.resource.serverName));
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.75 text-sm transition-colors',
|
||||
getStatusClass(attachment),
|
||||
onClick && 'cursor-pointer hover:bg-muted/50',
|
||||
className
|
||||
)}
|
||||
onclick={onClick}
|
||||
disabled={!onClick}
|
||||
>
|
||||
{#if attachment.loading}
|
||||
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
{:else if attachment.error}
|
||||
<AlertCircle class="h-3 w-3 text-red-500" />
|
||||
{:else}
|
||||
<ResourceIcon class="h-3 w-3 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span class="max-w-[150px] truncate text-xs">
|
||||
{getResourceDisplayName(attachment.resource)}
|
||||
</span>
|
||||
|
||||
{#if onRemove}
|
||||
<ActionIconRemove
|
||||
class="-my-2 -mr-1.5 bg-transparent"
|
||||
iconSize={2}
|
||||
id={attachment.id}
|
||||
{onRemove}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span class="truncate">
|
||||
{serverName}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import {
|
||||
mcpResourceAttachments,
|
||||
mcpHasResourceAttachments
|
||||
} from '$lib/stores/mcp-resources.svelte';
|
||||
import { ChatAttachmentMcpResource, HorizontalScrollCarousel } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onResourceClick?: (uri: string) => void;
|
||||
}
|
||||
|
||||
let { class: className, onResourceClick }: Props = $props();
|
||||
|
||||
const attachments = $derived(mcpResourceAttachments());
|
||||
const hasAttachments = $derived(mcpHasResourceAttachments());
|
||||
|
||||
function handleRemove(attachmentId: string) {
|
||||
mcpStore.removeResourceAttachment(attachmentId);
|
||||
}
|
||||
|
||||
function handleResourceClick(uri: string) {
|
||||
onResourceClick?.(uri);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if hasAttachments}
|
||||
<div class={className}>
|
||||
<HorizontalScrollCarousel gapSize="2">
|
||||
{#each attachments as attachment, i (attachment.id)}
|
||||
<ChatAttachmentMcpResource
|
||||
class={i === 0 ? 'ml-3' : ''}
|
||||
{attachment}
|
||||
onRemove={handleRemove}
|
||||
onClick={() => handleResourceClick(attachment.resource.uri)}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalScrollCarousel>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,12 +1,21 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ChatAttachmentMcpPrompt,
|
||||
ChatAttachmentMcpResource,
|
||||
ChatAttachmentThumbnailImage,
|
||||
ChatAttachmentThumbnailFile,
|
||||
HorizontalScrollCarousel,
|
||||
DialogChatAttachmentPreview,
|
||||
DialogChatAttachmentsViewAll
|
||||
DialogChatAttachmentsViewAll,
|
||||
DialogMcpResourcePreview
|
||||
} from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type {
|
||||
DatabaseMessageExtraMcpPrompt,
|
||||
DatabaseMessageExtraMcpResource,
|
||||
MCPResourceAttachment
|
||||
} from '$lib/types';
|
||||
import { getAttachmentDisplayItems } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -49,6 +58,8 @@
|
|||
let isScrollable = $state(false);
|
||||
let previewDialogOpen = $state(false);
|
||||
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
|
||||
let mcpResourcePreviewOpen = $state(false);
|
||||
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
|
||||
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
|
||||
let viewAllDialogOpen = $state(false);
|
||||
|
||||
|
|
@ -67,6 +78,26 @@
|
|||
previewDialogOpen = true;
|
||||
}
|
||||
|
||||
function openMcpResourcePreview(extra: DatabaseMessageExtraMcpResource) {
|
||||
mcpResourcePreviewExtra = extra;
|
||||
mcpResourcePreviewOpen = true;
|
||||
}
|
||||
|
||||
function toMcpResourceAttachment(
|
||||
extra: DatabaseMessageExtraMcpResource,
|
||||
id: string
|
||||
): MCPResourceAttachment {
|
||||
return {
|
||||
id,
|
||||
resource: {
|
||||
uri: extra.uri,
|
||||
name: extra.name,
|
||||
title: extra.name,
|
||||
serverName: extra.serverName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (carouselRef && displayItems.length) {
|
||||
carouselRef.resetScroll();
|
||||
|
|
@ -82,7 +113,41 @@
|
|||
onScrollableChange={(scrollable) => (isScrollable = scrollable)}
|
||||
>
|
||||
{#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}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else if item.isMcpResource && item.attachment?.type === AttachmentType.MCP_RESOURCE}
|
||||
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
|
||||
|
||||
<ChatAttachmentMcpResource
|
||||
class="flex-shrink-0 {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||
attachment={toMcpResourceAttachment(mcpResource, item.id)}
|
||||
onClick={() => openMcpResourcePreview(mcpResource)}
|
||||
/>
|
||||
{:else if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||
id={item.id}
|
||||
|
|
@ -128,7 +193,39 @@
|
|||
{: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}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else if item.isMcpResource && item.attachment?.type === AttachmentType.MCP_RESOURCE}
|
||||
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
|
||||
|
||||
<ChatAttachmentMcpResource
|
||||
attachment={toMcpResourceAttachment(mcpResource, item.id)}
|
||||
onClick={() => openMcpResourcePreview(mcpResource)}
|
||||
/>
|
||||
{:else if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="cursor-pointer"
|
||||
id={item.id}
|
||||
|
|
@ -184,3 +281,7 @@
|
|||
{imageClass}
|
||||
{activeModelId}
|
||||
/>
|
||||
|
||||
{#if mcpResourcePreviewExtra}
|
||||
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,39 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ChatAttachmentsList,
|
||||
ChatAttachmentMcpResources,
|
||||
ChatFormActions,
|
||||
ChatFormFileInputInvisible,
|
||||
ChatFormPromptPicker,
|
||||
ChatFormResourcePicker,
|
||||
ChatFormTextarea
|
||||
} from '$lib/components/app';
|
||||
import { DialogMcpResources } from '$lib/components/app/dialogs';
|
||||
import {
|
||||
CLIPBOARD_CONTENT_QUOTE_PREFIX,
|
||||
INPUT_CLASSES,
|
||||
SETTING_CONFIG_DEFAULT
|
||||
SETTING_CONFIG_DEFAULT,
|
||||
INITIAL_FILE_SIZE,
|
||||
PROMPT_CONTENT_SEPARATOR,
|
||||
PROMPT_TRIGGER_PREFIX,
|
||||
RESOURCE_TRIGGER_PREFIX
|
||||
} from '$lib/constants';
|
||||
import { KeyboardKey, MimeTypeText } from '$lib/enums';
|
||||
import {
|
||||
ContentPartType,
|
||||
FileExtensionText,
|
||||
KeyboardKey,
|
||||
MimeTypeText,
|
||||
SpecialFileType
|
||||
} 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 { activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
|
||||
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
|
||||
import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
|
||||
import {
|
||||
AudioRecorder,
|
||||
convertToWav,
|
||||
|
|
@ -36,6 +53,7 @@
|
|||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholder?: string;
|
||||
showMcpPromptButton?: boolean;
|
||||
|
||||
// Event Handlers
|
||||
onAttachmentRemove?: (index: number) => void;
|
||||
|
|
@ -44,6 +62,7 @@
|
|||
onSubmit?: () => void;
|
||||
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
|
||||
onUploadedFileRemove?: (fileId: string) => void;
|
||||
onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +72,7 @@
|
|||
disabled = false,
|
||||
isLoading = false,
|
||||
placeholder = 'Type a message...',
|
||||
showMcpPromptButton = false,
|
||||
uploadedFiles = $bindable([]),
|
||||
value = $bindable(''),
|
||||
onAttachmentRemove,
|
||||
|
|
@ -61,6 +81,7 @@
|
|||
onSubmit,
|
||||
onSystemPromptClick,
|
||||
onUploadedFileRemove,
|
||||
onUploadedFilesChange,
|
||||
onValueChange
|
||||
}: Props = $props();
|
||||
|
||||
|
|
@ -76,12 +97,26 @@
|
|||
let audioRecorder: AudioRecorder | undefined;
|
||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
||||
let promptPickerRef: ChatFormPromptPicker | undefined = $state(undefined);
|
||||
let resourcePickerRef: ChatFormResourcePicker | undefined = $state(undefined);
|
||||
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
|
||||
|
||||
// Audio Recording State
|
||||
let isRecording = $state(false);
|
||||
let recordingSupported = $state(false);
|
||||
|
||||
// Prompt Picker State
|
||||
let isPromptPickerOpen = $state(false);
|
||||
let promptSearchQuery = $state('');
|
||||
|
||||
// Inline Resource Picker State (triggered by @)
|
||||
let isInlineResourcePickerOpen = $state(false);
|
||||
let resourceSearchQuery = $state('');
|
||||
|
||||
// Resource Dialog State
|
||||
let isResourceDialogOpen = $state(false);
|
||||
let preSelectedResourceUri = $state<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
|
|
@ -211,7 +246,53 @@
|
|||
*
|
||||
*/
|
||||
|
||||
function handleInput() {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
|
||||
|
||||
if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
|
||||
isPromptPickerOpen = true;
|
||||
promptSearchQuery = value.slice(1);
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
} else if (
|
||||
value.startsWith(RESOURCE_TRIGGER_PREFIX) &&
|
||||
hasServers &&
|
||||
mcpStore.hasResourcesCapability(perChatOverrides)
|
||||
) {
|
||||
isInlineResourcePickerOpen = true;
|
||||
resourceSearchQuery = value.slice(1);
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
} else {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isInlineResourcePickerOpen) {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
|
||||
event.preventDefault();
|
||||
|
||||
|
|
@ -240,7 +321,7 @@
|
|||
if (text.startsWith(CLIPBOARD_CONTENT_QUOTE_PREFIX)) {
|
||||
const parsed = parseClipboardContent(text);
|
||||
|
||||
if (parsed.textAttachments.length > 0) {
|
||||
if (parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0) {
|
||||
event.preventDefault();
|
||||
value = parsed.message;
|
||||
onValueChange?.(parsed.message);
|
||||
|
|
@ -256,6 +337,29 @@
|
|||
onFilesAdd?.(attachmentFiles);
|
||||
}
|
||||
|
||||
// Handle MCP prompt attachments as ChatUploadedFile with mcpPrompt data
|
||||
if (parsed.mcpPromptAttachments.length > 0) {
|
||||
const mcpPromptFiles: ChatUploadedFile[] = parsed.mcpPromptAttachments.map((att) => ({
|
||||
id: uuid(),
|
||||
name: att.name,
|
||||
size: att.content.length,
|
||||
type: SpecialFileType.MCP_PROMPT,
|
||||
file: new File([att.content], `${att.name}${FileExtensionText.TXT}`, {
|
||||
type: MimeTypeText.PLAIN
|
||||
}),
|
||||
isLoading: false,
|
||||
textContent: att.content,
|
||||
mcpPrompt: {
|
||||
serverName: att.serverName,
|
||||
promptName: att.promptName,
|
||||
arguments: att.arguments
|
||||
}
|
||||
}));
|
||||
|
||||
uploadedFiles = [...uploadedFiles, ...mcpPromptFiles];
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
textareaRef?.focus();
|
||||
}, 10);
|
||||
|
|
@ -279,6 +383,130 @@
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* EVENT HANDLERS - Prompt Picker
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
function handlePromptLoadStart(
|
||||
placeholderId: string,
|
||||
promptInfo: MCPPromptInfo,
|
||||
args?: Record<string, string>
|
||||
) {
|
||||
// Only clear the value if the prompt was triggered by typing '/'
|
||||
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
|
||||
const promptName = promptInfo.title || promptInfo.name;
|
||||
const placeholder: ChatUploadedFile = {
|
||||
id: placeholderId,
|
||||
name: promptName,
|
||||
size: INITIAL_FILE_SIZE,
|
||||
type: SpecialFileType.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: PromptMessage) => {
|
||||
if (typeof msg.content === 'string') {
|
||||
return msg.content;
|
||||
}
|
||||
|
||||
if (msg.content.type === ContentPartType.TEXT) {
|
||||
return msg.content.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(PROMPT_CONTENT_SEPARATOR);
|
||||
|
||||
uploadedFiles = uploadedFiles.map((f) =>
|
||||
f.id === placeholderId
|
||||
? {
|
||||
...f,
|
||||
isLoading: false,
|
||||
textContent: promptText,
|
||||
size: promptText.length,
|
||||
file: new File([promptText], `${f.name}${FileExtensionText.TXT}`, {
|
||||
type: MimeTypeText.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();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* EVENT HANDLERS - Inline Resource Picker
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
function handleInlineResourcePickerClose() {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
function handleInlineResourceSelect() {
|
||||
// Clear the @query from input after resource is attached
|
||||
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
function handleBrowseResources() {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
|
||||
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
|
||||
isResourceDialogOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
|
|
@ -326,6 +554,25 @@
|
|||
onSubmit?.();
|
||||
}}
|
||||
>
|
||||
<ChatFormPromptPicker
|
||||
bind:this={promptPickerRef}
|
||||
isOpen={isPromptPickerOpen}
|
||||
searchQuery={promptSearchQuery}
|
||||
onClose={handlePromptPickerClose}
|
||||
onPromptLoadStart={handlePromptLoadStart}
|
||||
onPromptLoadComplete={handlePromptLoadComplete}
|
||||
onPromptLoadError={handlePromptLoadError}
|
||||
/>
|
||||
|
||||
<ChatFormResourcePicker
|
||||
bind:this={resourcePickerRef}
|
||||
isOpen={isInlineResourcePickerOpen}
|
||||
searchQuery={resourceSearchQuery}
|
||||
onClose={handleInlineResourcePickerClose}
|
||||
onResourceSelect={handleInlineResourceSelect}
|
||||
onBrowse={handleBrowseResources}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
|
|
@ -352,12 +599,23 @@
|
|||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
/>
|
||||
|
||||
{#if mcpHasResourceAttachments()}
|
||||
<ChatAttachmentMcpResources
|
||||
class="mb-3"
|
||||
onResourceClick={(uri) => {
|
||||
preSelectedResourceUri = uri;
|
||||
isResourceDialogOpen = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ChatFormActions
|
||||
class="px-3"
|
||||
bind:this={chatFormActionsRef}
|
||||
|
|
@ -371,7 +629,22 @@
|
|||
onMicClick={handleMicClick}
|
||||
{onStop}
|
||||
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
|
||||
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
|
||||
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogMcpResources
|
||||
bind:open={isResourceDialogOpen}
|
||||
preSelectedUri={preSelectedResourceUri}
|
||||
onAttach={(resource: MCPResourceInfo) => {
|
||||
mcpStore.attachResource(resource.uri);
|
||||
}}
|
||||
onOpenChange={(newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
preSelectedResourceUri = undefined;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,30 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Plus, MessageSquare } from '@lucide/svelte';
|
||||
import { Plus, MessageSquare, Settings, Zap, FolderOpen } 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';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
|
||||
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
hasAudioModality?: boolean;
|
||||
hasVisionModality?: boolean;
|
||||
hasMcpPromptsSupport?: boolean;
|
||||
hasMcpResourcesSupport?: boolean;
|
||||
onFileUpload?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
onMcpSettingsClick?: () => void;
|
||||
onMcpResourcesClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -20,8 +32,13 @@
|
|||
disabled = false,
|
||||
hasAudioModality = false,
|
||||
hasVisionModality = false,
|
||||
hasMcpPromptsSupport = false,
|
||||
hasMcpResourcesSupport = false,
|
||||
onFileUpload,
|
||||
onSystemPromptClick
|
||||
onSystemPromptClick,
|
||||
onMcpPromptClick,
|
||||
onMcpSettingsClick,
|
||||
onMcpResourcesClick
|
||||
}: Props = $props();
|
||||
|
||||
let isNewChat = $derived(!page.params.id);
|
||||
|
|
@ -34,6 +51,53 @@
|
|||
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
let hasMcpServers = $derived(mcpServers.length > 0);
|
||||
let mcpSearchQuery = $state('');
|
||||
let filteredMcpServers = $derived.by(() => {
|
||||
const query = mcpSearchQuery.toLowerCase().trim();
|
||||
if (!query) return mcpServers;
|
||||
return mcpServers.filter((s) => {
|
||||
const name = getServerLabel(s).toLowerCase();
|
||||
const url = s.url.toLowerCase();
|
||||
return name.includes(query) || url.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
function getServerLabel(server: MCPServerSettingsEntry): string {
|
||||
return mcpStore.getServerLabel(server);
|
||||
}
|
||||
|
||||
function isServerEnabledForChat(serverId: string): boolean {
|
||||
return conversationsStore.isMcpServerEnabledForChat(serverId);
|
||||
}
|
||||
|
||||
async function toggleServerForChat(serverId: string) {
|
||||
await conversationsStore.toggleMcpServerForChat(serverId);
|
||||
}
|
||||
|
||||
function handleMcpSubMenuOpen(open: boolean) {
|
||||
if (open) {
|
||||
mcpSearchQuery = '';
|
||||
mcpStore.runHealthChecksForServers(mcpServers);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMcpPromptClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpPromptClick?.();
|
||||
}
|
||||
|
||||
function handleMcpSettingsClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpSettingsClick?.();
|
||||
}
|
||||
|
||||
function handleMcpResourcesClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpResourcesClick?.();
|
||||
}
|
||||
|
||||
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
|
||||
</script>
|
||||
|
||||
|
|
@ -167,6 +231,103 @@
|
|||
<p>{systemMessageTooltip}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<McpLogo class="h-4 w-4" />
|
||||
|
||||
<span>MCP Servers</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-72 pt-0">
|
||||
<DropdownMenuSearchable
|
||||
placeholder="Search servers..."
|
||||
bind:searchValue={mcpSearchQuery}
|
||||
emptyMessage={hasMcpServers ? 'No servers found' : 'No MCP servers configured'}
|
||||
isEmpty={filteredMcpServers.length === 0}
|
||||
>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each filteredMcpServers as server (server.id)}
|
||||
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
||||
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
||||
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => !hasError && toggleServerForChat(server.id)}
|
||||
disabled={hasError}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
{#if mcpStore.getServerFavicon(server.id)}
|
||||
<img
|
||||
src={mcpStore.getServerFavicon(server.id)}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span class="truncate text-sm">{getServerLabel(server)}</span>
|
||||
|
||||
{#if hasError}
|
||||
<span
|
||||
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
|
||||
>
|
||||
Error
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabledForChat}
|
||||
disabled={hasError}
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onCheckedChange={() => toggleServerForChat(server.id)}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpSettingsClick}
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
|
||||
<span>Manage MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
{/snippet}
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
{#if hasMcpPromptsSupport}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpPromptClick}
|
||||
>
|
||||
<Zap class="h-4 w-4" />
|
||||
|
||||
<span>MCP Prompt</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if hasMcpResourcesSupport}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpResourcesClick}
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
|
||||
<span>MCP Resources</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
<script lang="ts">
|
||||
import { Plus, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { FILE_TYPE_ICONS } from '$lib/constants';
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
hasAudioModality?: boolean;
|
||||
hasVisionModality?: boolean;
|
||||
hasMcpPromptsSupport?: boolean;
|
||||
hasMcpResourcesSupport?: boolean;
|
||||
onFileUpload?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
onMcpSettingsClick?: () => void;
|
||||
onMcpResourcesClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
hasAudioModality = false,
|
||||
hasVisionModality = false,
|
||||
hasMcpPromptsSupport = false,
|
||||
hasMcpResourcesSupport = false,
|
||||
onFileUpload,
|
||||
onSystemPromptClick,
|
||||
onMcpPromptClick,
|
||||
onMcpSettingsClick,
|
||||
onMcpResourcesClick
|
||||
}: Props = $props();
|
||||
|
||||
let sheetOpen = $state(false);
|
||||
|
||||
function handleMcpPromptClick() {
|
||||
sheetOpen = false;
|
||||
onMcpPromptClick?.();
|
||||
}
|
||||
|
||||
function handleMcpSettingsClick() {
|
||||
onMcpSettingsClick?.();
|
||||
}
|
||||
|
||||
function handleMcpResourcesClick() {
|
||||
sheetOpen = false;
|
||||
onMcpResourcesClick?.();
|
||||
}
|
||||
|
||||
function handleSheetFileUpload() {
|
||||
sheetOpen = false;
|
||||
onFileUpload?.();
|
||||
}
|
||||
|
||||
function handleSheetSystemPromptClick() {
|
||||
sheetOpen = false;
|
||||
onSystemPromptClick?.();
|
||||
}
|
||||
|
||||
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
|
||||
|
||||
const sheetItemClass =
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
<Sheet.Root bind:open={sheetOpen}>
|
||||
<Button
|
||||
class="file-upload-button h-8 w-8 rounded-full p-0"
|
||||
{disabled}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onclick={() => (sheetOpen = true)}
|
||||
>
|
||||
<span class="sr-only">{fileUploadTooltipText}</span>
|
||||
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>Add to chat</Sheet.Title>
|
||||
|
||||
<Sheet.Description class="sr-only">
|
||||
Add files, system prompt or configure MCP servers
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex flex-col gap-1 overflow-y-auto px-1.5 pb-2">
|
||||
<!-- Images -->
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemClass}
|
||||
disabled={!hasVisionModality}
|
||||
onclick={handleSheetFileUpload}
|
||||
>
|
||||
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>Images</span>
|
||||
|
||||
{#if !hasVisionModality}
|
||||
<span class="ml-auto text-xs text-muted-foreground">Requires vision model</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Audio -->
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemClass}
|
||||
disabled={!hasAudioModality}
|
||||
onclick={handleSheetFileUpload}
|
||||
>
|
||||
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>Audio Files</span>
|
||||
|
||||
{#if !hasAudioModality}
|
||||
<span class="ml-auto text-xs text-muted-foreground">Requires audio model</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
|
||||
<FILE_TYPE_ICONS.text class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>Text Files</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
|
||||
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>PDF Files</span>
|
||||
|
||||
{#if !hasVisionModality}
|
||||
<span class="ml-auto text-xs text-muted-foreground">Text-only</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
|
||||
<MessageSquare class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>System Message</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class={sheetItemClass} onclick={handleMcpSettingsClick}>
|
||||
<McpLogo class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>MCP Servers</span>
|
||||
</button>
|
||||
|
||||
{#if hasMcpPromptsSupport}
|
||||
<button type="button" class={sheetItemClass} onclick={handleMcpPromptClick}>
|
||||
<Zap class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>MCP Prompt</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if hasMcpResourcesSupport}
|
||||
<button type="button" class={sheetItemClass} onclick={handleMcpResourcesClick}>
|
||||
<FolderOpen class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>MCP Resources</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
</div>
|
||||
|
|
@ -3,17 +3,24 @@
|
|||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
ChatFormActionAttachmentsDropdown,
|
||||
ChatFormActionAttachmentsSheet,
|
||||
ChatFormActionRecord,
|
||||
ChatFormActionSubmit,
|
||||
ModelsSelector
|
||||
McpServersSelector,
|
||||
ModelsSelector,
|
||||
ModelsSelectorSheet
|
||||
} from '$lib/components/app';
|
||||
import { DialogChatSettings } from '$lib/components/app/dialogs';
|
||||
import { SETTINGS_SECTION_TITLES } from '$lib/constants';
|
||||
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, serverError } 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';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
|
||||
interface Props {
|
||||
canSend?: boolean;
|
||||
|
|
@ -27,6 +34,8 @@
|
|||
onMicClick?: () => void;
|
||||
onStop?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
onMcpResourcesClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -40,7 +49,9 @@
|
|||
onFileUpload,
|
||||
onMicClick,
|
||||
onStop,
|
||||
onSystemPromptClick
|
||||
onSystemPromptClick,
|
||||
onMcpPromptClick,
|
||||
onMcpResourcesClick
|
||||
}: Props = $props();
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
|
|
@ -152,32 +163,83 @@
|
|||
return '';
|
||||
});
|
||||
|
||||
let selectorModelRef: ModelsSelector | undefined = $state(undefined);
|
||||
let selectorModelRef: ModelsSelector | ModelsSelectorSheet | undefined = $state(undefined);
|
||||
|
||||
let isMobile = new IsMobile();
|
||||
|
||||
export function openModelSelector() {
|
||||
selectorModelRef?.open();
|
||||
}
|
||||
|
||||
let showChatSettingsDialogWithMcpSection = $state(false);
|
||||
|
||||
let hasMcpPromptsSupport = $derived.by(() => {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
|
||||
return mcpStore.hasPromptsCapability(perChatOverrides);
|
||||
});
|
||||
|
||||
let hasMcpResourcesSupport = $derived.by(() => {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
|
||||
return mcpStore.hasResourcesCapability(perChatOverrides);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
|
||||
<div class="mr-auto flex items-center gap-2">
|
||||
<ChatFormActionAttachmentsDropdown
|
||||
{#if isMobile.current}
|
||||
<ChatFormActionAttachmentsSheet
|
||||
{disabled}
|
||||
{hasAudioModality}
|
||||
{hasVisionModality}
|
||||
{hasMcpPromptsSupport}
|
||||
{hasMcpResourcesSupport}
|
||||
{onFileUpload}
|
||||
{onSystemPromptClick}
|
||||
{onMcpPromptClick}
|
||||
{onMcpResourcesClick}
|
||||
onMcpSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
|
||||
/>
|
||||
{:else}
|
||||
<ChatFormActionAttachmentsDropdown
|
||||
{disabled}
|
||||
{hasAudioModality}
|
||||
{hasVisionModality}
|
||||
{hasMcpPromptsSupport}
|
||||
{hasMcpResourcesSupport}
|
||||
{onFileUpload}
|
||||
{onSystemPromptClick}
|
||||
{onMcpPromptClick}
|
||||
{onMcpResourcesClick}
|
||||
onMcpSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<McpServersSelector
|
||||
{disabled}
|
||||
{hasAudioModality}
|
||||
{hasVisionModality}
|
||||
{onFileUpload}
|
||||
{onSystemPromptClick}
|
||||
onSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<ModelsSelector
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={conversationModel}
|
||||
disabled={disabled || isOffline}
|
||||
forceForegroundText={true}
|
||||
useGlobalSelection={true}
|
||||
/>
|
||||
{#if isMobile.current}
|
||||
<ModelsSelectorSheet
|
||||
disabled={disabled || isOffline}
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={conversationModel}
|
||||
forceForegroundText
|
||||
useGlobalSelection
|
||||
/>
|
||||
{:else}
|
||||
<ModelsSelector
|
||||
disabled={disabled || isOffline}
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={conversationModel}
|
||||
forceForegroundText
|
||||
useGlobalSelection
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
|
|
@ -205,3 +267,9 @@
|
|||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DialogChatSettings
|
||||
open={showChatSettingsDialogWithMcpSection}
|
||||
onOpenChange={(open) => (showChatSettingsDialogWithMcpSection = open)}
|
||||
initialSection={SETTINGS_SECTION_TITLES.MCP}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="mt-4 flex items-center justify-center {className}">
|
||||
<div class="mt-6 items-center justify-center {className} hidden md:flex">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Press <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Enter</kbd> to send,
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Shift + Enter</kbd> for new line
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
|
||||
interface Props {
|
||||
server: MCPServerSettingsEntry | undefined;
|
||||
serverLabel: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
titleExtra?: Snippet;
|
||||
subtitle?: Snippet;
|
||||
}
|
||||
|
||||
let { server, serverLabel, title, description, titleExtra, subtitle }: Props = $props();
|
||||
|
||||
let faviconUrl = $derived(server ? mcpStore.getServerFavicon(server.id) : null);
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{#if faviconUrl}
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span>{serverLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{#if titleExtra}
|
||||
{@render titleExtra()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if description}
|
||||
<p class="mt-0.5 truncate text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if subtitle}
|
||||
{@render subtitle()}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
items: T[];
|
||||
isLoading: boolean;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
showSearchInput: boolean;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
itemKey: (item: T, index: number) => string;
|
||||
item: Snippet<[T, number, boolean]>;
|
||||
skeleton?: Snippet;
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
isLoading,
|
||||
selectedIndex,
|
||||
searchQuery = $bindable(),
|
||||
showSearchInput,
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyMessage = 'No items available',
|
||||
itemKey,
|
||||
item,
|
||||
skeleton,
|
||||
footer
|
||||
}: Props = $props();
|
||||
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
|
||||
const selectedElement = listContainer.querySelector(
|
||||
`[data-picker-index="${selectedIndex}"]`
|
||||
) as HTMLElement;
|
||||
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ScrollArea>
|
||||
{#if showSearchInput}
|
||||
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
|
||||
<SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
bind:this={listContainer}
|
||||
class="{CHAT_FORM_POPOVER_MAX_HEIGHT} p-2"
|
||||
class:pt-13={showSearchInput}
|
||||
>
|
||||
{#if isLoading}
|
||||
{#if skeleton}
|
||||
{@render skeleton()}
|
||||
{/if}
|
||||
{:else if items.length === 0}
|
||||
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{:else}
|
||||
{#each items as itemData, index (itemKey(itemData, index))}
|
||||
{@render item(itemData, index, index === selectedIndex)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if footer}
|
||||
{@render footer()}
|
||||
{/if}
|
||||
</ScrollArea>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
isSelected?: boolean;
|
||||
onClick: () => void;
|
||||
dataIndex?: number;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { isSelected = false, onClick, dataIndex, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-picker-index={dataIndex}
|
||||
onclick={onClick}
|
||||
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
|
||||
? 'bg-accent/50'
|
||||
: ''}"
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
titleWidth?: string;
|
||||
showBadge?: boolean;
|
||||
}
|
||||
|
||||
let { titleWidth = 'w-48', showBadge = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<!-- Server label skeleton -->
|
||||
<div class="mb-2 flex items-center gap-1.5">
|
||||
<div class="h-3 w-3 shrink-0 animate-pulse rounded-sm bg-muted"></div>
|
||||
<div class="h-3 w-24 animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
|
||||
<!-- Title skeleton -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-4 {titleWidth} animate-pulse rounded bg-muted"></div>
|
||||
|
||||
{#if showBadge}
|
||||
<div class="h-4 w-12 animate-pulse rounded-full bg-muted"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description skeleton -->
|
||||
<div class="h-3 w-full animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen?: boolean;
|
||||
srLabel?: string;
|
||||
onClose?: () => void;
|
||||
onKeydown?: (event: KeyboardEvent) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isOpen = $bindable(false),
|
||||
srLabel = 'Open picker',
|
||||
onClose,
|
||||
onKeydown,
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Popover.Root
|
||||
bind:open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger class="pointer-events-none absolute inset-0 opacity-0">
|
||||
<span class="sr-only">{srLabel}</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
|
||||
onkeydown={onKeydown}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{@render children()}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
|
@ -0,0 +1,435 @@
|
|||
<script lang="ts">
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { debounce, uuid } from '$lib/utils';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import {
|
||||
ChatFormPickerPopover,
|
||||
ChatFormPickerList,
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerItemHeader,
|
||||
ChatFormPickerListItemSkeleton,
|
||||
ChatFormPromptPickerArgumentForm
|
||||
} from '$lib/components/app/chat';
|
||||
|
||||
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 selectedIndexBeforeArgumentForm = $state<number | 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;
|
||||
});
|
||||
|
||||
$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 mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
prompts = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
prompts = await mcpStore.getAllPrompts();
|
||||
} catch (error) {
|
||||
console.error('[ChatFormPromptPicker] Failed to load prompts:', error);
|
||||
prompts = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePromptClick(prompt: MCPPromptInfo) {
|
||||
const args = prompt.arguments ?? [];
|
||||
|
||||
if (args.length > 0) {
|
||||
selectedIndexBeforeArgumentForm = selectedIndex;
|
||||
selectedPrompt = prompt;
|
||||
promptArgs = {};
|
||||
promptError = null;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
|
||||
if (firstInput) {
|
||||
firstInput.focus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
executePrompt(prompt, {});
|
||||
}
|
||||
}
|
||||
|
||||
async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
|
||||
promptError = null;
|
||||
|
||||
const placeholderId = uuid();
|
||||
|
||||
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 mcpStore.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 mcpStore.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] ?? [];
|
||||
|
||||
// Handle Escape - return to prompt selection list
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleCancelArgumentForm();
|
||||
return;
|
||||
}
|
||||
|
||||
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
|
||||
} else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleArgBlur(argName: string) {
|
||||
// Delay to allow click on suggestion
|
||||
setTimeout(() => {
|
||||
if (activeAutocomplete === argName) {
|
||||
suggestions[argName] = [];
|
||||
activeAutocomplete = null;
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function handleArgFocus(argName: string) {
|
||||
if ((suggestions[argName]?.length ?? 0) > 0) {
|
||||
activeAutocomplete = argName;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelArgumentForm() {
|
||||
// Restore the previously selected prompt index
|
||||
if (selectedIndexBeforeArgumentForm !== null) {
|
||||
selectedIndex = selectedIndexBeforeArgumentForm;
|
||||
selectedIndexBeforeArgumentForm = null;
|
||||
}
|
||||
selectedPrompt = null;
|
||||
promptArgs = {};
|
||||
promptError = null;
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!isOpen) return false;
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
if (selectedPrompt) {
|
||||
// Return to prompt selection list, keeping the selected prompt active
|
||||
handleCancelArgumentForm();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
if (filteredPrompts.length > 0) {
|
||||
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
if (filteredPrompts.length > 0) {
|
||||
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.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>
|
||||
|
||||
<ChatFormPickerPopover
|
||||
bind:isOpen
|
||||
class={className}
|
||||
srLabel="Open prompt picker"
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
>
|
||||
{#if selectedPrompt}
|
||||
{@const prompt = selectedPrompt}
|
||||
{@const server = serverSettingsMap.get(prompt.serverName)}
|
||||
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
|
||||
|
||||
<div class="p-4">
|
||||
<ChatFormPickerItemHeader
|
||||
{server}
|
||||
{serverLabel}
|
||||
title={prompt.title || prompt.name}
|
||||
description={prompt.description}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if prompt.arguments?.length}
|
||||
<Badge variant="secondary">
|
||||
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatFormPickerItemHeader>
|
||||
|
||||
<ChatFormPromptPickerArgumentForm
|
||||
prompt={selectedPrompt}
|
||||
{promptArgs}
|
||||
{suggestions}
|
||||
{loadingSuggestions}
|
||||
{activeAutocomplete}
|
||||
{autocompleteIndex}
|
||||
{promptError}
|
||||
onArgInput={handleArgInput}
|
||||
onArgKeydown={handleArgKeydown}
|
||||
onArgBlur={handleArgBlur}
|
||||
onArgFocus={handleArgFocus}
|
||||
onSelectSuggestion={selectSuggestion}
|
||||
onSubmit={handleArgumentSubmit}
|
||||
onCancel={handleCancelArgumentForm}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<ChatFormPickerList
|
||||
items={filteredPrompts}
|
||||
{isLoading}
|
||||
{selectedIndex}
|
||||
bind:searchQuery={internalSearchQuery}
|
||||
{showSearchInput}
|
||||
searchPlaceholder="Search prompts..."
|
||||
emptyMessage="No MCP prompts available"
|
||||
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
|
||||
>
|
||||
{#snippet item(prompt, index, isSelected)}
|
||||
{@const server = serverSettingsMap.get(prompt.serverName)}
|
||||
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
|
||||
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
onClick={() => handlePromptClick(prompt)}
|
||||
>
|
||||
<ChatFormPickerItemHeader
|
||||
{server}
|
||||
{serverLabel}
|
||||
title={prompt.title || prompt.name}
|
||||
description={prompt.description}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if prompt.arguments?.length}
|
||||
<Badge variant="secondary">
|
||||
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatFormPickerItemHeader>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
|
||||
{#snippet skeleton()}
|
||||
<ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
{/if}
|
||||
</ChatFormPickerPopover>
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<script lang="ts">
|
||||
import type { MCPPromptInfo } from '$lib/types';
|
||||
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
prompt: MCPPromptInfo;
|
||||
promptArgs: Record<string, string>;
|
||||
suggestions: Record<string, string[]>;
|
||||
loadingSuggestions: Record<string, boolean>;
|
||||
activeAutocomplete: string | null;
|
||||
autocompleteIndex: number;
|
||||
promptError: string | null;
|
||||
onArgInput: (argName: string, value: string) => void;
|
||||
onArgKeydown: (event: KeyboardEvent, argName: string) => void;
|
||||
onArgBlur: (argName: string) => void;
|
||||
onArgFocus: (argName: string) => void;
|
||||
onSelectSuggestion: (argName: string, value: string) => void;
|
||||
onSubmit: (event: SubmitEvent) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
prompt,
|
||||
promptArgs,
|
||||
suggestions,
|
||||
loadingSuggestions,
|
||||
activeAutocomplete,
|
||||
autocompleteIndex,
|
||||
promptError,
|
||||
onArgInput,
|
||||
onArgKeydown,
|
||||
onArgBlur,
|
||||
onArgFocus,
|
||||
onSelectSuggestion,
|
||||
onSubmit,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit} class="space-y-3 pt-4">
|
||||
{#each prompt.arguments ?? [] as arg (arg.name)}
|
||||
<ChatFormPromptPickerArgumentInput
|
||||
argument={arg}
|
||||
value={promptArgs[arg.name] ?? ''}
|
||||
suggestions={suggestions[arg.name] ?? []}
|
||||
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
|
||||
isAutocompleteActive={activeAutocomplete === arg.name}
|
||||
autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
|
||||
onInput={(value) => onArgInput(arg.name, value)}
|
||||
onKeydown={(e) => onArgKeydown(e, arg.name)}
|
||||
onBlur={() => onArgBlur(arg.name)}
|
||||
onFocus={() => onArgFocus(arg.name)}
|
||||
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if promptError}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<span class="shrink-0">⚠</span>
|
||||
|
||||
<span>{promptError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8 flex justify-end gap-2">
|
||||
<Button type="button" size="sm" onclick={onCancel} variant="secondary">Cancel</Button>
|
||||
|
||||
<Button size="sm" type="submit">Use Prompt</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts">
|
||||
import type { MCPPromptInfo } from '$lib/types';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
|
||||
|
||||
interface Props {
|
||||
argument: PromptArgument;
|
||||
value: string;
|
||||
suggestions?: string[];
|
||||
isLoadingSuggestions?: boolean;
|
||||
isAutocompleteActive?: boolean;
|
||||
autocompleteIndex?: number;
|
||||
onInput: (value: string) => void;
|
||||
onKeydown: (event: KeyboardEvent) => void;
|
||||
onBlur: () => void;
|
||||
onFocus: () => void;
|
||||
onSelectSuggestion: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
argument,
|
||||
value = '',
|
||||
suggestions = [],
|
||||
isLoadingSuggestions = false,
|
||||
isAutocompleteActive = false,
|
||||
autocompleteIndex = 0,
|
||||
onInput,
|
||||
onKeydown,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onSelectSuggestion
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative grid gap-1">
|
||||
<Label for="arg-{argument.name}" class="mb-1 text-muted-foreground">
|
||||
<span>
|
||||
{argument.name}
|
||||
|
||||
{#if argument.required}
|
||||
<span class="text-destructive">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if isLoadingSuggestions}
|
||||
<span class="text-xs text-muted-foreground/50">...</span>
|
||||
{/if}
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="arg-{argument.name}"
|
||||
type="text"
|
||||
{value}
|
||||
oninput={(e) => onInput(e.currentTarget.value)}
|
||||
onkeydown={onKeydown}
|
||||
onblur={onBlur}
|
||||
onfocus={onFocus}
|
||||
placeholder={argument.description || argument.name}
|
||||
required={argument.required}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
{#if isAutocompleteActive && suggestions.length > 0}
|
||||
<div
|
||||
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
|
||||
transition:fly={{ y: -5, duration: 100 }}
|
||||
>
|
||||
{#each suggestions as suggestion, i (suggestion)}
|
||||
<button
|
||||
type="button"
|
||||
onmousedown={() => onSelectSuggestion(suggestion)}
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
<script lang="ts">
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { FolderOpen } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
ChatFormPickerPopover,
|
||||
ChatFormPickerList,
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerItemHeader,
|
||||
ChatFormPickerListItemSkeleton
|
||||
} from '$lib/components/app/chat';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen?: boolean;
|
||||
searchQuery?: string;
|
||||
onClose?: () => void;
|
||||
onResourceSelect?: (resource: MCPResourceInfo) => void;
|
||||
onBrowse?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isOpen = false,
|
||||
searchQuery = '',
|
||||
onClose,
|
||||
onResourceSelect,
|
||||
onBrowse
|
||||
}: Props = $props();
|
||||
|
||||
let resources = $state<MCPResourceInfo[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
let internalSearchQuery = $state('');
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
loadResources();
|
||||
selectedIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadResources() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
resources = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await mcpStore.fetchAllResources();
|
||||
resources = mcpResourceStore.getAllResourceInfos();
|
||||
} catch (error) {
|
||||
console.error('[ChatFormResourcePicker] Failed to load resources:', error);
|
||||
resources = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo) {
|
||||
mcpStore.attachResource(resource.uri);
|
||||
onResourceSelect?.(resource);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function isResourceAttached(uri: string): boolean {
|
||||
return mcpResourceStore.isAttached(uri);
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!isOpen) return false;
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
onClose?.();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
|
||||
if (filteredResources.length > 0) {
|
||||
selectedIndex = (selectedIndex + 1) % filteredResources.length;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
if (filteredResources.length > 0) {
|
||||
selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
if (filteredResources[selectedIndex]) {
|
||||
handleResourceClick(filteredResources[selectedIndex]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let filteredResources = $derived.by(() => {
|
||||
const sortedServers = mcpStore.getServersSorted();
|
||||
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
|
||||
|
||||
const sortedResources = [...resources].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 sortedResources;
|
||||
|
||||
return sortedResources.filter(
|
||||
(resource) =>
|
||||
resource.name.toLowerCase().includes(query) ||
|
||||
resource.title?.toLowerCase().includes(query) ||
|
||||
resource.description?.toLowerCase().includes(query) ||
|
||||
resource.uri.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
let showSearchInput = $derived(resources.length > 3);
|
||||
</script>
|
||||
|
||||
<ChatFormPickerPopover
|
||||
bind:isOpen
|
||||
class={className}
|
||||
srLabel="Open resource picker"
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={filteredResources}
|
||||
{isLoading}
|
||||
{selectedIndex}
|
||||
bind:searchQuery={internalSearchQuery}
|
||||
{showSearchInput}
|
||||
searchPlaceholder="Search resources..."
|
||||
emptyMessage="No MCP resources available"
|
||||
itemKey={(resource) => resource.serverName + ':' + resource.uri}
|
||||
>
|
||||
{#snippet item(resource, index, isSelected)}
|
||||
{@const server = serverSettingsMap.get(resource.serverName)}
|
||||
{@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
|
||||
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
onClick={() => handleResourceClick(resource)}
|
||||
>
|
||||
<ChatFormPickerItemHeader
|
||||
{server}
|
||||
{serverLabel}
|
||||
title={resource.title || resource.name}
|
||||
description={resource.description}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if isResourceAttached(resource.uri)}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
|
||||
>
|
||||
attached
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet subtitle()}
|
||||
<p class="mt-0.5 truncate text-xs text-muted-foreground/60">
|
||||
{resource.uri}
|
||||
</p>
|
||||
{/snippet}
|
||||
</ChatFormPickerItemHeader>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
|
||||
{#snippet skeleton()}
|
||||
<ChatFormPickerListItemSkeleton />
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
{#if onBrowse && resources.length > 3}
|
||||
<Button
|
||||
class="fixed right-3 bottom-3"
|
||||
type="button"
|
||||
onclick={onBrowse}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<FolderOpen class="h-3 w-3" />
|
||||
|
||||
Browse all
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</ChatFormPickerPopover>
|
||||
|
|
@ -6,13 +6,15 @@
|
|||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { DatabaseService } from '$lib/services';
|
||||
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { MessageRole, AttachmentType } from '$lib/enums';
|
||||
import {
|
||||
ChatMessageAssistant,
|
||||
ChatMessageUser,
|
||||
ChatMessageSystem
|
||||
ChatMessageSystem,
|
||||
ChatMessageMcpPrompt
|
||||
} from '$lib/components/app/chat';
|
||||
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -83,6 +85,20 @@
|
|||
startEdit: handleEdit
|
||||
});
|
||||
|
||||
let mcpPromptExtra = $derived.by(() => {
|
||||
if (message.role !== MessageRole.USER) return null;
|
||||
if (message.content.trim()) return null;
|
||||
if (!message.extra || message.extra.length !== 1) return null;
|
||||
|
||||
const extra = message.extra[0];
|
||||
|
||||
if (extra.type === AttachmentType.MCP_PROMPT) {
|
||||
return extra as DatabaseMessageExtraMcpPrompt;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const pendingId = pendingEditMessageId();
|
||||
|
||||
|
|
@ -245,6 +261,21 @@
|
|||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
/>
|
||||
{:else if mcpPromptExtra}
|
||||
<ChatMessageMcpPrompt
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{message}
|
||||
mcpPrompt={mcpPromptExtra}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
/>
|
||||
{:else if message.role === MessageRole.USER}
|
||||
<ChatMessageUser
|
||||
class={className}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,354 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ChatMessageStatistics,
|
||||
CollapsibleContentBlock,
|
||||
MarkdownContent,
|
||||
SyntaxHighlightedCode
|
||||
} from '$lib/components/app';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte';
|
||||
import { AgenticSectionType, AttachmentType, FileTypeText } from '$lib/enums';
|
||||
import { formatJsonPretty } from '$lib/utils';
|
||||
import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
|
||||
import { parseAgenticContent, type AgenticSection } from '$lib/utils';
|
||||
import type { DatabaseMessage, DatabaseMessageExtraImageFile } from '$lib/types/database';
|
||||
import type { ChatMessageAgenticTimings, ChatMessageAgenticTurnStats } from '$lib/types/chat';
|
||||
import { ChatMessageStatsView } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
message?: DatabaseMessage;
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
highlightTurns?: boolean;
|
||||
}
|
||||
|
||||
type ToolResultLine = {
|
||||
text: string;
|
||||
image?: DatabaseMessageExtraImageFile;
|
||||
};
|
||||
|
||||
let { content, message, isStreaming = false, highlightTurns = false }: Props = $props();
|
||||
|
||||
let expandedStates: Record<number, boolean> = $state({});
|
||||
|
||||
const sections = $derived(parseAgenticContent(content));
|
||||
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
|
||||
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
|
||||
|
||||
// Parse toolResults with images only when sections or message.extra change
|
||||
const sectionsParsed = $derived(
|
||||
sections.map((section) => ({
|
||||
...section,
|
||||
parsedLines: section.toolResult
|
||||
? parseToolResultWithImages(section.toolResult, message?.extra)
|
||||
: []
|
||||
}))
|
||||
);
|
||||
|
||||
// Group flat sections into agentic turns
|
||||
// A new turn starts when a non-tool section follows a tool section
|
||||
const turnGroups = $derived.by(() => {
|
||||
const turns: { sections: (typeof sectionsParsed)[number][]; flatIndices: number[] }[] = [];
|
||||
let currentTurn: (typeof sectionsParsed)[number][] = [];
|
||||
let currentIndices: number[] = [];
|
||||
let prevWasTool = false;
|
||||
|
||||
for (let i = 0; i < sectionsParsed.length; i++) {
|
||||
const section = sectionsParsed[i];
|
||||
const isTool =
|
||||
section.type === AgenticSectionType.TOOL_CALL ||
|
||||
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
|
||||
section.type === AgenticSectionType.TOOL_CALL_STREAMING;
|
||||
|
||||
if (!isTool && prevWasTool && currentTurn.length > 0) {
|
||||
turns.push({ sections: currentTurn, flatIndices: currentIndices });
|
||||
currentTurn = [];
|
||||
currentIndices = [];
|
||||
}
|
||||
|
||||
currentTurn.push(section);
|
||||
currentIndices.push(i);
|
||||
prevWasTool = isTool;
|
||||
}
|
||||
|
||||
if (currentTurn.length > 0) {
|
||||
turns.push({ sections: currentTurn, flatIndices: currentIndices });
|
||||
}
|
||||
|
||||
return turns;
|
||||
});
|
||||
|
||||
function getDefaultExpanded(section: AgenticSection): boolean {
|
||||
if (
|
||||
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
|
||||
section.type === AgenticSectionType.TOOL_CALL_STREAMING
|
||||
) {
|
||||
return showToolCallInProgress;
|
||||
}
|
||||
|
||||
if (section.type === AgenticSectionType.REASONING_PENDING) {
|
||||
return showThoughtInProgress;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isExpanded(index: number, section: AgenticSection): boolean {
|
||||
if (expandedStates[index] !== undefined) {
|
||||
return expandedStates[index];
|
||||
}
|
||||
|
||||
return getDefaultExpanded(section);
|
||||
}
|
||||
|
||||
function toggleExpanded(index: number, section: AgenticSection) {
|
||||
const currentState = isExpanded(index, section);
|
||||
|
||||
expandedStates[index] = !currentState;
|
||||
}
|
||||
|
||||
function parseToolResultWithImages(
|
||||
toolResult: string,
|
||||
extras?: DatabaseMessage['extra']
|
||||
): ToolResultLine[] {
|
||||
const lines = toolResult.split(NEWLINE_SEPARATOR);
|
||||
|
||||
return lines.map((line) => {
|
||||
const match = line.match(ATTACHMENT_SAVED_REGEX);
|
||||
if (!match || !extras) return { text: line };
|
||||
|
||||
const attachmentName = match[1];
|
||||
const image = extras.find(
|
||||
(e): e is DatabaseMessageExtraImageFile =>
|
||||
e.type === AttachmentType.IMAGE && e.name === attachmentName
|
||||
);
|
||||
|
||||
return { text: line, image };
|
||||
});
|
||||
}
|
||||
|
||||
function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
|
||||
return {
|
||||
turns: 1,
|
||||
toolCallsCount: stats.toolCalls.length,
|
||||
toolsMs: stats.toolsMs,
|
||||
toolCalls: stats.toolCalls,
|
||||
llm: stats.llm
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderSection(section: (typeof sectionsParsed)[number], index: number)}
|
||||
{#if section.type === AgenticSectionType.TEXT}
|
||||
<div class="agentic-text">
|
||||
<MarkdownContent content={section.content} attachments={message?.extra} />
|
||||
</div>
|
||||
{:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING}
|
||||
{@const streamingIcon = isStreaming ? Loader2 : AlertTriangle}
|
||||
{@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4 text-yellow-500'}
|
||||
{@const streamingSubtitle = isStreaming ? '' : 'incomplete'}
|
||||
|
||||
<CollapsibleContentBlock
|
||||
open={isExpanded(index, section)}
|
||||
class="my-2"
|
||||
icon={streamingIcon}
|
||||
iconClass={streamingIconClass}
|
||||
title={section.toolName || 'Tool call'}
|
||||
subtitle={streamingSubtitle}
|
||||
{isStreaming}
|
||||
onToggle={() => toggleExpanded(index, section)}
|
||||
>
|
||||
<div class="pt-3">
|
||||
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Arguments:</span>
|
||||
|
||||
{#if isStreaming}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if section.toolArgs}
|
||||
<SyntaxHighlightedCode
|
||||
code={formatJsonPretty(section.toolArgs)}
|
||||
language={FileTypeText.JSON}
|
||||
maxHeight="20rem"
|
||||
class="text-xs"
|
||||
/>
|
||||
{:else if isStreaming}
|
||||
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
|
||||
Receiving arguments...
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
|
||||
>
|
||||
Response was truncated
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</CollapsibleContentBlock>
|
||||
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
||||
{@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
||||
{@const toolIcon = isPending ? Loader2 : Wrench}
|
||||
{@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
|
||||
|
||||
<CollapsibleContentBlock
|
||||
open={isExpanded(index, section)}
|
||||
class="my-2"
|
||||
icon={toolIcon}
|
||||
iconClass={toolIconClass}
|
||||
title={section.toolName || ''}
|
||||
subtitle={isPending ? 'executing...' : undefined}
|
||||
isStreaming={isPending}
|
||||
onToggle={() => toggleExpanded(index, section)}
|
||||
>
|
||||
{#if section.toolArgs && section.toolArgs !== '{}'}
|
||||
<div class="pt-3">
|
||||
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
|
||||
|
||||
<SyntaxHighlightedCode
|
||||
code={formatJsonPretty(section.toolArgs)}
|
||||
language={FileTypeText.JSON}
|
||||
maxHeight="20rem"
|
||||
class="text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-3">
|
||||
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Result:</span>
|
||||
|
||||
{#if isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if section.toolResult}
|
||||
<div class="overflow-auto rounded-lg border border-border bg-muted p-4">
|
||||
{#each section.parsedLines as line, i (i)}
|
||||
<div class="font-mono text-xs leading-relaxed whitespace-pre-wrap">{line.text}</div>
|
||||
{#if line.image}
|
||||
<img
|
||||
src={line.image.base64Url}
|
||||
alt={line.image.name}
|
||||
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if isPending}
|
||||
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
|
||||
Waiting for result...
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</CollapsibleContentBlock>
|
||||
{:else if section.type === AgenticSectionType.REASONING}
|
||||
<CollapsibleContentBlock
|
||||
open={isExpanded(index, section)}
|
||||
class="my-2"
|
||||
icon={Brain}
|
||||
title="Reasoning"
|
||||
onToggle={() => toggleExpanded(index, section)}
|
||||
>
|
||||
<div class="pt-3">
|
||||
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
|
||||
{section.content}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContentBlock>
|
||||
{:else if section.type === AgenticSectionType.REASONING_PENDING}
|
||||
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
|
||||
{@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
|
||||
|
||||
<CollapsibleContentBlock
|
||||
open={isExpanded(index, section)}
|
||||
class="my-2"
|
||||
icon={Brain}
|
||||
title={reasoningTitle}
|
||||
subtitle={reasoningSubtitle}
|
||||
{isStreaming}
|
||||
onToggle={() => toggleExpanded(index, section)}
|
||||
>
|
||||
<div class="pt-3">
|
||||
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
|
||||
{section.content}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContentBlock>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="agentic-content">
|
||||
{#if highlightTurns && turnGroups.length > 1}
|
||||
{#each turnGroups as turn, turnIndex (turnIndex)}
|
||||
{@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
|
||||
<div class="agentic-turn my-2 hover:bg-muted/80 dark:hover:bg-muted/30">
|
||||
<span class="agentic-turn-label">Turn {turnIndex + 1}</span>
|
||||
{#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
|
||||
{@render renderSection(section, turn.flatIndices[sIdx])}
|
||||
{/each}
|
||||
{#if turnStats}
|
||||
<div class="turn-stats">
|
||||
<ChatMessageStatistics
|
||||
promptTokens={turnStats.llm.prompt_n}
|
||||
promptMs={turnStats.llm.prompt_ms}
|
||||
predictedTokens={turnStats.llm.predicted_n}
|
||||
predictedMs={turnStats.llm.predicted_ms}
|
||||
agenticTimings={turnStats.toolCalls.length > 0
|
||||
? buildTurnAgenticTimings(turnStats)
|
||||
: undefined}
|
||||
initialView={ChatMessageStatsView.GENERATION}
|
||||
hideSummary
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each sectionsParsed as section, index (index)}
|
||||
{@render renderSection(section, index)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.agentic-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.agentic-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.agentic-turn {
|
||||
position: relative;
|
||||
border: 1.5px dashed var(--muted-foreground);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.agentic-turn-label {
|
||||
position: absolute;
|
||||
top: -1rem;
|
||||
left: 0.75rem;
|
||||
padding: 0 0.375rem;
|
||||
background: var(--background);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.turn-stats {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid hsl(var(--muted) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,23 +1,24 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ChatMessageAgenticContent,
|
||||
ChatMessageActions,
|
||||
ChatMessageStatistics,
|
||||
MarkdownContent,
|
||||
ModelBadge,
|
||||
ModelsSelector
|
||||
} from '$lib/components/app';
|
||||
import ChatMessageThinkingBlock from './ChatMessageThinkingBlock.svelte';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
|
||||
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
|
||||
import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
|
||||
import { tick } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Check, X } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { INPUT_CLASSES, REASONING_TAGS } from '$lib/constants';
|
||||
import { MessageRole, KeyboardKey } from '$lib/enums';
|
||||
import { AGENTIC_TAGS, INPUT_CLASSES, REASONING_TAGS } from '$lib/constants';
|
||||
import { MessageRole, KeyboardKey, ChatMessageStatsView } from '$lib/enums';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
|
|
@ -48,58 +49,6 @@
|
|||
textareaElement?: HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
interface ParsedReasoningContent {
|
||||
content: string;
|
||||
reasoningContent: string | null;
|
||||
hasReasoningMarkers: boolean;
|
||||
}
|
||||
|
||||
function parseReasoningContent(content: string | undefined): ParsedReasoningContent {
|
||||
if (!content) {
|
||||
return {
|
||||
content: '',
|
||||
reasoningContent: null,
|
||||
hasReasoningMarkers: false
|
||||
};
|
||||
}
|
||||
|
||||
const plainParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
const { START, END } = REASONING_TAGS;
|
||||
let cursor = 0;
|
||||
let hasReasoningMarkers = false;
|
||||
|
||||
while (cursor < content.length) {
|
||||
const startIndex = content.indexOf(START, cursor);
|
||||
|
||||
if (startIndex === -1) {
|
||||
plainParts.push(content.slice(cursor));
|
||||
break;
|
||||
}
|
||||
|
||||
hasReasoningMarkers = true;
|
||||
plainParts.push(content.slice(cursor, startIndex));
|
||||
|
||||
const reasoningStart = startIndex + START.length;
|
||||
const endIndex = content.indexOf(END, reasoningStart);
|
||||
|
||||
if (endIndex === -1) {
|
||||
reasoningParts.push(content.slice(reasoningStart));
|
||||
cursor = content.length;
|
||||
break;
|
||||
}
|
||||
|
||||
reasoningParts.push(content.slice(reasoningStart, endIndex));
|
||||
cursor = endIndex + END.length;
|
||||
}
|
||||
|
||||
return {
|
||||
content: plainParts.join(''),
|
||||
reasoningContent: reasoningParts.length > 0 ? reasoningParts.join('\n\n') : null,
|
||||
hasReasoningMarkers
|
||||
};
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
deletionInfo,
|
||||
|
|
@ -135,15 +84,22 @@
|
|||
}
|
||||
}
|
||||
|
||||
const parsedMessageContent = $derived.by(() => parseReasoningContent(messageContent));
|
||||
const visibleMessageContent = $derived(parsedMessageContent.content);
|
||||
const thinkingContent = $derived(parsedMessageContent.reasoningContent);
|
||||
const hasReasoningMarkers = $derived(parsedMessageContent.hasReasoningMarkers);
|
||||
const hasAgenticMarkers = $derived(
|
||||
messageContent?.includes(AGENTIC_TAGS.TOOL_CALL_START) ?? false
|
||||
);
|
||||
const hasStreamingToolCall = $derived(
|
||||
isChatStreaming() && agenticStreamingToolCall(message.convId) !== null
|
||||
);
|
||||
const hasReasoningMarkers = $derived(messageContent?.includes(REASONING_TAGS.START) ?? false);
|
||||
const isStructuredContent = $derived(
|
||||
hasAgenticMarkers || hasReasoningMarkers || hasStreamingToolCall
|
||||
);
|
||||
const processingState = useProcessingState();
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let showRawOutput = $state(false);
|
||||
let activeStatsView = $state<ChatMessageStatsView>(ChatMessageStatsView.GENERATION);
|
||||
let statsContainerEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
function getScrollParent(el: HTMLElement): HTMLElement | null {
|
||||
|
|
@ -158,19 +114,25 @@
|
|||
return null;
|
||||
}
|
||||
|
||||
async function handleStatsViewChange() {
|
||||
async function handleStatsViewChange(view: ChatMessageStatsView) {
|
||||
const el = statsContainerEl;
|
||||
if (!el) {
|
||||
activeStatsView = view;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollParent = getScrollParent(el);
|
||||
if (!scrollParent) {
|
||||
activeStatsView = view;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const yBefore = el.getBoundingClientRect().top;
|
||||
|
||||
activeStatsView = view;
|
||||
|
||||
await tick();
|
||||
|
||||
const delta = el.getBoundingClientRect().top - yBefore;
|
||||
|
|
@ -188,11 +150,16 @@
|
|||
});
|
||||
}
|
||||
|
||||
let highlightAgenticTurns = $derived(
|
||||
hasAgenticMarkers &&
|
||||
(currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
|
||||
);
|
||||
|
||||
let displayedModel = $derived(message.model ?? null);
|
||||
|
||||
let isCurrentlyLoading = $derived(isLoading());
|
||||
let isStreaming = $derived(isChatStreaming());
|
||||
let hasNoContent = $derived(!visibleMessageContent?.trim());
|
||||
let hasNoContent = $derived(!message?.content?.trim());
|
||||
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
|
||||
|
||||
let showProcessingInfoTop = $derived(
|
||||
|
|
@ -231,14 +198,6 @@
|
|||
role="group"
|
||||
aria-label="Assistant message with actions"
|
||||
>
|
||||
{#if !editCtx.isEditing && thinkingContent}
|
||||
<ChatMessageThinkingBlock
|
||||
reasoningContent={thinkingContent}
|
||||
isStreaming={!message.timestamp}
|
||||
hasRegularContent={!!visibleMessageContent?.trim()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showProcessingInfoTop}
|
||||
<div class="mt-6 w-full max-w-[48rem]" in:fade>
|
||||
<div class="processing-container">
|
||||
|
|
@ -297,8 +256,15 @@
|
|||
{:else if message.role === MessageRole.ASSISTANT}
|
||||
{#if showRawOutput}
|
||||
<pre class="raw-output">{messageContent || ''}</pre>
|
||||
{:else if isStructuredContent}
|
||||
<ChatMessageAgenticContent
|
||||
content={messageContent || ''}
|
||||
isStreaming={isChatStreaming()}
|
||||
highlightTurns={highlightAgenticTurns}
|
||||
{message}
|
||||
/>
|
||||
{:else}
|
||||
<MarkdownContent content={visibleMessageContent || ''} attachments={message.extra} />
|
||||
<MarkdownContent content={messageContent || ''} attachments={message.extra} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm whitespace-pre-wrap">
|
||||
|
|
@ -344,11 +310,13 @@
|
|||
{/if}
|
||||
|
||||
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||
{@const agentic = message.timings.agentic}
|
||||
<ChatMessageStatistics
|
||||
promptTokens={message.timings.prompt_n}
|
||||
promptMs={message.timings.prompt_ms}
|
||||
predictedTokens={message.timings.predicted_n}
|
||||
predictedMs={message.timings.predicted_ms}
|
||||
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
|
||||
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
|
||||
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
|
||||
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
|
||||
agenticTimings={agentic}
|
||||
onActiveViewChange={handleStatsViewChange}
|
||||
/>
|
||||
{:else if isLoading() && currentConfig.showMessageStats}
|
||||
|
|
@ -360,7 +328,7 @@
|
|||
|
||||
{#if liveStats || genStats}
|
||||
<ChatMessageStatistics
|
||||
isLive={true}
|
||||
isLive
|
||||
isProcessingPrompt={!!isStillProcessingPrompt}
|
||||
promptTokens={liveStats?.tokensProcessed}
|
||||
promptMs={liveStats?.timeMs}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,10 @@
|
|||
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
|
||||
}
|
||||
|
||||
function handleUploadedFilesChange(files: ChatUploadedFile[]) {
|
||||
editCtx.setUploadedFiles(files);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
chatStore.setEditModeActive(handleFilesAdd);
|
||||
|
||||
|
|
@ -95,9 +99,11 @@
|
|||
attachments={editCtx.editedExtras}
|
||||
uploadedFiles={editCtx.editedUploadedFiles}
|
||||
placeholder="Edit your message..."
|
||||
showMcpPromptButton
|
||||
onValueChange={editCtx.setContent}
|
||||
onAttachmentRemove={handleAttachmentRemove}
|
||||
onUploadedFileRemove={handleUploadedFileRemove}
|
||||
onUploadedFilesChange={handleUploadedFilesChange}
|
||||
onFilesAdd={handleFilesAdd}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ChatMessageActions,
|
||||
ChatMessageEditForm,
|
||||
ChatMessageMcpPromptContent
|
||||
} from '$lib/components/app';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import { MessageRole, McpPromptVariant } from '$lib/enums';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
mcpPrompt: DatabaseMessageExtraMcpPrompt;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
showDeleteDialog: boolean;
|
||||
deletionInfo: {
|
||||
totalCount: number;
|
||||
userMessages: number;
|
||||
assistantMessages: number;
|
||||
messageTypes: string[];
|
||||
} | null;
|
||||
onCopy: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onShowDeleteDialogChange: (show: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
mcpPrompt,
|
||||
siblingInfo = null,
|
||||
showDeleteDialog,
|
||||
deletionInfo,
|
||||
onCopy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onNavigateToSibling,
|
||||
onShowDeleteDialogChange
|
||||
}: Props = $props();
|
||||
|
||||
// Get edit context
|
||||
const editCtx = getMessageEditContext();
|
||||
</script>
|
||||
|
||||
<div
|
||||
aria-label="MCP Prompt message with actions"
|
||||
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
|
||||
role="group"
|
||||
>
|
||||
{#if editCtx.isEditing}
|
||||
<ChatMessageEditForm />
|
||||
{:else}
|
||||
<ChatMessageMcpPromptContent
|
||||
prompt={mcpPrompt}
|
||||
variant={McpPromptVariant.MESSAGE}
|
||||
class="w-full max-w-[80%]"
|
||||
/>
|
||||
|
||||
{#if message.timestamp}
|
||||
<div class="max-w-[80%]">
|
||||
<ChatMessageActions
|
||||
actionsPosition="right"
|
||||
{deletionInfo}
|
||||
justify="end"
|
||||
{onConfirmDelete}
|
||||
{onCopy}
|
||||
{onDelete}
|
||||
{onEdit}
|
||||
{onNavigateToSibling}
|
||||
{onShowDeleteDialogChange}
|
||||
{siblingInfo}
|
||||
{showDeleteDialog}
|
||||
role={MessageRole.USER}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
<script lang="ts">
|
||||
import { Card } from '$lib/components/ui/card';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { McpPromptVariant } from '$lib/enums';
|
||||
import { TruncatedText } from '$lib/components/app/misc';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
interface ContentPart {
|
||||
text: string;
|
||||
argKey: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
prompt: DatabaseMessageExtraMcpPrompt;
|
||||
variant?: McpPromptVariant;
|
||||
isLoading?: boolean;
|
||||
loadError?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
prompt,
|
||||
variant = McpPromptVariant.MESSAGE,
|
||||
isLoading = false,
|
||||
loadError
|
||||
}: Props = $props();
|
||||
|
||||
let hoveredArgKey = $state<string | null>(null);
|
||||
let argumentEntries = $derived(Object.entries(prompt.arguments ?? {}));
|
||||
let hasArguments = $derived(prompt.arguments && Object.keys(prompt.arguments).length > 0);
|
||||
let hasContent = $derived(prompt.content && prompt.content.trim().length > 0);
|
||||
|
||||
let contentParts = $derived.by((): ContentPart[] => {
|
||||
if (!prompt.content || !hasArguments) {
|
||||
return [{ text: prompt.content || '', argKey: null }];
|
||||
}
|
||||
|
||||
const parts: ContentPart[] = [];
|
||||
let remaining = prompt.content;
|
||||
|
||||
const valueToKey = new SvelteMap<string, string>();
|
||||
for (const [key, value] of argumentEntries) {
|
||||
if (value && value.trim()) {
|
||||
valueToKey.set(value, key);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedValues = [...valueToKey.keys()].sort((a, b) => b.length - a.length);
|
||||
|
||||
while (remaining.length > 0) {
|
||||
let earliestMatch: { index: number; value: string; key: string } | null = null;
|
||||
|
||||
for (const value of sortedValues) {
|
||||
const index = remaining.indexOf(value);
|
||||
if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
|
||||
earliestMatch = { index, value, key: valueToKey.get(value)! };
|
||||
}
|
||||
}
|
||||
|
||||
if (earliestMatch) {
|
||||
if (earliestMatch.index > 0) {
|
||||
parts.push({ text: remaining.slice(0, earliestMatch.index), argKey: null });
|
||||
}
|
||||
|
||||
parts.push({ text: earliestMatch.value, argKey: earliestMatch.key });
|
||||
remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
|
||||
} else {
|
||||
parts.push({ text: remaining, argKey: null });
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
});
|
||||
|
||||
let showArgBadges = $derived(hasArguments && !isLoading && !loadError);
|
||||
let isAttachment = $derived(variant === McpPromptVariant.ATTACHMENT);
|
||||
let textSizeClass = $derived(isAttachment ? 'text-xs' : 'text-md');
|
||||
let paddingClass = $derived(isAttachment ? 'px-3 py-2' : 'px-3.75 py-2.5');
|
||||
let maxHeightStyle = $derived(
|
||||
isAttachment ? 'max-height: 6rem;' : 'max-height: var(--max-message-height);'
|
||||
);
|
||||
|
||||
const serverFavicon = $derived(mcpStore.getServerFavicon(prompt.serverName));
|
||||
const serverDisplayName = $derived(mcpStore.getServerDisplayName(prompt.serverName));
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 {className}">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="inline-flex flex-wrap items-center gap-1.25 text-xs text-muted-foreground">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#if serverFavicon}
|
||||
<img
|
||||
src={serverFavicon}
|
||||
alt=""
|
||||
class="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<span>{serverDisplayName}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<TruncatedText text={prompt.name} />
|
||||
</div>
|
||||
|
||||
{#if showArgBadges}
|
||||
<div class="flex flex-wrap justify-end gap-1">
|
||||
{#each argumentEntries as [key, value] (key)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="rounded-sm bg-purple-200/60 px-1.5 py-0.5 text-[10px] leading-none text-purple-700 transition-opacity dark:bg-purple-800/40 dark:text-purple-300 {hoveredArgKey &&
|
||||
hoveredArgKey !== key
|
||||
? 'opacity-30'
|
||||
: ''}"
|
||||
onmouseenter={() => (hoveredArgKey = key)}
|
||||
onmouseleave={() => (hoveredArgKey = null)}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<span class="max-w-xs break-all">{value}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<Card
|
||||
class="relative overflow-hidden rounded-[1.125rem] border border-destructive/50 bg-destructive/10 backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto {paddingClass}"
|
||||
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
|
||||
>
|
||||
<span class="{textSizeClass} text-destructive">{loadError}</span>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if isLoading}
|
||||
<Card
|
||||
class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 px-1 py-2 backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto {paddingClass}"
|
||||
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 w-3/4 animate-pulse rounded bg-foreground/20"></div>
|
||||
|
||||
<div class="h-3 w-full animate-pulse rounded bg-foreground/20"></div>
|
||||
|
||||
<div class="h-3 w-5/6 animate-pulse rounded bg-foreground/20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if hasContent}
|
||||
<Card
|
||||
class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 py-0 text-foreground backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto {paddingClass}"
|
||||
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
|
||||
>
|
||||
<span class="{textSizeClass} whitespace-pre-wrap">
|
||||
<!-- This formatting is needed to keep the text in proper shape -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
{#each contentParts as part, i (i)}{#if part.argKey}<span
|
||||
class="rounded-sm bg-purple-300/50 px-0.5 text-purple-900 transition-opacity dark:bg-purple-700/50 dark:text-purple-100 {hoveredArgKey &&
|
||||
hoveredArgKey !== part.argKey
|
||||
? 'opacity-30'
|
||||
: ''}"
|
||||
onmouseenter={() => (hoveredArgKey = part.argKey)}
|
||||
onmouseleave={() => (hoveredArgKey = null)}>{part.text}</span
|
||||
>{:else}<span class="transition-opacity {hoveredArgKey ? 'opacity-30' : ''}"
|
||||
>{part.text}</span
|
||||
>{/if}{/each}</span
|
||||
>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
|
||||
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
|
||||
import { BadgeChatStatistic } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ChatMessageStatsView } from '$lib/enums';
|
||||
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
|
||||
import { formatPerformanceTime } from '$lib/utils';
|
||||
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
|
||||
|
||||
|
|
@ -14,7 +15,9 @@
|
|||
isLive?: boolean;
|
||||
isProcessingPrompt?: boolean;
|
||||
initialView?: ChatMessageStatsView;
|
||||
agenticTimings?: ChatMessageAgenticTimings;
|
||||
onActiveViewChange?: (view: ChatMessageStatsView) => void;
|
||||
hideSummary?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -25,7 +28,9 @@
|
|||
isLive = false,
|
||||
isProcessingPrompt = false,
|
||||
initialView = ChatMessageStatsView.GENERATION,
|
||||
onActiveViewChange
|
||||
agenticTimings,
|
||||
onActiveViewChange,
|
||||
hideSummary = false
|
||||
}: Props = $props();
|
||||
|
||||
let activeView: ChatMessageStatsView = $derived(initialView);
|
||||
|
|
@ -87,6 +92,26 @@
|
|||
|
||||
// In live mode, generation tab is disabled until we have generation stats
|
||||
let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
|
||||
|
||||
let hasAgenticStats = $derived(agenticTimings !== undefined && agenticTimings.toolCallsCount > 0);
|
||||
|
||||
let agenticToolsPerSecond = $derived(
|
||||
hasAgenticStats && agenticTimings!.toolsMs > 0
|
||||
? (agenticTimings!.toolCallsCount / agenticTimings!.toolsMs) * MS_PER_SECOND
|
||||
: 0
|
||||
);
|
||||
|
||||
let formattedAgenticToolsTime = $derived(
|
||||
hasAgenticStats ? formatPerformanceTime(agenticTimings!.toolsMs) : DEFAULT_PERFORMANCE_TIME
|
||||
);
|
||||
|
||||
let agenticTotalTimeMs = $derived(
|
||||
hasAgenticStats
|
||||
? agenticTimings!.toolsMs + agenticTimings!.llm.predicted_ms + agenticTimings!.llm.prompt_ms
|
||||
: 0
|
||||
);
|
||||
|
||||
let formattedAgenticTotalTime = $derived(formatPerformanceTime(agenticTotalTimeMs));
|
||||
</script>
|
||||
|
||||
<div class="inline-flex items-center text-xs text-muted-foreground">
|
||||
|
|
@ -140,6 +165,52 @@
|
|||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
{#if hasAgenticStats}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
|
||||
ChatMessageStatsView.TOOLS
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:text-foreground'}"
|
||||
onclick={() => (activeView = ChatMessageStatsView.TOOLS)}
|
||||
>
|
||||
<Wrench class="h-3 w-3" />
|
||||
|
||||
<span class="sr-only">Tools</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Tool calls</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
{#if !hideSummary}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
|
||||
ChatMessageStatsView.SUMMARY
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:text-foreground'}"
|
||||
onclick={() => (activeView = ChatMessageStatsView.SUMMARY)}
|
||||
>
|
||||
<Layers class="h-3 w-3" />
|
||||
|
||||
<span class="sr-only">Summary</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Agentic summary</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
|
|
@ -164,6 +235,48 @@
|
|||
value="{tokensPerSecond.toFixed(2)} t/s"
|
||||
tooltipLabel="Generation speed"
|
||||
/>
|
||||
{:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats}
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Wrench}
|
||||
value="{agenticTimings!.toolCallsCount} calls"
|
||||
tooltipLabel="Tool calls executed"
|
||||
/>
|
||||
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value={formattedAgenticToolsTime}
|
||||
tooltipLabel="Tool execution time"
|
||||
/>
|
||||
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Gauge}
|
||||
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
|
||||
tooltipLabel="Tool execution rate"
|
||||
/>
|
||||
{:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats}
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Layers}
|
||||
value="{agenticTimings!.turns} turns"
|
||||
tooltipLabel="Agentic turns (LLM calls)"
|
||||
/>
|
||||
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={WholeWord}
|
||||
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
|
||||
tooltipLabel="Total tokens generated"
|
||||
/>
|
||||
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value={formattedAgenticTotalTime}
|
||||
tooltipLabel="Total time (LLM + tools)"
|
||||
/>
|
||||
{:else if hasPromptStats}
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@
|
|||
<Card
|
||||
class="overflow-y-auto rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
|
||||
data-multiline={isMultiline ? '' : undefined}
|
||||
style="border: 2px dashed hsl(var(--border)); max-height: var(--max-message-height);"
|
||||
style="border: 2px dashed hsl(var(--border)); max-height: var(--max-message-height); overflow-wrap: anywhere; word-break: break-word;"
|
||||
>
|
||||
<div
|
||||
class="relative transition-all duration-300 {isExpanded
|
||||
|
|
@ -157,9 +157,9 @@
|
|||
: 'max-height: none;'}
|
||||
>
|
||||
{#if currentConfig.renderUserContentAsMarkdown}
|
||||
<div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
|
||||
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
|
||||
<MarkdownContent
|
||||
class="markdown-system-content overflow-auto"
|
||||
class="markdown-system-content -my-4"
|
||||
content={message.content}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Brain } from '@lucide/svelte';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { Card } from '$lib/components/ui/card';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
hasRegularContent?: boolean;
|
||||
isStreaming?: boolean;
|
||||
reasoningContent: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
hasRegularContent = false,
|
||||
isStreaming = false,
|
||||
reasoningContent
|
||||
}: Props = $props();
|
||||
|
||||
const currentConfig = config();
|
||||
|
||||
let isExpanded = $state(currentConfig.showThoughtInProgress);
|
||||
|
||||
$effect(() => {
|
||||
if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) {
|
||||
isExpanded = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
|
||||
<Card class="gap-0 border-muted bg-muted/30 py-0">
|
||||
<Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<Brain class="h-4 w-4" />
|
||||
|
||||
<span class="text-sm font-medium">
|
||||
{isStreaming ? 'Reasoning...' : 'Reasoning'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={buttonVariants({
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
|
||||
})}
|
||||
>
|
||||
<ChevronsUpDownIcon class="h-4 w-4" />
|
||||
|
||||
<span class="sr-only">Toggle reasoning content</span>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="border-t border-muted px-3 pb-3">
|
||||
<div class="pt-3">
|
||||
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
|
||||
{reasoningContent ?? ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Card>
|
||||
</Collapsible.Root>
|
||||
|
|
@ -82,7 +82,7 @@
|
|||
{:else}
|
||||
{#if message.extra && message.extra.length > 0}
|
||||
<div class="mb-2 max-w-[80%]">
|
||||
<ChatAttachmentsList attachments={message.extra} readonly={true} imageHeight="h-80" />
|
||||
<ChatAttachmentsList attachments={message.extra} readonly imageHeight="h-80" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -428,9 +428,9 @@
|
|||
>
|
||||
<div class="w-full max-w-[48rem] px-4">
|
||||
<div class="mb-10 text-center" in:fade={{ duration: 300 }}>
|
||||
<h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
|
||||
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">llama.cpp</h1>
|
||||
|
||||
<p class="text-lg text-muted-foreground">
|
||||
<p class="text-muted-foreground md:text-lg">
|
||||
{serverStore.props?.modalities?.audio
|
||||
? 'Record audio, type a message '
|
||||
: 'Type a message'} or upload files to get started
|
||||
|
|
@ -441,8 +441,10 @@
|
|||
<div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
|
||||
<Alert.Root variant="destructive">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
|
||||
<Alert.Title class="flex items-center justify-between">
|
||||
<span>Server unavailable</span>
|
||||
|
||||
<button
|
||||
onclick={() => serverStore.fetch()}
|
||||
disabled={isServerLoading}
|
||||
|
|
@ -452,6 +454,7 @@
|
|||
{isServerLoading ? 'Retrying...' : 'Retry'}
|
||||
</button>
|
||||
</Alert.Title>
|
||||
|
||||
<Alert.Description>{serverError()}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
|
|
@ -467,7 +470,7 @@
|
|||
onSend={handleSendMessage}
|
||||
onStop={() => chatStore.stopGeneration()}
|
||||
onSystemPromptAdd={handleSystemPromptAdd}
|
||||
showHelperText={true}
|
||||
showHelperText
|
||||
bind:uploadedFiles
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@
|
|||
class={className}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
showMcpPromptButton
|
||||
onFilesAdd={handleFilesAdd}
|
||||
{onStop}
|
||||
onSubmit={handleSubmit}
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@
|
|||
</script>
|
||||
|
||||
<header
|
||||
class="pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end p-4 duration-200 ease-linear {sidebar.open
|
||||
class="pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end p-2 duration-200 ease-linear md:p-4 {sidebar.open
|
||||
? 'md:left-[var(--sidebar-width)]'
|
||||
: ''}"
|
||||
>
|
||||
<div class="pointer-events-auto flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-lg"
|
||||
onclick={toggleSettings}
|
||||
class="rounded-full backdrop-blur-lg"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@
|
|||
import {
|
||||
ChatSettingsFooter,
|
||||
ChatSettingsImportExportTab,
|
||||
ChatSettingsFields
|
||||
ChatSettingsFields,
|
||||
McpLogo,
|
||||
McpServersSettings
|
||||
} from '$lib/components/app';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
|
|
@ -259,6 +261,32 @@
|
|||
icon: Database,
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.MCP,
|
||||
icon: McpLogo,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
|
||||
label: 'Agentic loop max turns',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
|
||||
label: 'Always show agentic turns in conversation',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
|
||||
label: 'Max lines per tool preview',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
|
||||
label: 'Show tool call in progress',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.DEVELOPER,
|
||||
icon: Code,
|
||||
|
|
@ -431,7 +459,7 @@
|
|||
|
||||
<!-- Mobile Header with Horizontal Scrollable Menu -->
|
||||
<div class="flex flex-col pt-6 md:hidden">
|
||||
<div class="border-b border-border/30 py-4">
|
||||
<div class="border-b border-border/30 pt-4 md:py-4">
|
||||
<!-- Horizontal Scrollable Category Menu with Navigation -->
|
||||
<div class="relative flex items-center" style="scroll-padding: 1rem;">
|
||||
<button
|
||||
|
|
@ -492,6 +520,19 @@
|
|||
|
||||
{#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
|
||||
<ChatSettingsImportExportTab />
|
||||
{:else if currentSection.title === SETTINGS_SECTION_TITLES.MCP}
|
||||
<div class="space-y-6">
|
||||
<ChatSettingsFields
|
||||
fields={currentSection.fields}
|
||||
{localConfig}
|
||||
onConfigChange={handleConfigChange}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
|
||||
<div class="border-t border-border/30 pt-6">
|
||||
<McpServersSettings />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<ChatSettingsFields
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
* - Horizontal scroll with smooth navigation arrows
|
||||
* - Image thumbnails with lazy loading and error fallback
|
||||
* - File type icons for non-image files (PDF, text, audio, etc.)
|
||||
* - MCP prompt attachments with expandable content preview
|
||||
* - Click-to-preview with full-size dialog and download option
|
||||
* - "View All" button when `limitToSingleRow` is enabled and content overflows
|
||||
* - Vision modality validation to warn about unsupported image uploads
|
||||
|
|
@ -50,6 +51,33 @@
|
|||
*/
|
||||
export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachmentsList.svelte';
|
||||
|
||||
/**
|
||||
* Displays MCP Prompt attachment with expandable content preview.
|
||||
* Shows server name, prompt name, and allows expanding to view full prompt arguments
|
||||
* and content. Used when user selects a prompt from ChatFormPromptPicker.
|
||||
*/
|
||||
export { default as ChatAttachmentMcpPrompt } from './ChatAttachments/ChatAttachmentMcpPrompt.svelte';
|
||||
|
||||
/**
|
||||
* Displays a single MCP Resource attachment with icon, name, and server info.
|
||||
* Shows loading/error states and supports remove action.
|
||||
* Used within ChatAttachmentMcpResources for individual resource display.
|
||||
*/
|
||||
export { default as ChatAttachmentMcpResource } from './ChatAttachments/ChatAttachmentMcpResource.svelte';
|
||||
|
||||
/**
|
||||
* Full-size attachment preview component for dialog display. Handles different file types:
|
||||
* images (full-size display), text files (syntax highlighted), PDFs (text extraction or image preview),
|
||||
* audio (placeholder with download), and generic files (download option).
|
||||
*/
|
||||
export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachmentPreview.svelte';
|
||||
|
||||
/**
|
||||
* Displays MCP Resource attachments as a horizontal carousel.
|
||||
* Shows resource name, URI, and allows clicking to view resource content.
|
||||
*/
|
||||
export { default as ChatAttachmentMcpResources } from './ChatAttachments/ChatAttachmentMcpResources.svelte';
|
||||
|
||||
/**
|
||||
* Thumbnail for non-image file attachments. Displays file type icon based on extension,
|
||||
* file name (truncated), and file size.
|
||||
|
|
@ -71,20 +99,15 @@ export { default as ChatAttachmentThumbnailImage } from './ChatAttachments/ChatA
|
|||
*/
|
||||
export { default as ChatAttachmentsViewAll } from './ChatAttachments/ChatAttachmentsViewAll.svelte';
|
||||
|
||||
/**
|
||||
* Full-size preview dialog for attachments. Opens when clicking on any attachment
|
||||
* thumbnail. Shows the attachment in full size with options to download or close.
|
||||
* Handles both image and non-image attachments with appropriate rendering.
|
||||
*/
|
||||
export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachmentPreview.svelte';
|
||||
/**
|
||||
*
|
||||
* FORM
|
||||
*
|
||||
* Components for the chat input area. The form handles user input, file attachments,
|
||||
* audio recording. It integrates with multiple stores:
|
||||
* audio recording, and MCP prompts & resources selection. It integrates with multiple stores:
|
||||
* - `chatStore` for message submission and generation control
|
||||
* - `modelsStore` for model selection and validation
|
||||
* - `mcpStore` for MCP prompt browsing and loading
|
||||
*
|
||||
* The form exposes a public API for programmatic control from parent components
|
||||
* (focus, height reset, model selector, validation).
|
||||
|
|
@ -95,7 +118,7 @@ export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachme
|
|||
* **ChatForm** - Main chat input component with rich features
|
||||
*
|
||||
* The primary input interface for composing and sending chat messages.
|
||||
* Orchestrates text input, file attachments, audio recording.
|
||||
* Orchestrates text input, file attachments, audio recording, and MCP prompts.
|
||||
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
|
||||
*
|
||||
* **Architecture:**
|
||||
|
|
@ -108,11 +131,14 @@ export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachme
|
|||
* - IME-safe Enter key handling (waits for composition end)
|
||||
* - Shift+Enter for newline, Enter for submit
|
||||
* - Paste handler for files and long text (> {pasteLongTextToFileLen} chars → file conversion)
|
||||
* - Keyboard shortcut `/` triggers MCP prompt picker
|
||||
*
|
||||
* **Features:**
|
||||
* - Auto-resizing textarea with placeholder
|
||||
* - File upload via button dropdown (images/text/PDF), drag-drop, or paste
|
||||
* - Audio recording with WAV conversion (when model supports audio)
|
||||
* - MCP prompt picker with search and argument forms
|
||||
* - MCP reource picker with component to list attached resources at the bottom of Chat Form
|
||||
* - Model selector integration (router mode)
|
||||
* - Loading state with stop button, disabled state for errors
|
||||
*
|
||||
|
|
@ -144,6 +170,13 @@ export { default as ChatForm } from './ChatForm/ChatForm.svelte';
|
|||
*/
|
||||
export { default as ChatFormActionAttachmentsDropdown } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
|
||||
|
||||
/**
|
||||
* Mobile sheet variant of the file attachment selector. Renders a bottom sheet
|
||||
* with the same options as ChatFormActionAttachmentsDropdown, optimized for
|
||||
* touch interaction on mobile devices.
|
||||
*/
|
||||
export { default as ChatFormActionAttachmentsSheet } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsSheet.svelte';
|
||||
|
||||
/**
|
||||
* Audio recording button with real-time recording indicator. Records audio
|
||||
* and converts to WAV format for upload. Only visible when the active model
|
||||
|
|
@ -182,6 +215,118 @@ export { default as ChatFormHelperText } from './ChatForm/ChatFormHelperText.sve
|
|||
*/
|
||||
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormPromptPicker** - MCP prompt selection interface
|
||||
*
|
||||
* Floating picker for browsing and selecting MCP Server Prompts.
|
||||
* Triggered by typing `/` in the chat input or choosing `MCP Prompt` option in ChatFormActionAttachmentsDropdown.
|
||||
* Loads prompts from connected MCP servers and allows users to select and configure them.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Fetches available prompts from mcpStore
|
||||
* - Manages selection state and keyboard navigation internally
|
||||
* - Delegates argument input to ChatFormPromptPickerArgumentForm
|
||||
* - Communicates prompt loading lifecycle via callbacks
|
||||
*
|
||||
* **Prompt Loading Flow:**
|
||||
* 1. User selects prompt → `onPromptLoadStart` called with placeholder ID
|
||||
* 2. Prompt content fetched from MCP server asynchronously
|
||||
* 3. On success → `onPromptLoadComplete` with full prompt data
|
||||
* 4. On failure → `onPromptLoadError` with error details
|
||||
*
|
||||
* **Features:**
|
||||
* - Search/filter prompts by name across all connected servers
|
||||
* - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
|
||||
* - Argument input forms for prompts with required parameters
|
||||
* - Autocomplete suggestions for argument values
|
||||
* - Loading states with skeleton placeholders
|
||||
* - Server information header per prompt for visual identification
|
||||
*
|
||||
* **Exported API:**
|
||||
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatFormPromptPicker
|
||||
* bind:this={pickerRef}
|
||||
* isOpen={showPicker}
|
||||
* searchQuery={promptQuery}
|
||||
* onClose={() => showPicker = false}
|
||||
* onPromptLoadStart={(id, info) => addPlaceholder(id, info)}
|
||||
* onPromptLoadComplete={(id, result) => replacePlaceholder(id, result)}
|
||||
* onPromptLoadError={(id, error) => handleError(id, error)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatFormPromptPicker } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte';
|
||||
|
||||
/**
|
||||
* Form for entering MCP prompt arguments. Displays input fields for each
|
||||
* required argument defined by the prompt. Validates input and submits
|
||||
* when all required fields are filled. Shows argument descriptions as hints.
|
||||
*/
|
||||
export { default as ChatFormPromptPickerArgumentForm } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentForm.svelte';
|
||||
|
||||
/**
|
||||
* Single argument input field with autocomplete suggestions. Fetches suggestions
|
||||
* from MCP server based on argument type. Supports keyboard navigation through
|
||||
* suggestions list. Used within ChatFormPromptPickerArgumentForm.
|
||||
*/
|
||||
export { default as ChatFormPromptPickerArgumentInput } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentInput.svelte';
|
||||
|
||||
/**
|
||||
* Shared popover wrapper for inline picker popovers (prompts, resources).
|
||||
* Provides consistent positioning, styling, and open/close behavior.
|
||||
*/
|
||||
export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickerPopover.svelte';
|
||||
|
||||
/**
|
||||
* Generic scrollable list for picker popovers. Provides search input,
|
||||
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
|
||||
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
|
||||
* Shared by ChatFormPromptPicker and ChatFormResourcePicker.
|
||||
*/
|
||||
export { default as ChatFormPickerList } from './ChatForm/ChatFormPicker/ChatFormPickerList.svelte';
|
||||
|
||||
/**
|
||||
* Generic button wrapper for picker list items. Provides consistent styling,
|
||||
* hover/selected states, and data-picker-index attribute for scroll-into-view.
|
||||
* Shared by ChatFormPromptPicker and ChatFormResourcePicker.
|
||||
*/
|
||||
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPicker/ChatFormPickerListItem.svelte';
|
||||
|
||||
/**
|
||||
* Generic header for picker items displaying server favicon, label, item title,
|
||||
* and optional description. Accepts `titleExtra` and `subtitle` snippets for
|
||||
* custom content like badges or URIs. Shared by both pickers.
|
||||
*/
|
||||
export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPicker/ChatFormPickerItemHeader.svelte';
|
||||
|
||||
/**
|
||||
* Generic skeleton loading placeholder for picker list items. Configurable
|
||||
* title width and optional badge skeleton. Shared by both pickers.
|
||||
*/
|
||||
export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormResourcePicker** - MCP resource selection interface
|
||||
*
|
||||
* Floating picker for browsing and attaching MCP Server Resources.
|
||||
* Triggered by typing `@` in the chat input.
|
||||
* Loads resources from connected MCP servers and allows users to attach them to the chat context.
|
||||
*
|
||||
* **Features:**
|
||||
* - Search/filter resources by name, title, description, or URI across all connected servers
|
||||
* - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
|
||||
* - Shows attached state for already-attached resources
|
||||
* - Loading states with skeleton placeholders
|
||||
* - Server information header per resource for visual identification
|
||||
*
|
||||
* **Exported API:**
|
||||
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
|
||||
*/
|
||||
export { default as ChatFormResourcePicker } from './ChatForm/ChatFormResourcePicker/ChatFormResourcePicker.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* MESSAGES
|
||||
|
|
@ -248,6 +393,7 @@ export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
|
|||
*
|
||||
* **User Messages:**
|
||||
* - Shows attachments via ChatAttachmentsList
|
||||
* - Displays MCP prompts if present
|
||||
* - Edit creates new branch or preserves responses
|
||||
*
|
||||
* **Assistant Messages:**
|
||||
|
|
@ -276,6 +422,46 @@ export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
|
|||
*/
|
||||
export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
|
||||
|
||||
/**
|
||||
* **ChatMessageAgenticContent** - Agentic workflow output display
|
||||
*
|
||||
* Specialized renderer for assistant messages containing agentic workflow markers.
|
||||
* Parses structured content and displays tool calls and reasoning blocks as
|
||||
* interactive collapsible sections with real-time streaming support.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses `parseAgenticContent()` from `$lib/utils` to parse markers
|
||||
* - Renders sections as CollapsibleContentBlock components
|
||||
* - Handles streaming state for progressive content display
|
||||
* - Falls back to MarkdownContent for plain text sections
|
||||
*
|
||||
* **Marker Format:**
|
||||
* - Tool calls: in constants/agentic.ts (AGENTIC_TAGS)
|
||||
* - Reasoning: in constants/agentic.ts (REASONING_TAGS)
|
||||
* - Partial markers handled gracefully during streaming
|
||||
*
|
||||
* **Execution States:**
|
||||
* - **Streaming**: Animated spinner, block expanded, auto-scroll enabled
|
||||
* - **Pending**: Waiting indicator for queued tool calls
|
||||
* - **Completed**: Static display, block collapsed by default
|
||||
*
|
||||
* **Features:**
|
||||
* - JSON arguments syntax highlighting via SyntaxHighlightedCode
|
||||
* - Tool results display with formatting
|
||||
* - Plain text sections between markers rendered as markdown
|
||||
* - Smart collapse defaults (expanded while streaming, collapsed when done)
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatMessageAgenticContent
|
||||
* content={message.content}
|
||||
* {message}
|
||||
* isStreaming={isGenerating}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatMessageAgenticContent } from './ChatMessages/ChatMessageAgenticContent.svelte';
|
||||
|
||||
/**
|
||||
* Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate
|
||||
* buttons based on message role. Includes branching controls when message has siblings.
|
||||
|
|
@ -299,6 +485,20 @@ export { default as ChatMessageBranchingControls } from './ChatMessages/ChatMess
|
|||
*/
|
||||
export { default as ChatMessageStatistics } from './ChatMessages/ChatMessageStatistics.svelte';
|
||||
|
||||
/**
|
||||
* MCP prompt display in user messages. Shows when user selected an MCP prompt
|
||||
* via ChatFormPromptPicker. Displays server name, prompt name, and expandable
|
||||
* content preview. Stored in message.extra as DatabaseMessageExtraMcpPrompt.
|
||||
*/
|
||||
export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessageMcpPrompt.svelte';
|
||||
|
||||
/**
|
||||
* Formatted content display for MCP prompt messages. Renders the full prompt
|
||||
* content with arguments in a readable format. Used within ChatMessageMcpPrompt
|
||||
* for the expanded view.
|
||||
*/
|
||||
export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessageMcpPromptContent.svelte';
|
||||
|
||||
/**
|
||||
* System message display component. Renders system messages with distinct styling.
|
||||
* Visibility controlled by `showSystemMessage` config setting.
|
||||
|
|
@ -307,7 +507,7 @@ export { default as ChatMessageSystem } from './ChatMessages/ChatMessageSystem.s
|
|||
|
||||
/**
|
||||
* User message display component. Renders user messages with right-aligned bubble styling.
|
||||
* Shows message content, attachments via ChatAttachmentsList.
|
||||
* Shows message content, attachments via ChatAttachmentsList, and MCP prompts if present.
|
||||
* Supports inline editing mode with ChatMessageEditForm integration.
|
||||
*/
|
||||
export { default as ChatMessageUser } from './ChatMessages/ChatMessageUser.svelte';
|
||||
|
|
@ -385,7 +585,7 @@ export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditFo
|
|||
* @example
|
||||
* ```svelte
|
||||
* <!-- In chat route -->
|
||||
* <ChatScreen showCenteredEmpty={true} />
|
||||
* <ChatScreen showCenteredEmpty />
|
||||
*
|
||||
* <!-- In conversation route -->
|
||||
* <ChatScreen showCenteredEmpty={false} />
|
||||
|
|
@ -460,6 +660,7 @@ export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProc
|
|||
* - **Sampling**: Temperature, top_p, top_k, min_p, repeat_penalty, etc.
|
||||
* - **Penalties**: Frequency penalty, presence penalty, repeat last N
|
||||
* - **Import/Export**: Conversation backup and restore
|
||||
* - **MCP**: MCP server management (opens DialogChatSettings with MCP tab)
|
||||
* - **Developer**: Debug options, disable auto-scroll
|
||||
*
|
||||
* **Parameter Sync:**
|
||||
|
|
|
|||
|
|
@ -25,13 +25,9 @@
|
|||
BOOL_TRUE_STRING,
|
||||
SETTINGS_KEYS
|
||||
} from '$lib/constants';
|
||||
import { UrlPrefix } from '$lib/enums';
|
||||
import { ColorMode, UrlProtocol } from '$lib/enums';
|
||||
import { FileTypeText } from '$lib/enums/files';
|
||||
import {
|
||||
highlightCode,
|
||||
detectIncompleteCodeBlock,
|
||||
type IncompleteCodeBlock
|
||||
} from '$lib/utils/code';
|
||||
import { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from '$lib/utils';
|
||||
import '$styles/katex-custom.scss';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
|
|
@ -505,7 +501,7 @@
|
|||
|
||||
// Don't handle data URLs or already-handled images
|
||||
if (
|
||||
img.src.startsWith(UrlPrefix.DATA) ||
|
||||
img.src.startsWith(UrlProtocol.DATA) ||
|
||||
img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
|
||||
)
|
||||
return;
|
||||
|
|
@ -560,7 +556,7 @@
|
|||
|
||||
$effect(() => {
|
||||
const currentMode = mode.current;
|
||||
const isDark = currentMode === 'dark';
|
||||
const isDark = currentMode === ColorMode.DARK;
|
||||
|
||||
loadHighlightTheme(isDark);
|
||||
});
|
||||
|
|
@ -622,7 +618,7 @@
|
|||
<ActionIconsCodeBlock
|
||||
code={incompleteCodeBlock.code}
|
||||
language={incompleteCodeBlock.language || 'text'}
|
||||
disabled={true}
|
||||
disabled
|
||||
onPreview={(code, lang) => {
|
||||
previewCode = code;
|
||||
previewLanguage = lang;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import { ColorMode } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
|
|
@ -39,7 +40,7 @@
|
|||
|
||||
$effect(() => {
|
||||
const currentMode = mode.current;
|
||||
const isDark = currentMode === 'dark';
|
||||
const isDark = currentMode === ColorMode.DARK;
|
||||
|
||||
loadHighlightTheme(isDark);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte
|
|||
* bind:open
|
||||
* icon={BrainIcon}
|
||||
* title="Thinking..."
|
||||
* isStreaming={true}
|
||||
* isStreaming
|
||||
* >
|
||||
* {reasoningContent}
|
||||
* </CollapsibleContentBlock>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Download } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { SyntaxHighlightedCode, ActionIconCopyToClipboard } from '$lib/components/app';
|
||||
import {
|
||||
getLanguageFromFilename,
|
||||
isCodeResource,
|
||||
isImageResource,
|
||||
downloadResourceContent
|
||||
} from '$lib/utils';
|
||||
import { MimeTypeIncludes, MimeTypeText } from '$lib/enums';
|
||||
import { DEFAULT_RESOURCE_FILENAME } from '$lib/constants';
|
||||
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
extra: DatabaseMessageExtraMcpResource;
|
||||
}
|
||||
|
||||
let { open = $bindable(), onOpenChange, extra }: Props = $props();
|
||||
|
||||
const serverName = $derived(mcpStore.getServerDisplayName(extra.serverName));
|
||||
const favicon = $derived(mcpStore.getServerFavicon(extra.serverName));
|
||||
|
||||
function getLanguage(): string {
|
||||
if (extra.mimeType?.includes(MimeTypeIncludes.JSON)) return MimeTypeIncludes.JSON;
|
||||
if (extra.mimeType?.includes(MimeTypeIncludes.JAVASCRIPT)) return MimeTypeIncludes.JAVASCRIPT;
|
||||
if (extra.mimeType?.includes(MimeTypeIncludes.TYPESCRIPT)) return MimeTypeIncludes.TYPESCRIPT;
|
||||
|
||||
const name = extra.name || extra.uri || '';
|
||||
return getLanguageFromFilename(name) || 'plaintext';
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!extra.content) return;
|
||||
downloadResourceContent(
|
||||
extra.content,
|
||||
extra.mimeType || MimeTypeText.PLAIN,
|
||||
extra.name || DEFAULT_RESOURCE_FILENAME
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="pr-8">{extra.name}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">{extra.uri}</span>
|
||||
|
||||
{#if serverName}
|
||||
<span class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
·
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{serverName}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if extra.mimeType}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 text-xs">{extra.mimeType}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<ActionIconCopyToClipboard
|
||||
text={extra.content}
|
||||
canCopy={!!extra.content}
|
||||
ariaLabel="Copy content"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={handleDownload}
|
||||
disabled={!extra.content}
|
||||
title="Download content"
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-auto">
|
||||
{#if isImageResource(extra.mimeType, extra.uri) && extra.content}
|
||||
<div class="flex items-center justify-center">
|
||||
<img
|
||||
src={extra.content.startsWith('data:')
|
||||
? extra.content
|
||||
: `data:${extra.mimeType || 'image/png'};base64,${extra.content}`}
|
||||
alt={extra.name}
|
||||
class="max-h-[70vh] max-w-full rounded object-contain"
|
||||
/>
|
||||
</div>
|
||||
{:else if isCodeResource(extra.mimeType, extra.uri) && extra.content}
|
||||
<SyntaxHighlightedCode code={extra.content} language={getLanguage()} maxHeight="70vh" />
|
||||
{:else if extra.content}
|
||||
<pre
|
||||
class="max-h-[70vh] overflow-auto rounded-md border bg-muted/30 p-4 font-mono text-sm break-words whitespace-pre-wrap">{extra.content}</pre>
|
||||
{:else}
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">No content available</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
<script lang="ts">
|
||||
import { FolderOpen, Plus, Loader2, Braces } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import {
|
||||
mcpResources,
|
||||
mcpTotalResourceCount,
|
||||
mcpResourceStore
|
||||
} from '$lib/stores/mcp-resources.svelte';
|
||||
import {
|
||||
McpResourceBrowser,
|
||||
McpResourcePreview,
|
||||
McpResourceTemplateForm
|
||||
} from '$lib/components/app';
|
||||
import { getResourceDisplayName } from '$lib/utils';
|
||||
import type { MCPResourceInfo, MCPResourceContent, MCPResourceTemplateInfo } from '$lib/types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onAttach?: (resource: MCPResourceInfo) => void;
|
||||
preSelectedUri?: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), onOpenChange, onAttach, preSelectedUri }: Props = $props();
|
||||
|
||||
let selectedResources = new SvelteSet<string>();
|
||||
let lastSelectedUri = $state<string | null>(null);
|
||||
let isAttaching = $state(false);
|
||||
|
||||
let selectedTemplate = $state<MCPResourceTemplateInfo | null>(null);
|
||||
let templatePreviewUri = $state<string | null>(null);
|
||||
let templatePreviewContent = $state<MCPResourceContent[] | null>(null);
|
||||
let templatePreviewLoading = $state(false);
|
||||
let templatePreviewError = $state<string | null>(null);
|
||||
|
||||
const totalCount = $derived(mcpTotalResourceCount());
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
loadResources();
|
||||
|
||||
if (preSelectedUri) {
|
||||
selectedResources.clear();
|
||||
selectedResources.add(preSelectedUri);
|
||||
lastSelectedUri = preSelectedUri;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function loadResources() {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (initialized) {
|
||||
await mcpStore.fetchAllResources();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
open = newOpen;
|
||||
onOpenChange?.(newOpen);
|
||||
|
||||
if (!newOpen) {
|
||||
selectedResources.clear();
|
||||
lastSelectedUri = null;
|
||||
clearTemplateState();
|
||||
}
|
||||
}
|
||||
|
||||
function clearTemplateState() {
|
||||
selectedTemplate = null;
|
||||
templatePreviewUri = null;
|
||||
templatePreviewContent = null;
|
||||
templatePreviewLoading = false;
|
||||
templatePreviewError = null;
|
||||
}
|
||||
|
||||
function handleTemplateSelect(template: MCPResourceTemplateInfo) {
|
||||
selectedResources.clear();
|
||||
lastSelectedUri = null;
|
||||
|
||||
if (
|
||||
selectedTemplate?.uriTemplate === template.uriTemplate &&
|
||||
selectedTemplate?.serverName === template.serverName
|
||||
) {
|
||||
clearTemplateState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
selectedTemplate = template;
|
||||
templatePreviewUri = null;
|
||||
templatePreviewContent = null;
|
||||
templatePreviewLoading = false;
|
||||
templatePreviewError = null;
|
||||
}
|
||||
|
||||
async function handleTemplateResolve(uri: string, serverName: string) {
|
||||
templatePreviewUri = uri;
|
||||
templatePreviewContent = null;
|
||||
templatePreviewLoading = true;
|
||||
templatePreviewError = null;
|
||||
|
||||
try {
|
||||
const content = await mcpStore.readResourceByUri(serverName, uri);
|
||||
|
||||
if (content) {
|
||||
templatePreviewContent = content;
|
||||
} else {
|
||||
templatePreviewError = 'Failed to read resource';
|
||||
}
|
||||
} catch (error) {
|
||||
templatePreviewError = error instanceof Error ? error.message : 'Unknown error';
|
||||
} finally {
|
||||
templatePreviewLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTemplateCancelForm() {
|
||||
clearTemplateState();
|
||||
}
|
||||
|
||||
async function handleAttachTemplateResource() {
|
||||
if (!templatePreviewUri || !selectedTemplate || !templatePreviewContent) return;
|
||||
|
||||
isAttaching = true;
|
||||
|
||||
try {
|
||||
const knownResource = mcpResourceStore.findResourceByUri(templatePreviewUri);
|
||||
|
||||
if (knownResource) {
|
||||
if (!mcpResourceStore.isAttached(knownResource.uri)) {
|
||||
await mcpStore.attachResource(knownResource.uri);
|
||||
}
|
||||
|
||||
toast.success(`Resource attached: ${knownResource.title || knownResource.name}`);
|
||||
} else {
|
||||
if (mcpResourceStore.isAttached(templatePreviewUri)) {
|
||||
toast.info('Resource already attached');
|
||||
handleOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceInfo: MCPResourceInfo = {
|
||||
uri: templatePreviewUri,
|
||||
name: templatePreviewUri.split('/').pop() || templatePreviewUri,
|
||||
serverName: selectedTemplate.serverName
|
||||
};
|
||||
|
||||
const attachment = mcpResourceStore.addAttachment(resourceInfo);
|
||||
mcpResourceStore.updateAttachmentContent(attachment.id, templatePreviewContent);
|
||||
|
||||
toast.success(`Resource attached: ${resourceInfo.name}`);
|
||||
}
|
||||
|
||||
handleOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to attach template resource:', error);
|
||||
} finally {
|
||||
isAttaching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceSelect(resource: MCPResourceInfo, shiftKey: boolean = false) {
|
||||
clearTemplateState();
|
||||
|
||||
if (shiftKey && lastSelectedUri) {
|
||||
const allResources = getAllResourcesFlatInTreeOrder();
|
||||
const lastIndex = allResources.findIndex((r) => r.uri === lastSelectedUri);
|
||||
const currentIndex = allResources.findIndex((r) => r.uri === resource.uri);
|
||||
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
selectedResources.add(allResources[i].uri);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectedResources.clear();
|
||||
selectedResources.add(resource.uri);
|
||||
lastSelectedUri = resource.uri;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceToggle(resource: MCPResourceInfo, checked: boolean) {
|
||||
clearTemplateState();
|
||||
|
||||
if (checked) {
|
||||
selectedResources.add(resource.uri);
|
||||
} else {
|
||||
selectedResources.delete(resource.uri);
|
||||
}
|
||||
|
||||
lastSelectedUri = resource.uri;
|
||||
}
|
||||
|
||||
function getAllResourcesFlatInTreeOrder(): MCPResourceInfo[] {
|
||||
const allResources: MCPResourceInfo[] = [];
|
||||
const resourcesMap = mcpResources();
|
||||
|
||||
for (const [serverName, serverRes] of resourcesMap.entries()) {
|
||||
for (const resource of serverRes.resources) {
|
||||
allResources.push({ ...resource, serverName });
|
||||
}
|
||||
}
|
||||
|
||||
return allResources.sort((a, b) => {
|
||||
const aName = getResourceDisplayName(a);
|
||||
const bName = getResourceDisplayName(b);
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAttach() {
|
||||
if (selectedResources.size === 0) return;
|
||||
|
||||
isAttaching = true;
|
||||
|
||||
try {
|
||||
const allResources = getAllResourcesFlatInTreeOrder();
|
||||
const resourcesToAttach = allResources.filter((r) => selectedResources.has(r.uri));
|
||||
|
||||
for (const resource of resourcesToAttach) {
|
||||
await mcpStore.attachResource(resource.uri);
|
||||
onAttach?.(resource);
|
||||
}
|
||||
|
||||
const count = resourcesToAttach.length;
|
||||
|
||||
toast.success(
|
||||
count === 1
|
||||
? `Resource attached: ${resourcesToAttach[0].name}`
|
||||
: `${count} resources attached`
|
||||
);
|
||||
|
||||
handleOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to attach resources:', error);
|
||||
} finally {
|
||||
isAttaching = false;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTemplateUri = $derived(selectedTemplate?.uriTemplate ?? null);
|
||||
|
||||
const hasTemplateResult = $derived(
|
||||
!!selectedTemplate && !!templatePreviewContent && !!templatePreviewUri
|
||||
);
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="max-h-[80vh] !max-w-4xl overflow-hidden p-0">
|
||||
<Dialog.Header class="border-b border-border/30 px-6 py-4">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<FolderOpen class="h-5 w-5" />
|
||||
|
||||
<span>MCP Resources</span>
|
||||
|
||||
{#if totalCount > 0}
|
||||
<span class="text-sm font-normal text-muted-foreground">({totalCount})</span>
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
|
||||
<Dialog.Description>
|
||||
Browse and attach resources from connected MCP servers to your chat context.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex h-[500px] min-w-0">
|
||||
<div class="w-72 shrink-0 overflow-y-auto border-r border-border/30 p-4">
|
||||
<McpResourceBrowser
|
||||
onSelect={handleResourceSelect}
|
||||
onToggle={handleResourceToggle}
|
||||
onTemplateSelect={handleTemplateSelect}
|
||||
selectedUris={selectedResources}
|
||||
{selectedTemplateUri}
|
||||
expandToUri={preSelectedUri}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-auto p-4">
|
||||
{#if selectedTemplate && !templatePreviewContent}
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Braces class="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<span class="text-sm font-medium">
|
||||
{selectedTemplate.title || selectedTemplate.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if selectedTemplate.description}
|
||||
<p class="mb-4 text-xs text-muted-foreground">
|
||||
{selectedTemplate.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mb-4 rounded-md border border-border/50 bg-muted/30 px-3 py-2">
|
||||
<p class="font-mono text-xs break-all text-muted-foreground">
|
||||
{selectedTemplate.uriTemplate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if templatePreviewLoading}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if templatePreviewError}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-2 text-red-500">
|
||||
<span class="text-sm">{templatePreviewError}</span>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
templatePreviewError = null;
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<McpResourceTemplateForm
|
||||
template={selectedTemplate}
|
||||
onResolve={handleTemplateResolve}
|
||||
onCancel={handleTemplateCancelForm}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if hasTemplateResult}
|
||||
<!-- Template resolved: show preview -->
|
||||
<McpResourcePreview
|
||||
resource={{
|
||||
uri: templatePreviewUri ?? '',
|
||||
name: templatePreviewUri?.split('/').pop() || (templatePreviewUri ?? ''),
|
||||
serverName: selectedTemplate?.serverName || ''
|
||||
}}
|
||||
preloadedContent={templatePreviewContent}
|
||||
/>
|
||||
{:else if selectedResources.size === 1}
|
||||
{@const allResources = getAllResourcesFlatInTreeOrder()}
|
||||
{@const selectedResource = allResources.find((r) => selectedResources.has(r.uri))}
|
||||
|
||||
<McpResourcePreview resource={selectedResource ?? null} />
|
||||
{:else if selectedResources.size > 1}
|
||||
<div class="flex flex-col gap-10">
|
||||
{#each getAllResourcesFlatInTreeOrder() as resource (resource.uri)}
|
||||
{#if selectedResources.has(resource.uri)}
|
||||
<McpResourcePreview {resource} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Select a resource to preview
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="border-t border-border/30 px-6 py-4">
|
||||
<Button variant="outline" onclick={() => handleOpenChange(false)}>Cancel</Button>
|
||||
|
||||
{#if hasTemplateResult}
|
||||
<Button onclick={handleAttachTemplateResource} disabled={isAttaching}>
|
||||
{#if isAttaching}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
|
||||
Attach Resource
|
||||
</Button>
|
||||
{:else}
|
||||
<Button onclick={handleAttach} disabled={selectedResources.size === 0 || isAttaching}>
|
||||
{#if isAttaching}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
|
||||
Attach {selectedResources.size > 0 ? `(${selectedResources.size})` : 'Resource'}
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { McpLogo, McpServersSettings } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let { onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
function handleClose() {
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={handleClose}>
|
||||
<Dialog.Content
|
||||
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
|
||||
md:h-[80dvh] md:h-auto md:max-h-[80dvh] md:min-h-0 md:rounded-lg"
|
||||
style="max-width: 56rem;"
|
||||
>
|
||||
<div class="grid gap-2 border-b border-border/30 p-4 md:p-6">
|
||||
<Dialog.Title class="inline-flex items-center text-lg font-semibold">
|
||||
<McpLogo class="mr-2 inline h-4 w-4" />
|
||||
|
||||
MCP Servers
|
||||
</Dialog.Title>
|
||||
|
||||
<Dialog.Description class="text-sm text-muted-foreground">
|
||||
Add and configure MCP servers to enable agentic tool execution capabilities.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-6">
|
||||
<McpServersSettings />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -414,3 +414,57 @@ export { default as DialogConversationSelection } from './DialogConversationSele
|
|||
* ```
|
||||
*/
|
||||
export { default as DialogModelInformation } from './DialogModelInformation.svelte';
|
||||
|
||||
/**
|
||||
* **DialogMcpResources** - MCP resources browser dialog
|
||||
*
|
||||
* Dialog for browsing and attaching MCP resources to chat context.
|
||||
* Displays resources from connected MCP servers in a tree structure
|
||||
* with preview panel and multi-select support.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN Dialog with two-panel layout
|
||||
* - Left panel: McpResourceBrowser with tree navigation
|
||||
* - Right panel: McpResourcePreview for selected resource
|
||||
* - Integrates with mcpStore for resource fetching and attachment
|
||||
*
|
||||
* **Features:**
|
||||
* - Tree-based resource navigation by server and path
|
||||
* - Single and multi-select with shift+click
|
||||
* - Resource preview with content display
|
||||
* - Quick attach button per resource
|
||||
* - Batch attach for multiple selections
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogMcpResources
|
||||
* bind:open={showResources}
|
||||
* onAttach={handleResourceAttach}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogMcpResources } from './DialogMcpResources.svelte';
|
||||
|
||||
/**
|
||||
* **DialogMcpResourcePreview** - MCP resource content preview
|
||||
*
|
||||
* Dialog for previewing the content of a stored MCP resource attachment.
|
||||
* Displays the resource content with syntax highlighting for code,
|
||||
* image rendering for images, and plain text for other content.
|
||||
*
|
||||
* **Features:**
|
||||
* - Syntax highlighted code preview
|
||||
* - Image rendering for image resources
|
||||
* - Copy to clipboard and download actions
|
||||
* - Server name and favicon display
|
||||
* - MIME type badge
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogMcpResourcePreview
|
||||
* bind:open={previewOpen}
|
||||
* extra={mcpResourceExtra}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.svelte';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
value: string;
|
||||
suggestions?: string[];
|
||||
isLoadingSuggestions?: boolean;
|
||||
isAutocompleteActive?: boolean;
|
||||
autocompleteIndex?: number;
|
||||
onInput: (value: string) => void;
|
||||
onKeydown: (event: KeyboardEvent) => void;
|
||||
onBlur: () => void;
|
||||
onFocus: () => void;
|
||||
onSelectSuggestion: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
value = '',
|
||||
suggestions = [],
|
||||
isLoadingSuggestions = false,
|
||||
isAutocompleteActive = false,
|
||||
autocompleteIndex = 0,
|
||||
onInput,
|
||||
onKeydown,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onSelectSuggestion
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative grid gap-1">
|
||||
<Label for="tpl-arg-{name}" class="mb-1 text-muted-foreground">
|
||||
<span>
|
||||
{name}
|
||||
|
||||
<span class="text-destructive">*</span>
|
||||
</span>
|
||||
|
||||
{#if isLoadingSuggestions}
|
||||
<span class="text-xs text-muted-foreground/50">...</span>
|
||||
{/if}
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="tpl-arg-{name}"
|
||||
type="text"
|
||||
{value}
|
||||
oninput={(e) => onInput(e.currentTarget.value)}
|
||||
onkeydown={onKeydown}
|
||||
onblur={onBlur}
|
||||
onfocus={onFocus}
|
||||
placeholder="Enter {name}"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
{#if isAutocompleteActive && suggestions.length > 0}
|
||||
<div
|
||||
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
|
||||
transition:fly={{ y: -5, duration: 100 }}
|
||||
>
|
||||
{#each suggestions as suggestion, i (suggestion)}
|
||||
<button
|
||||
type="button"
|
||||
onmousedown={() => onSelectSuggestion(suggestion)}
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { Plus, Trash2 } from '@lucide/svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { autoResizeTextarea } from '$lib/utils';
|
||||
import {
|
||||
autoResizeTextarea,
|
||||
sanitizeKeyValuePairKey,
|
||||
sanitizeKeyValuePairValue
|
||||
} from '$lib/utils';
|
||||
import { KEY_VALUE_PAIR_KEY_MAX_LENGTH, KEY_VALUE_PAIR_VALUE_MAX_LENGTH } from '$lib/constants';
|
||||
import type { KeyValuePair } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -36,17 +41,41 @@
|
|||
onPairsChange(pairs.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function updatePairKey(index: number, key: string) {
|
||||
function updatePairKey(index: number, rawKey: string) {
|
||||
const key = sanitizeKeyValuePairKey(rawKey);
|
||||
const newPairs = [...pairs];
|
||||
|
||||
newPairs[index] = { ...newPairs[index], key };
|
||||
onPairsChange(newPairs);
|
||||
}
|
||||
|
||||
function updatePairValue(index: number, value: string) {
|
||||
function trimPairKey(index: number, key: string) {
|
||||
const trimmed = key.trim();
|
||||
if (trimmed === key) return;
|
||||
|
||||
const newPairs = [...pairs];
|
||||
|
||||
newPairs[index] = { ...newPairs[index], key: trimmed };
|
||||
onPairsChange(newPairs);
|
||||
}
|
||||
|
||||
function updatePairValue(index: number, rawValue: string) {
|
||||
const value = sanitizeKeyValuePairValue(rawValue);
|
||||
const newPairs = [...pairs];
|
||||
|
||||
newPairs[index] = { ...newPairs[index], value };
|
||||
onPairsChange(newPairs);
|
||||
}
|
||||
|
||||
function trimPairValue(index: number, value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === value) return;
|
||||
|
||||
const newPairs = [...pairs];
|
||||
|
||||
newPairs[index] = { ...newPairs[index], value: trimmed };
|
||||
onPairsChange(newPairs);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
|
|
@ -77,7 +106,9 @@
|
|||
type="text"
|
||||
placeholder={keyPlaceholder}
|
||||
value={pair.key}
|
||||
maxlength={KEY_VALUE_PAIR_KEY_MAX_LENGTH}
|
||||
oninput={(e) => updatePairKey(index, e.currentTarget.value)}
|
||||
onblur={(e) => trimPairKey(index, e.currentTarget.value)}
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
|
|
@ -85,10 +116,12 @@
|
|||
use:autoResizeTextarea
|
||||
placeholder={valuePlaceholder}
|
||||
value={pair.value}
|
||||
maxlength={KEY_VALUE_PAIR_VALUE_MAX_LENGTH}
|
||||
oninput={(e) => {
|
||||
updatePairValue(index, e.currentTarget.value);
|
||||
autoResizeTextarea(e.currentTarget);
|
||||
}}
|
||||
onblur={(e) => trimPairValue(index, e.currentTarget.value)}
|
||||
class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm leading-5 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
|
||||
rows="1"
|
||||
></textarea>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,18 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* **SearchInput** - Search field with clear button
|
||||
* **InputWithSuggestions** - Input field with autocomplete suggestions
|
||||
*
|
||||
* Input field optimized for search with clear button and keyboard handling.
|
||||
* Supports placeholder, autofocus, and change callbacks.
|
||||
* Text input with dropdown suggestions and keyboard navigation.
|
||||
* Supports autocomplete functionality with suggestion loading.
|
||||
*
|
||||
* **Features:**
|
||||
* - Autocomplete dropdown with suggestions
|
||||
* - Keyboard navigation (arrow keys, enter)
|
||||
* - Loading state for suggestions
|
||||
* - Focus and blur handling
|
||||
*/
|
||||
export { default as SearchInput } from './SearchInput.svelte';
|
||||
export { default as InputWithSuggestions } from './InputWithSuggestions.svelte';
|
||||
|
||||
/**
|
||||
* **KeyValuePairs** - Editable key-value list
|
||||
|
|
@ -28,3 +34,11 @@ export { default as SearchInput } from './SearchInput.svelte';
|
|||
* - Auto-resize value textarea
|
||||
*/
|
||||
export { default as KeyValuePairs } from './KeyValuePairs.svelte';
|
||||
|
||||
/**
|
||||
* **SearchInput** - Search field with clear button
|
||||
*
|
||||
* Input field optimized for search with clear button and keyboard handling.
|
||||
* Supports placeholder, autofocus, and change callbacks.
|
||||
*/
|
||||
export { default as SearchInput } from './SearchInput.svelte';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export * from './chat';
|
|||
export * from './content';
|
||||
export * from './dialogs';
|
||||
export * from './forms';
|
||||
export * from './mcp';
|
||||
export * from './misc';
|
||||
export * from './models';
|
||||
export * from './navigation';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
|
||||
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
let enabledMcpServersForChat = $derived(
|
||||
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
|
||||
);
|
||||
let healthyEnabledMcpServers = $derived(
|
||||
enabledMcpServersForChat.filter((s) => {
|
||||
const healthState = mcpStore.getHealthCheckState(s.id);
|
||||
return healthState.status !== HealthCheckStatus.ERROR;
|
||||
})
|
||||
);
|
||||
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
|
||||
let extraServersCount = $derived(
|
||||
Math.max(0, healthyEnabledMcpServers.length - MAX_DISPLAYED_MCP_AVATARS)
|
||||
);
|
||||
let mcpFavicons = $derived(
|
||||
healthyEnabledMcpServers
|
||||
.slice(0, MAX_DISPLAYED_MCP_AVATARS)
|
||||
.map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
|
||||
.filter((f) => f.url !== null)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
|
||||
<div class={cn('inline-flex items-center gap-1.5', className)}>
|
||||
<div class="flex -space-x-1">
|
||||
{#each mcpFavicons as favicon (favicon.id)}
|
||||
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
|
||||
<img
|
||||
src={favicon.url}
|
||||
alt=""
|
||||
class="h-4 w-4"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if extraServersCount > 0}
|
||||
<span class="text-xs text-muted-foreground">+{extraServersCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import { Wrench, Database, MessageSquare, FileText, Sparkles, ListChecks } from '@lucide/svelte';
|
||||
import type { MCPCapabilitiesInfo } from '$lib/types';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
interface Props {
|
||||
capabilities?: MCPCapabilitiesInfo;
|
||||
}
|
||||
|
||||
let { capabilities }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if capabilities}
|
||||
{#if capabilities.server.tools}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-green-50 px-1.5 text-[10px] dark:bg-green-950">
|
||||
<Wrench class="h-3 w-3 text-green-600 dark:text-green-400" />
|
||||
|
||||
Tools
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.resources}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-blue-50 px-1.5 text-[10px] dark:bg-blue-950">
|
||||
<Database class="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||
|
||||
Resources
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.prompts}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-purple-50 px-1.5 text-[10px] dark:bg-purple-950">
|
||||
<MessageSquare class="h-3 w-3 text-purple-600 dark:text-purple-400" />
|
||||
|
||||
Prompts
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.logging}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-orange-50 px-1.5 text-[10px] dark:bg-orange-950">
|
||||
<FileText class="h-3 w-3 text-orange-600 dark:text-orange-400" />
|
||||
|
||||
Logging
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.completions}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-cyan-50 px-1.5 text-[10px] dark:bg-cyan-950">
|
||||
<Sparkles class="h-3 w-3 text-cyan-600 dark:text-cyan-400" />
|
||||
|
||||
Completions
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.tasks}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-pink-50 px-1.5 text-[10px] dark:bg-pink-950">
|
||||
<ListChecks class="h-3 w-3 text-pink-600 dark:text-pink-400" />
|
||||
|
||||
Tasks
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import type { MCPConnectionLog } from '$lib/types';
|
||||
import { formatTime, getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
logs: MCPConnectionLog[];
|
||||
connectionTimeMs?: number;
|
||||
defaultExpanded?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { logs, connectionTimeMs, defaultExpanded = false, class: className }: Props = $props();
|
||||
|
||||
let isExpanded = $derived(defaultExpanded);
|
||||
</script>
|
||||
|
||||
{#if logs.length > 0}
|
||||
<Collapsible.Root bind:open={isExpanded} class={className}>
|
||||
<div class="space-y-2">
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
<span>Connection Log ({logs.length})</span>
|
||||
|
||||
{#if connectionTimeMs !== undefined}
|
||||
<span class="ml-1">· Connected in {connectionTimeMs}ms</span>
|
||||
{/if}
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
|
||||
<Collapsible.Content class="mt-2">
|
||||
<div
|
||||
class="max-h-64 space-y-0.5 overflow-y-auto rounded bg-muted/50 p-2 font-mono text-[10px]"
|
||||
>
|
||||
{#each logs as log (log.timestamp.getTime() + log.message)}
|
||||
{@const Icon = getMcpLogLevelIcon(log.level)}
|
||||
|
||||
<div class={cn('flex items-start gap-1.5', getMcpLogLevelClass(log.level))}>
|
||||
<span class="shrink-0 text-muted-foreground">
|
||||
{formatTime(log.timestamp)}
|
||||
</span>
|
||||
|
||||
<Icon class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
|
||||
<span class="break-all">{log.message}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<script>
|
||||
let { class: className = '', style = '' } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={className}
|
||||
{style}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 174 174"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
><g id="shape-320b5b95-d08d-8089-8007-585a8e498184"
|
||||
><defs
|
||||
><clipPath
|
||||
id="frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1"
|
||||
class="frame-clip frame-clip-def"
|
||||
><rect
|
||||
rx="0"
|
||||
ry="0"
|
||||
x="0"
|
||||
y="0"
|
||||
width="174.00000000000045"
|
||||
height="174"
|
||||
transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
|
||||
/></clipPath
|
||||
></defs
|
||||
><g class="frame-container-wrapper"
|
||||
><g class="frame-container-blur"
|
||||
><g class="frame-container-shadows"
|
||||
><g clip-path="url(#frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1)" fill="none"
|
||||
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a8e498184"
|
||||
><rect
|
||||
rx="0"
|
||||
ry="0"
|
||||
x="0"
|
||||
y="0"
|
||||
width="174.00000000000045"
|
||||
height="174"
|
||||
transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
|
||||
class="frame-background"
|
||||
/></g
|
||||
><g class="frame-children"
|
||||
><g id="shape-320b5b95-d08d-8089-8007-585a974337b1"
|
||||
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b1"
|
||||
><path
|
||||
d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
style="fill: none;"
|
||||
/></g
|
||||
><g
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
id="strokes-b954dcef-3e3e-8015-8007-585acd4382b6-320b5b95-d08d-8089-8007-585a974337b1"
|
||||
class="strokes"
|
||||
><g class="stroke-shape"
|
||||
><path
|
||||
d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
|
||||
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
|
||||
/></g
|
||||
></g
|
||||
></g
|
||||
><g id="shape-320b5b95-d08d-8089-8007-585a974337b2"
|
||||
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b2"
|
||||
><path
|
||||
d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
style="fill: none;"
|
||||
/></g
|
||||
><g
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
id="strokes-b954dcef-3e3e-8015-8007-585acd447743-320b5b95-d08d-8089-8007-585a974337b2"
|
||||
class="strokes"
|
||||
><g class="stroke-shape"
|
||||
><path
|
||||
d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
|
||||
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
|
||||
/></g
|
||||
></g
|
||||
></g
|
||||
><g id="shape-320b5b95-d08d-8089-8007-585a974337b3"
|
||||
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b3"
|
||||
><path
|
||||
d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
style="fill: none;"
|
||||
/></g
|
||||
><g
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
id="strokes-b954dcef-3e3e-8015-8007-585acd44c5c9-320b5b95-d08d-8089-8007-585a974337b3"
|
||||
class="strokes"
|
||||
><g class="stroke-shape"
|
||||
><path
|
||||
d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
|
||||
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
|
||||
/></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></svg
|
||||
>
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
<script lang="ts">
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
|
||||
import type { MCPServerResources, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { parseResourcePath } from '$lib/utils';
|
||||
import McpResourceBrowserHeader from './McpResourceBrowserHeader.svelte';
|
||||
import McpResourceBrowserEmptyState from './McpResourceBrowserEmptyState.svelte';
|
||||
import McpResourceBrowserServerItem from './McpResourceBrowserServerItem.svelte';
|
||||
|
||||
interface Props {
|
||||
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
|
||||
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
|
||||
onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
|
||||
selectedUris?: Set<string>;
|
||||
selectedTemplateUri?: string | null;
|
||||
expandToUri?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onSelect,
|
||||
onToggle,
|
||||
onTemplateSelect,
|
||||
selectedUris = new Set(),
|
||||
selectedTemplateUri,
|
||||
expandToUri,
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
let expandedServers = new SvelteSet<string>();
|
||||
let expandedFolders = new SvelteSet<string>();
|
||||
let searchQuery = $state('');
|
||||
|
||||
const resources = $derived(mcpResources());
|
||||
const isLoading = $derived(mcpResourcesLoading());
|
||||
|
||||
const filteredResources = $derived.by(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return resources;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = new SvelteMap();
|
||||
|
||||
for (const [serverName, serverRes] of resources.entries()) {
|
||||
const filteredResources = serverRes.resources.filter((r) => {
|
||||
return (
|
||||
r.title?.toLowerCase().includes(query) ||
|
||||
r.uri.toLowerCase().includes(query) ||
|
||||
serverName.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const filteredTemplates = serverRes.templates.filter((t) => {
|
||||
return (
|
||||
t.name?.toLowerCase().includes(query) ||
|
||||
t.title?.toLowerCase().includes(query) ||
|
||||
t.uriTemplate.toLowerCase().includes(query) ||
|
||||
serverName.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
if (filteredResources.length > 0 || filteredTemplates.length > 0 || query.trim()) {
|
||||
filtered.set(serverName, {
|
||||
...serverRes,
|
||||
resources: filteredResources,
|
||||
templates: filteredTemplates
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (expandToUri && resources.size > 0) {
|
||||
autoExpandToResource(expandToUri);
|
||||
}
|
||||
});
|
||||
|
||||
function autoExpandToResource(uri: string) {
|
||||
for (const [serverName, serverRes] of resources.entries()) {
|
||||
const resource = serverRes.resources.find((r) => r.uri === uri);
|
||||
if (resource) {
|
||||
expandedServers.add(serverName);
|
||||
|
||||
const pathParts = parseResourcePath(uri);
|
||||
if (pathParts.length > 1) {
|
||||
let currentPath = '';
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
currentPath = `${currentPath}/${pathParts[i]}`;
|
||||
const folderId = `${serverName}:${currentPath}`;
|
||||
expandedFolders.add(folderId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleServer(serverName: string) {
|
||||
if (expandedServers.has(serverName)) {
|
||||
expandedServers.delete(serverName);
|
||||
} else {
|
||||
expandedServers.add(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(folderId: string) {
|
||||
if (expandedFolders.has(folderId)) {
|
||||
expandedFolders.delete(folderId);
|
||||
} else {
|
||||
expandedFolders.add(folderId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
mcpStore.fetchAllResources();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-2', className)}>
|
||||
<McpResourceBrowserHeader
|
||||
{isLoading}
|
||||
onRefresh={handleRefresh}
|
||||
onSearch={(q) => (searchQuery = q)}
|
||||
{searchQuery}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if filteredResources.size === 0}
|
||||
<McpResourceBrowserEmptyState {isLoading} />
|
||||
{:else}
|
||||
{#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)}
|
||||
<McpResourceBrowserServerItem
|
||||
serverName={serverName as string}
|
||||
serverRes={serverRes as MCPServerResources}
|
||||
isExpanded={expandedServers.has(serverName as string)}
|
||||
{selectedUris}
|
||||
{selectedTemplateUri}
|
||||
{expandedFolders}
|
||||
onToggleServer={() => toggleServer(serverName as string)}
|
||||
onToggleFolder={toggleFolder}
|
||||
{onSelect}
|
||||
{onToggle}
|
||||
{onTemplateSelect}
|
||||
{searchQuery}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
let { isLoading }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="py-4 text-center text-sm text-muted-foreground">
|
||||
{#if isLoading}
|
||||
Loading resources...
|
||||
{:else}
|
||||
No resources available
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import { RefreshCw, Loader2 } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { SearchInput } from '$lib/components/app/forms';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
onSearch?: (query: string) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
let { isLoading, onRefresh, onSearch, searchQuery = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="mb-2 flex items-center gap-4">
|
||||
<SearchInput
|
||||
placeholder="Search resources..."
|
||||
value={searchQuery}
|
||||
onInput={(value) => onSearch?.(value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0"
|
||||
onclick={onRefresh}
|
||||
disabled={isLoading}
|
||||
title="Refresh resources"
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-medium">Available resources</h3>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
<script lang="ts">
|
||||
import { FolderOpen, ChevronDown, ChevronRight, Loader2, Braces } from '@lucide/svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import {
|
||||
type ResourceTreeNode,
|
||||
buildResourceTree,
|
||||
countTreeResources,
|
||||
sortTreeChildren
|
||||
} from './mcp-resource-browser';
|
||||
import { getDisplayName, getResourceIcon } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
serverName: string;
|
||||
serverRes: MCPServerResources;
|
||||
isExpanded: boolean;
|
||||
selectedUris: Set<string>;
|
||||
selectedTemplateUri?: string | null;
|
||||
expandedFolders: SvelteSet<string>;
|
||||
onToggleServer: () => void;
|
||||
onToggleFolder: (folderId: string) => void;
|
||||
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
|
||||
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
|
||||
onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
serverName,
|
||||
serverRes,
|
||||
isExpanded,
|
||||
selectedUris,
|
||||
selectedTemplateUri,
|
||||
expandedFolders,
|
||||
onToggleServer,
|
||||
onToggleFolder,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onTemplateSelect,
|
||||
searchQuery = ''
|
||||
}: Props = $props();
|
||||
|
||||
const hasResources = $derived(serverRes.resources.length > 0);
|
||||
const hasTemplates = $derived(serverRes.templates.length > 0);
|
||||
const hasContent = $derived(hasResources || hasTemplates);
|
||||
const displayName = $derived(mcpStore.getServerDisplayName(serverName));
|
||||
const favicon = $derived(mcpStore.getServerFavicon(serverName));
|
||||
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
|
||||
|
||||
const templateInfos = $derived<MCPResourceTemplateInfo[]>(
|
||||
serverRes.templates.map((t) => ({
|
||||
uriTemplate: t.uriTemplate,
|
||||
name: t.name,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
mimeType: t.mimeType,
|
||||
serverName,
|
||||
annotations: t.annotations,
|
||||
icons: t.icons
|
||||
}))
|
||||
);
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
|
||||
onSelect?.(resource, event.shiftKey);
|
||||
}
|
||||
|
||||
function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
|
||||
onToggle?.(resource, checked);
|
||||
}
|
||||
|
||||
function isResourceSelected(resource: MCPResourceInfo): boolean {
|
||||
return selectedUris.has(resource.uri);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderTreeNode(node: ResourceTreeNode, depth: number, parentPath: string)}
|
||||
{@const isFolder = !node.resource && node.children.size > 0}
|
||||
{@const folderId = `${serverName}:${parentPath}/${node.name}`}
|
||||
{@const isFolderExpanded = expandedFolders.has(folderId)}
|
||||
|
||||
{#if isFolder}
|
||||
{@const folderCount = countTreeResources(node)}
|
||||
<Collapsible.Root open={isFolderExpanded} onOpenChange={() => onToggleFolder(folderId)}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50"
|
||||
>
|
||||
{#if isFolderExpanded}
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
{/if}
|
||||
|
||||
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
||||
<span class="font-medium">{node.name}</span>
|
||||
|
||||
<span class="text-xs text-muted-foreground">({folderCount})</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#each sortTreeChildren( [...node.children.values()] ) as child (child.resource?.uri || `${serverName}:${parentPath}/${node.name}/${child.name}`)}
|
||||
{@render renderTreeNode(child, depth + 1, `${parentPath}/${node.name}`)}
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{:else if node.resource}
|
||||
{@const resource = node.resource}
|
||||
{@const ResourceIcon = getResourceIcon(resource.mimeType, resource.uri)}
|
||||
{@const isSelected = isResourceSelected(resource)}
|
||||
{@const resourceDisplayName = resource.title || getDisplayName(node.name)}
|
||||
|
||||
<div class="group flex w-full items-center gap-2">
|
||||
{#if onToggle}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked: boolean | 'indeterminate') =>
|
||||
handleCheckboxChange(resource, checked === true)}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'flex flex-1 items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
|
||||
'hover:bg-muted/50',
|
||||
isSelected && 'bg-muted'
|
||||
)}
|
||||
onclick={(e: MouseEvent) => handleResourceClick(resource, e)}
|
||||
title={resourceDisplayName}
|
||||
>
|
||||
<ResourceIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="min-w-0 flex-1 truncate text-left">
|
||||
{resourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Collapsible.Root open={isExpanded} onOpenChange={onToggleServer}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
<span class="inline-flex flex-col items-start text-left">
|
||||
<span class="inline-flex items-center justify-start gap-1.5 font-medium">
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({serverRes.resources.length} resource{serverRes.resources.length !== 1
|
||||
? 's'
|
||||
: ''}{#if hasTemplates}, {serverRes.templates.length} template{serverRes.templates
|
||||
.length !== 1
|
||||
? 's'
|
||||
: ''}{/if})
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if serverRes.loading}
|
||||
<Loader2 class="ml-auto h-3 w-3 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#if serverRes.error}
|
||||
<div class="py-1 text-xs text-red-500">
|
||||
Error: {serverRes.error}
|
||||
</div>
|
||||
{:else if !hasContent}
|
||||
<div class="py-1 text-xs text-muted-foreground">No resources</div>
|
||||
{:else}
|
||||
{#if hasResources}
|
||||
{#each sortTreeChildren( [...resourceTree.children.values()] ) as child (child.resource?.uri || `${serverName}:${child.name}`)}
|
||||
{@render renderTreeNode(child, 1, '')}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if hasTemplates && onTemplateSelect}
|
||||
{#if hasResources}
|
||||
<div class="my-1 border-t border-border/30"></div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="py-0.5 text-[11px] font-medium tracking-wide text-muted-foreground/70 uppercase"
|
||||
>
|
||||
Templates
|
||||
</div>
|
||||
|
||||
{#each templateInfos as template (template.uriTemplate)}
|
||||
<button
|
||||
class={cn(
|
||||
'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
|
||||
'hover:bg-muted/50',
|
||||
selectedTemplateUri === template.uriTemplate && 'bg-muted'
|
||||
)}
|
||||
onclick={() => onTemplateSelect(template)}
|
||||
title={template.uriTemplate}
|
||||
>
|
||||
<Braces class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="min-w-0 flex-1 truncate text-left">
|
||||
{template.title || template.name}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import type { MCPResource, MCPResourceInfo } from '$lib/types';
|
||||
import { parseResourcePath } from '$lib/utils';
|
||||
|
||||
export interface ResourceTreeNode {
|
||||
name: string;
|
||||
resource?: MCPResourceInfo;
|
||||
children: Map<string, ResourceTreeNode>;
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
function resourceMatchesSearch(resource: MCPResource, query: string): boolean {
|
||||
return (
|
||||
resource.title?.toLowerCase().includes(query) || resource.uri.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildResourceTree(
|
||||
resourceList: MCPResource[],
|
||||
serverName: string,
|
||||
searchQuery?: string
|
||||
): ResourceTreeNode {
|
||||
const root: ResourceTreeNode = { name: 'root', children: new Map() };
|
||||
|
||||
if (!searchQuery || !searchQuery.trim()) {
|
||||
for (const resource of resourceList) {
|
||||
const pathParts = parseResourcePath(resource.uri);
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const part = pathParts[i];
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, { name: part, children: new Map() });
|
||||
}
|
||||
current = current.children.get(part)!;
|
||||
}
|
||||
|
||||
const fileName = pathParts[pathParts.length - 1] || resource.name;
|
||||
current.children.set(resource.uri, {
|
||||
name: fileName,
|
||||
resource: { ...resource, serverName },
|
||||
children: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
// Build tree with filtering
|
||||
for (const resource of resourceList) {
|
||||
if (!resourceMatchesSearch(resource, query)) continue;
|
||||
|
||||
const pathParts = parseResourcePath(resource.uri);
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const part = pathParts[i];
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, { name: part, children: new Map(), isFiltered: true });
|
||||
}
|
||||
current = current.children.get(part)!;
|
||||
}
|
||||
|
||||
const fileName = pathParts[pathParts.length - 1] || resource.name;
|
||||
|
||||
current.children.set(resource.uri, {
|
||||
name: fileName,
|
||||
resource: { ...resource, serverName },
|
||||
children: new Map(),
|
||||
isFiltered: true
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupEmptyFolders(node: ResourceTreeNode): boolean {
|
||||
if (node.resource) return true;
|
||||
|
||||
const toDelete: string[] = [];
|
||||
for (const [name, child] of node.children.entries()) {
|
||||
if (!cleanupEmptyFolders(child)) {
|
||||
toDelete.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of toDelete) {
|
||||
node.children.delete(name);
|
||||
}
|
||||
|
||||
return node.children.size > 0;
|
||||
}
|
||||
|
||||
cleanupEmptyFolders(root);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
export function countTreeResources(node: ResourceTreeNode): number {
|
||||
if (node.resource) return 1;
|
||||
let count = 0;
|
||||
|
||||
for (const child of node.children.values()) {
|
||||
count += countTreeResources(child);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function sortTreeChildren(children: ResourceTreeNode[]): ResourceTreeNode[] {
|
||||
return children.sort((a, b) => {
|
||||
const aIsFolder = !a.resource && a.children.size > 0;
|
||||
const bIsFolder = !b.resource && b.children.size > 0;
|
||||
|
||||
if (aIsFolder && !bIsFolder) return -1;
|
||||
if (!aIsFolder && bIsFolder) return 1;
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
<script lang="ts">
|
||||
import { FileText, Loader2, AlertCircle, Download } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import {
|
||||
isImageMimeType,
|
||||
createBase64DataUrl,
|
||||
getResourceTextContent,
|
||||
getResourceBlobContent,
|
||||
downloadResourceContent
|
||||
} from '$lib/utils';
|
||||
import { MimeTypeApplication, MimeTypeText } from '$lib/enums';
|
||||
import { ActionIconCopyToClipboard } from '$lib/components/app';
|
||||
import type { MCPResourceInfo, MCPResourceContent } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
resource: MCPResourceInfo | null;
|
||||
/** Pre-loaded content (e.g., from template resolution). Skips store fetch when provided. */
|
||||
preloadedContent?: MCPResourceContent[] | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { resource, preloadedContent, class: className }: Props = $props();
|
||||
|
||||
let content = $state<MCPResourceContent[] | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (resource) {
|
||||
if (preloadedContent) {
|
||||
content = preloadedContent;
|
||||
isLoading = false;
|
||||
error = null;
|
||||
} else {
|
||||
loadContent(resource.uri);
|
||||
}
|
||||
} else {
|
||||
content = null;
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadContent(uri: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await mcpStore.readResource(uri);
|
||||
if (result) {
|
||||
content = result;
|
||||
} else {
|
||||
error = 'Failed to load resource content';
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const text = getResourceTextContent(content);
|
||||
if (!text || !resource) return;
|
||||
downloadResourceContent(
|
||||
text,
|
||||
resource.mimeType || MimeTypeText.PLAIN,
|
||||
resource.name || 'resource.txt'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-3', className)}>
|
||||
{#if !resource}
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<FileText class="h-8 w-8 opacity-50" />
|
||||
|
||||
<span class="text-sm">Select a resource to preview</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate font-medium">{resource.title || resource.name}</h3>
|
||||
|
||||
<p class="truncate text-xs text-muted-foreground">{resource.uri}</p>
|
||||
|
||||
{#if resource.description}
|
||||
<p class="mt-1 text-sm text-muted-foreground">{resource.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<ActionIconCopyToClipboard
|
||||
text={getResourceTextContent(content)}
|
||||
canCopy={!isLoading && !!getResourceTextContent(content)}
|
||||
ariaLabel="Copy content"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={handleDownload}
|
||||
disabled={isLoading || !getResourceTextContent(content)}
|
||||
title="Download content"
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3 break-all">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-8 text-red-500">
|
||||
<AlertCircle class="h-6 w-6" />
|
||||
|
||||
<span class="text-sm">{error}</span>
|
||||
</div>
|
||||
{:else if content}
|
||||
{@const textContent = getResourceTextContent(content)}
|
||||
{@const blobContent = getResourceBlobContent(content)}
|
||||
|
||||
{#if textContent}
|
||||
<pre class="font-mono text-xs break-words whitespace-pre-wrap">{textContent}</pre>
|
||||
{/if}
|
||||
|
||||
{#each blobContent as blob (blob.uri)}
|
||||
{#if isImageMimeType(blob.mimeType ?? MimeTypeApplication.OCTET_STREAM)}
|
||||
<img
|
||||
src={createBase64DataUrl(
|
||||
blob.mimeType ?? MimeTypeApplication.OCTET_STREAM,
|
||||
blob.blob
|
||||
)}
|
||||
alt="Resource content"
|
||||
class="max-w-full rounded"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded bg-muted p-2 text-sm text-muted-foreground">
|
||||
<FileText class="h-4 w-4" />
|
||||
|
||||
<span>Binary content ({blob.mimeType || 'unknown type'})</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if !textContent && blobContent.length === 0}
|
||||
<div class="py-4 text-center text-sm text-muted-foreground">No content available</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if resource.mimeType || resource.annotations}
|
||||
<div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{#if resource.mimeType}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5">{resource.mimeType}</span>
|
||||
{/if}
|
||||
|
||||
{#if resource.annotations?.priority !== undefined}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5">
|
||||
Priority: {resource.annotations.priority}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="rounded bg-muted px-1.5 py-0.5">
|
||||
Server: {resource.serverName}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { InputWithSuggestions } from '$lib/components/app';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { MIN_AUTOCOMPLETE_INPUT_LENGTH } from '$lib/constants';
|
||||
import type { MCPResourceTemplateInfo } from '$lib/types';
|
||||
import {
|
||||
debounce,
|
||||
extractTemplateVariables,
|
||||
expandTemplate,
|
||||
isTemplateComplete
|
||||
} from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
template: MCPResourceTemplateInfo;
|
||||
onResolve: (uri: string, serverName: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { template, onResolve, onCancel }: Props = $props();
|
||||
|
||||
const variables = $derived(extractTemplateVariables(template.uriTemplate));
|
||||
|
||||
let values = $state<Record<string, string>>({});
|
||||
let suggestions = $state<Record<string, string[]>>({});
|
||||
let loadingSuggestions = $state<Record<string, boolean>>({});
|
||||
let activeAutocomplete = $state<string | null>(null);
|
||||
let autocompleteIndex = $state(0);
|
||||
|
||||
const expandedUri = $derived(expandTemplate(template.uriTemplate, values));
|
||||
const isComplete = $derived(isTemplateComplete(template.uriTemplate, values));
|
||||
|
||||
const fetchCompletions = debounce(async (argName: string, value: string) => {
|
||||
if (value.length < 1) {
|
||||
suggestions[argName] = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
loadingSuggestions[argName] = true;
|
||||
|
||||
try {
|
||||
const result = await mcpStore.getResourceCompletions(
|
||||
template.serverName,
|
||||
template.uriTemplate,
|
||||
argName,
|
||||
value
|
||||
);
|
||||
|
||||
if (result && result.values.length > 0) {
|
||||
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('[McpResourceTemplateForm] Failed to fetch completions:', error);
|
||||
suggestions[argName] = [];
|
||||
} finally {
|
||||
loadingSuggestions[argName] = false;
|
||||
}
|
||||
}, 200);
|
||||
|
||||
function handleArgInput(argName: string, value: string) {
|
||||
values[argName] = value;
|
||||
fetchCompletions(argName, value);
|
||||
}
|
||||
|
||||
function selectSuggestion(argName: string, value: string) {
|
||||
values[argName] = value;
|
||||
suggestions[argName] = [];
|
||||
activeAutocomplete = null;
|
||||
}
|
||||
|
||||
function handleArgKeydown(event: KeyboardEvent, argName: string) {
|
||||
const argSuggestions = suggestions[argName] ?? [];
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (argSuggestions.length > 0 && activeAutocomplete === argName) {
|
||||
suggestions[argName] = [];
|
||||
activeAutocomplete = null;
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
|
||||
} else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleArgBlur(argName: string) {
|
||||
setTimeout(() => {
|
||||
if (activeAutocomplete === argName) {
|
||||
suggestions[argName] = [];
|
||||
activeAutocomplete = null;
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function handleArgFocus(argName: string) {
|
||||
const value = values[argName] ?? '';
|
||||
|
||||
if (value.length >= MIN_AUTOCOMPLETE_INPUT_LENGTH) {
|
||||
fetchCompletions(argName, value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isComplete) {
|
||||
onResolve(expandedUri, template.serverName);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-3">
|
||||
{#each variables as variable (variable.name)}
|
||||
<InputWithSuggestions
|
||||
name={variable.name}
|
||||
value={values[variable.name] ?? ''}
|
||||
suggestions={suggestions[variable.name] ?? []}
|
||||
isLoadingSuggestions={loadingSuggestions[variable.name] ?? false}
|
||||
isAutocompleteActive={activeAutocomplete === variable.name}
|
||||
autocompleteIndex={activeAutocomplete === variable.name ? autocompleteIndex : 0}
|
||||
onInput={(value) => handleArgInput(variable.name, value)}
|
||||
onKeydown={(e) => handleArgKeydown(e, variable.name)}
|
||||
onBlur={() => handleArgBlur(variable.name)}
|
||||
onFocus={() => handleArgFocus(variable.name)}
|
||||
onSelectSuggestion={(value) => selectSuggestion(variable.name, value)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if isComplete}
|
||||
<div class="rounded-md bg-muted/50 px-3 py-2">
|
||||
<p class="text-xs text-muted-foreground">Resolved URI:</p>
|
||||
|
||||
<p class="mt-0.5 font-mono text-xs break-all">{expandedUri}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" size="sm" variant="secondary" onclick={onCancel}>Cancel</Button>
|
||||
|
||||
<Button size="sm" type="submit" disabled={!isComplete}>Read Resource</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import {
|
||||
McpServerCardActions,
|
||||
McpServerCardDeleteDialog,
|
||||
McpServerCardEditForm,
|
||||
McpServerCardHeader,
|
||||
McpServerCardToolsList,
|
||||
McpConnectionLogs,
|
||||
McpServerInfo
|
||||
} from '$lib/components/app/mcp';
|
||||
|
||||
interface Props {
|
||||
server: MCPServerSettingsEntry;
|
||||
faviconUrl: string | null;
|
||||
enabled?: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { server, faviconUrl, enabled, onToggle, onUpdate, onDelete }: Props = $props();
|
||||
|
||||
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
|
||||
let displayName = $derived(mcpStore.getServerLabel(server));
|
||||
let isIdle = $derived(healthState.status === HealthCheckStatus.IDLE);
|
||||
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.CONNECTING);
|
||||
let isConnected = $derived(healthState.status === HealthCheckStatus.SUCCESS);
|
||||
let isError = $derived(healthState.status === HealthCheckStatus.ERROR);
|
||||
let showSkeleton = $derived(isIdle || isHealthChecking);
|
||||
let errorMessage = $derived(
|
||||
healthState.status === HealthCheckStatus.ERROR ? healthState.message : undefined
|
||||
);
|
||||
let tools = $derived(healthState.status === HealthCheckStatus.SUCCESS ? healthState.tools : []);
|
||||
|
||||
let connectionLogs = $derived(
|
||||
healthState.status === HealthCheckStatus.CONNECTING ||
|
||||
healthState.status === HealthCheckStatus.SUCCESS ||
|
||||
healthState.status === HealthCheckStatus.ERROR
|
||||
? healthState.logs
|
||||
: []
|
||||
);
|
||||
|
||||
let successState = $derived(
|
||||
healthState.status === HealthCheckStatus.SUCCESS ? healthState : null
|
||||
);
|
||||
let serverInfo = $derived(successState?.serverInfo);
|
||||
let capabilities = $derived(successState?.capabilities);
|
||||
let transportType = $derived(successState?.transportType);
|
||||
let protocolVersion = $derived(successState?.protocolVersion);
|
||||
let connectionTimeMs = $derived(successState?.connectionTimeMs);
|
||||
let instructions = $derived(successState?.instructions);
|
||||
|
||||
let isEditing = $derived(!server.url.trim());
|
||||
let showDeleteDialog = $state(false);
|
||||
let editFormRef: McpServerCardEditForm | null = $state(null);
|
||||
|
||||
function handleHealthCheck() {
|
||||
mcpStore.runHealthCheck(server);
|
||||
}
|
||||
|
||||
async function startEditing() {
|
||||
isEditing = true;
|
||||
await tick();
|
||||
editFormRef?.setInitialValues(server.url, server.headers || '', server.useProxy || false);
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (server.url.trim()) {
|
||||
isEditing = false;
|
||||
} else {
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
function saveEditing(url: string, headers: string, useProxy: boolean) {
|
||||
onUpdate({
|
||||
url: url,
|
||||
headers: headers || undefined,
|
||||
useProxy: useProxy
|
||||
});
|
||||
isEditing = false;
|
||||
|
||||
if (server.enabled && url) {
|
||||
setTimeout(() => mcpStore.runHealthCheck({ ...server, url, useProxy }), 100);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteClick() {
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="!gap-3 bg-muted/30 p-4">
|
||||
{#if isEditing}
|
||||
<McpServerCardEditForm
|
||||
bind:this={editFormRef}
|
||||
serverId={server.id}
|
||||
serverUrl={server.url}
|
||||
serverUseProxy={server.useProxy}
|
||||
onSave={saveEditing}
|
||||
onCancel={cancelEditing}
|
||||
/>
|
||||
{:else}
|
||||
<McpServerCardHeader
|
||||
{displayName}
|
||||
{faviconUrl}
|
||||
enabled={enabled ?? server.enabled}
|
||||
disabled={isError}
|
||||
{onToggle}
|
||||
{serverInfo}
|
||||
{capabilities}
|
||||
{transportType}
|
||||
/>
|
||||
|
||||
{#if isError && errorMessage}
|
||||
<p class="text-xs text-destructive">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
{#if isConnected && serverInfo?.description}
|
||||
<p class="line-clamp-2 text-xs text-muted-foreground">
|
||||
{serverInfo.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-3">
|
||||
{#if showSkeleton}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-4 w-4 rounded" />
|
||||
<Skeleton class="h-3 w-24" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Skeleton class="h-5 w-16 rounded-full" />
|
||||
<Skeleton class="h-5 w-20 rounded-full" />
|
||||
<Skeleton class="h-5 w-14 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-4 w-4 rounded" />
|
||||
<Skeleton class="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if isConnected && instructions}
|
||||
<McpServerInfo {instructions} />
|
||||
{/if}
|
||||
|
||||
{#if tools.length > 0}
|
||||
<McpServerCardToolsList {tools} />
|
||||
{/if}
|
||||
|
||||
{#if connectionLogs.length > 0}
|
||||
<McpConnectionLogs logs={connectionLogs} {connectionTimeMs} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-4">
|
||||
{#if showSkeleton}
|
||||
<Skeleton class="h-3 w-28" />
|
||||
{:else if protocolVersion}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<span class="text-[10px] text-muted-foreground">
|
||||
Protocol version: {protocolVersion}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<McpServerCardActions
|
||||
{isHealthChecking}
|
||||
onEdit={startEditing}
|
||||
onRefresh={handleHealthCheck}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<McpServerCardDeleteDialog
|
||||
bind:open={showDeleteDialog}
|
||||
{displayName}
|
||||
onOpenChange={(open) => (showDeleteDialog = open)}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { Trash2, RefreshCw, Pencil } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
isHealthChecking: boolean;
|
||||
onEdit: () => void;
|
||||
onRefresh: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { isHealthChecking, onEdit, onRefresh, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={onEdit} aria-label="Edit">
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
onclick={onRefresh}
|
||||
disabled={isHealthChecking}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onclick={onDelete}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
displayName: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), displayName, onOpenChange, onConfirm }: Props = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root bind:open {onOpenChange}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Server</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to delete <strong>{displayName}</strong>? This action cannot be
|
||||
undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
|
||||
<AlertDialog.Action
|
||||
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
|
||||
onclick={onConfirm}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { McpServerForm } from '$lib/components/app/mcp';
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
serverUrl: string;
|
||||
serverUseProxy?: boolean;
|
||||
onSave: (url: string, headers: string, useProxy: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { serverId, serverUrl, serverUseProxy = false, onSave, onCancel }: Props = $props();
|
||||
|
||||
let editUrl = $derived(serverUrl);
|
||||
let editHeaders = $state('');
|
||||
let editUseProxy = $derived(serverUseProxy);
|
||||
|
||||
let urlError = $derived.by(() => {
|
||||
if (!editUrl.trim()) return 'URL is required';
|
||||
try {
|
||||
new URL(editUrl);
|
||||
return null;
|
||||
} catch {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
});
|
||||
|
||||
let canSave = $derived(!urlError);
|
||||
|
||||
function handleSave() {
|
||||
if (!canSave) return;
|
||||
onSave(editUrl.trim(), editHeaders.trim(), editUseProxy);
|
||||
}
|
||||
|
||||
export function setInitialValues(url: string, headers: string, useProxy: boolean) {
|
||||
editUrl = url;
|
||||
editHeaders = headers;
|
||||
editUseProxy = useProxy;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="font-medium">Configure Server</p>
|
||||
|
||||
<McpServerForm
|
||||
url={editUrl}
|
||||
headers={editHeaders}
|
||||
useProxy={editUseProxy}
|
||||
onUrlChange={(v) => (editUrl = v)}
|
||||
onHeadersChange={(v) => (editHeaders = v)}
|
||||
onUseProxyChange={(v) => (editUseProxy = v)}
|
||||
urlError={editUrl ? urlError : null}
|
||||
id={serverId}
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onclick={onCancel}>Cancel</Button>
|
||||
|
||||
<Button size="sm" onclick={handleSave} disabled={!canSave}>
|
||||
{serverUrl.trim() ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<script lang="ts">
|
||||
import { Cable, ExternalLink } from '@lucide/svelte';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { McpCapabilitiesBadges } from '$lib/components/app/mcp';
|
||||
import { MCP_TRANSPORT_LABELS, MCP_TRANSPORT_ICONS } from '$lib/constants';
|
||||
import { MCPTransportType } from '$lib/enums';
|
||||
import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
displayName: string;
|
||||
faviconUrl: string | null;
|
||||
enabled: boolean;
|
||||
disabled?: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
serverInfo?: MCPServerInfo;
|
||||
capabilities?: MCPCapabilitiesInfo;
|
||||
transportType?: MCPTransportType;
|
||||
}
|
||||
|
||||
let {
|
||||
displayName,
|
||||
faviconUrl,
|
||||
enabled,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
serverInfo,
|
||||
capabilities,
|
||||
transportType
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="grid min-w-0 gap-3">
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
{#if faviconUrl}
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
class="h-5 w-5 shrink-0 rounded"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted">
|
||||
<Cable class="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="min-w-0 shrink-0 truncate leading-none font-medium">{displayName}</p>
|
||||
|
||||
{#if serverInfo?.version}
|
||||
<Badge variant="secondary" class="h-4 min-w-0 truncate px-1 text-[10px]">
|
||||
v{serverInfo.version}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if serverInfo?.websiteUrl}
|
||||
<a
|
||||
href={serverInfo.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Open website"
|
||||
>
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if capabilities || transportType}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
{#if transportType}
|
||||
{@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]}
|
||||
<Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">
|
||||
{#if TransportIcon}
|
||||
<TransportIcon class="h-3 w-3" />
|
||||
{/if}
|
||||
|
||||
{MCP_TRANSPORT_LABELS[transportType] || transportType}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities}
|
||||
<McpCapabilitiesBadges {capabilities} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center pl-2">
|
||||
<Switch checked={enabled} {disabled} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
interface Tool {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tools: Tool[];
|
||||
}
|
||||
|
||||
let { tools }: Props = $props();
|
||||
|
||||
let isExpanded = $state(false);
|
||||
let toolsCount = $derived(tools.length);
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open={isExpanded}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
<span>{toolsCount} tools available · Show details</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="mt-2">
|
||||
<div class="max-h-64 space-y-3 overflow-y-auto">
|
||||
{#each tools as tool (tool.name)}
|
||||
<div>
|
||||
<Badge variant="secondary">{tool.name}</Badge>
|
||||
|
||||
{#if tool.description}
|
||||
<p class="mt-1 text-xs text-muted-foreground">{tool.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
</script>
|
||||
|
||||
<Card.Root class="grid gap-3 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-5 w-5 rounded" />
|
||||
<Skeleton class="h-5 w-28" />
|
||||
<Skeleton class="h-5 w-12 rounded-full" />
|
||||
</div>
|
||||
<Skeleton class="h-6 w-11 rounded-full" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Skeleton class="h-4 w-40" />
|
||||
<Skeleton class="h-4 w-52" />
|
||||
</div>
|
||||
|
||||
<Skeleton class="h-3.5 w-36" />
|
||||
|
||||
<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>
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { KeyValuePairs } from '$lib/components/app';
|
||||
import type { KeyValuePair } from '$lib/types';
|
||||
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
|
||||
import { UrlProtocol } from '$lib/enums';
|
||||
import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
headers: string;
|
||||
useProxy?: boolean;
|
||||
onUrlChange: (url: string) => void;
|
||||
onHeadersChange: (headers: string) => void;
|
||||
onUseProxyChange?: (useProxy: boolean) => void;
|
||||
urlError?: string | null;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
url,
|
||||
headers,
|
||||
useProxy = false,
|
||||
onUrlChange,
|
||||
onHeadersChange,
|
||||
onUseProxyChange,
|
||||
urlError = null,
|
||||
id = 'server'
|
||||
}: Props = $props();
|
||||
|
||||
let isWebSocket = $derived(
|
||||
url.toLowerCase().startsWith(UrlProtocol.WEBSOCKET) ||
|
||||
url.toLowerCase().startsWith(UrlProtocol.WEBSOCKET_SECURE)
|
||||
);
|
||||
|
||||
let headerPairs = $derived<KeyValuePair[]>(parseHeadersToArray(headers));
|
||||
|
||||
function updateHeaderPairs(newPairs: KeyValuePair[]) {
|
||||
headerPairs = newPairs;
|
||||
onHeadersChange(serializeHeaders(newPairs));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div>
|
||||
<label for="server-url-{id}" class="mb-2 block text-xs font-medium">
|
||||
Server URL <span class="text-destructive">*</span>
|
||||
</label>
|
||||
|
||||
<Input
|
||||
id="server-url-{id}"
|
||||
type="url"
|
||||
placeholder={MCP_SERVER_URL_PLACEHOLDER}
|
||||
value={url}
|
||||
oninput={(e) => onUrlChange(e.currentTarget.value)}
|
||||
class={urlError ? 'border-destructive' : ''}
|
||||
/>
|
||||
|
||||
{#if urlError}
|
||||
<p class="mt-1.5 text-xs text-destructive">{urlError}</p>
|
||||
{/if}
|
||||
|
||||
{#if !isWebSocket && onUseProxyChange}
|
||||
<label class="mt-3 flex cursor-pointer items-center gap-2">
|
||||
<Switch
|
||||
id="use-proxy-{id}"
|
||||
checked={useProxy}
|
||||
onCheckedChange={(checked) => onUseProxyChange?.(checked)}
|
||||
/>
|
||||
|
||||
<span class="text-xs text-muted-foreground">Use llama-server proxy</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<KeyValuePairs
|
||||
class="mt-2"
|
||||
pairs={headerPairs}
|
||||
onPairsChange={updateHeaderPairs}
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Value"
|
||||
addButtonLabel="Add"
|
||||
emptyMessage="No custom headers configured."
|
||||
sectionLabel="Custom Headers"
|
||||
sectionLabelOptional
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
|
||||
interface Props {
|
||||
instructions?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { instructions, class: className }: Props = $props();
|
||||
|
||||
let isExpanded = $state(false);
|
||||
</script>
|
||||
|
||||
{#if instructions}
|
||||
<Collapsible.Root bind:open={isExpanded} class={className}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
<span>Server instructions</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="mt-2">
|
||||
<p class="rounded bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
{instructions}
|
||||
</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<script lang="ts">
|
||||
import { Settings } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { DropdownMenuSearchable, McpActiveServersAvatars } from '$lib/components/app';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
onSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
let { class: className = '', disabled = false, onSettingsClick }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
let hasMcpServers = $derived(mcpServers.length > 0);
|
||||
let enabledMcpServersForChat = $derived(
|
||||
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
|
||||
);
|
||||
let healthyEnabledMcpServers = $derived(
|
||||
enabledMcpServersForChat.filter((s) => {
|
||||
const healthState = mcpStore.getHealthCheckState(s.id);
|
||||
return healthState.status !== HealthCheckStatus.ERROR;
|
||||
})
|
||||
);
|
||||
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
|
||||
let mcpFavicons = $derived(
|
||||
healthyEnabledMcpServers
|
||||
.slice(0, 3)
|
||||
.map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
|
||||
.filter((f) => f.url !== null)
|
||||
);
|
||||
let filteredMcpServers = $derived.by(() => {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
if (query) {
|
||||
return mcpServers.filter((s) => {
|
||||
const name = getServerLabel(s).toLowerCase();
|
||||
const url = s.url.toLowerCase();
|
||||
return name.includes(query) || url.includes(query);
|
||||
});
|
||||
}
|
||||
return mcpServers;
|
||||
});
|
||||
|
||||
function getServerLabel(server: MCPServerSettingsEntry): string {
|
||||
return mcpStore.getServerLabel(server);
|
||||
}
|
||||
|
||||
function handleDropdownOpen(open: boolean) {
|
||||
if (open) {
|
||||
mcpStore.runHealthChecksForServers(mcpServers);
|
||||
}
|
||||
}
|
||||
|
||||
function isServerEnabledForChat(serverId: string): boolean {
|
||||
return conversationsStore.isMcpServerEnabledForChat(serverId);
|
||||
}
|
||||
|
||||
async function toggleServerForChat(serverId: string) {
|
||||
await conversationsStore.toggleMcpServerForChat(serverId);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if hasMcpServers && hasEnabledMcpServers && mcpFavicons.length > 0}
|
||||
<DropdownMenu.Root
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
searchQuery = '';
|
||||
}
|
||||
handleDropdownOpen(open);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
{disabled}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex cursor-pointer items-center rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
{disabled}
|
||||
aria-label="MCP Servers"
|
||||
>
|
||||
<McpActiveServersAvatars class={className} />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content align="start" class="w-72 pt-0">
|
||||
<DropdownMenuSearchable
|
||||
bind:searchValue={searchQuery}
|
||||
placeholder="Search servers..."
|
||||
emptyMessage="No servers found"
|
||||
isEmpty={filteredMcpServers.length === 0}
|
||||
>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each filteredMcpServers as server (server.id)}
|
||||
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
||||
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
||||
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-2 px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => !hasError && toggleServerForChat(server.id)}
|
||||
disabled={hasError}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
{#if mcpStore.getServerFavicon(server.id)}
|
||||
<img
|
||||
src={mcpStore.getServerFavicon(server.id)}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span class="truncate text-sm">{getServerLabel(server)}</span>
|
||||
|
||||
{#if hasError}
|
||||
<span
|
||||
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
|
||||
>
|
||||
Error
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabledForChat}
|
||||
disabled={hasError}
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onCheckedChange={() => toggleServerForChat(server.id)}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={onSettingsClick}
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
|
||||
<span>Manage MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
{/snippet}
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<script lang="ts">
|
||||
import { Plus } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { uuid } from '$lib/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { McpServerCard, McpServerCardSkeleton, McpServerForm } from '$lib/components/app/mcp';
|
||||
import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
|
||||
let servers = $derived(mcpStore.getServersSorted());
|
||||
|
||||
let initialLoadComplete = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (initialLoadComplete) return;
|
||||
|
||||
const allChecked =
|
||||
servers.length > 0 &&
|
||||
servers.every((server) => {
|
||||
const state = mcpStore.getHealthCheckState(server.id);
|
||||
|
||||
return (
|
||||
state.status === HealthCheckStatus.SUCCESS || state.status === HealthCheckStatus.ERROR
|
||||
);
|
||||
});
|
||||
|
||||
if (allChecked) {
|
||||
initialLoadComplete = true;
|
||||
}
|
||||
});
|
||||
|
||||
let isAddingServer = $state(false);
|
||||
let newServerUrl = $state('');
|
||||
let newServerHeaders = $state('');
|
||||
let newServerUrlError = $derived.by(() => {
|
||||
if (!newServerUrl.trim()) return 'URL is required';
|
||||
try {
|
||||
new URL(newServerUrl);
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
});
|
||||
|
||||
function showAddServerForm() {
|
||||
isAddingServer = true;
|
||||
newServerUrl = '';
|
||||
newServerHeaders = '';
|
||||
}
|
||||
|
||||
function cancelAddServer() {
|
||||
isAddingServer = false;
|
||||
newServerUrl = '';
|
||||
newServerHeaders = '';
|
||||
}
|
||||
|
||||
function saveNewServer() {
|
||||
if (newServerUrlError) return;
|
||||
|
||||
const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
|
||||
|
||||
mcpStore.addServer({
|
||||
id: newServerId,
|
||||
enabled: true,
|
||||
url: newServerUrl.trim(),
|
||||
headers: newServerHeaders.trim() || undefined
|
||||
});
|
||||
|
||||
conversationsStore.setMcpServerOverride(newServerId, true);
|
||||
|
||||
isAddingServer = false;
|
||||
newServerUrl = '';
|
||||
newServerHeaders = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-5 md:space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold">Manage Servers</h4>
|
||||
</div>
|
||||
|
||||
{#if !isAddingServer}
|
||||
<Button variant="outline" size="sm" class="shrink-0" onclick={showAddServerForm}>
|
||||
<Plus class="h-4 w-4" />
|
||||
|
||||
Add New Server
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isAddingServer}
|
||||
<Card.Root class="bg-muted/30 p-4">
|
||||
<div class="space-y-4">
|
||||
<p class="font-medium">Add New Server</p>
|
||||
|
||||
<McpServerForm
|
||||
url={newServerUrl}
|
||||
headers={newServerHeaders}
|
||||
onUrlChange={(v) => (newServerUrl = v)}
|
||||
onHeadersChange={(v) => (newServerHeaders = v)}
|
||||
urlError={newServerUrl ? newServerUrlError : null}
|
||||
id="new-server"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onclick={cancelAddServer}>Cancel</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onclick={saveNewServer}
|
||||
disabled={!!newServerUrlError}
|
||||
aria-label="Save"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
{#if servers.length === 0 && !isAddingServer}
|
||||
<div class="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No MCP Servers configured yet. Add one to enable agentic features.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if servers.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each servers as server (server.id)}
|
||||
{#if !initialLoadComplete}
|
||||
<McpServerCardSkeleton />
|
||||
{:else}
|
||||
<McpServerCard
|
||||
{server}
|
||||
faviconUrl={mcpStore.getServerFavicon(server.id)}
|
||||
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
|
||||
onToggle={async () => await conversationsStore.toggleMcpServerForChat(server.id)}
|
||||
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
|
||||
onDelete={() => mcpStore.removeServer(server.id)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
*
|
||||
* MCP (Model Context Protocol)
|
||||
*
|
||||
* Components for managing MCP server connections and displaying server status.
|
||||
* MCP enables agentic workflows by connecting to external tool servers.
|
||||
*
|
||||
* The MCP system integrates with:
|
||||
* - `mcpStore` for server CRUD operations and health checks
|
||||
* - `conversationsStore` for per-conversation server enable/disable
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **McpServersSettings** - MCP servers configuration section
|
||||
*
|
||||
* Settings section for configuring MCP server connections.
|
||||
* Displays server cards with status, tools, and management actions.
|
||||
* Used within the MCP tab of ChatSettings.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Manages add server form state locally
|
||||
* - Delegates server display to McpServerCard components
|
||||
* - Integrates with mcpStore for server operations
|
||||
* - Shows skeleton loading states during health checks
|
||||
*
|
||||
* **Features:**
|
||||
* - Add new MCP servers by URL with validation
|
||||
* - Server cards with connection status indicators
|
||||
* - Health check status (connected/disconnected/error)
|
||||
* - Tools list per server showing available capabilities
|
||||
* - Enable/disable toggle per conversation
|
||||
* - Edit/delete server actions
|
||||
* - Skeleton loading states during connection
|
||||
* - Empty state with helpful message
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <McpServersSettings />
|
||||
* ```
|
||||
*/
|
||||
export { default as McpServersSettings } from './McpServersSettings.svelte';
|
||||
|
||||
/**
|
||||
* **McpActiveServersAvatars** - Active MCP servers indicator
|
||||
*
|
||||
* Compact avatar row showing favicons of active MCP servers.
|
||||
* Displays up to 3 server icons with "+N" counter for additional servers.
|
||||
* Clickable to open MCP settings dialog.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Filters servers by enabled status and health check
|
||||
* - Fetches favicons from server URLs
|
||||
* - Integrates with conversationsStore for per-chat server state
|
||||
*
|
||||
* **Features:**
|
||||
* - Overlapping favicon avatars (max 3 visible)
|
||||
* - "+N" counter for additional servers
|
||||
* - Click handler for settings navigation
|
||||
* - Disabled state support
|
||||
* - Only shows healthy, enabled servers
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <McpActiveServersAvatars
|
||||
* onSettingsClick={() => showMcpSettings = true}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as McpActiveServersAvatars } from './McpActiveServersAvatars.svelte';
|
||||
|
||||
/**
|
||||
* **McpServersSelector** - Quick MCP server toggle dropdown
|
||||
*
|
||||
* Compact dropdown for quickly enabling/disabling MCP servers for the current chat.
|
||||
* Uses McpActiveServersAvatars as trigger and shows searchable server list with switches.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses DropdownMenuSearchable for searchable dropdown UI
|
||||
* - McpActiveServersAvatars as the trigger element
|
||||
* - Integrates with conversationsStore for per-chat toggle
|
||||
* - Runs health checks on dropdown open
|
||||
*
|
||||
* **Features:**
|
||||
* - Searchable server list by name/URL
|
||||
* - Switch toggles matching McpServersSettings behavior
|
||||
* - Error state display for unhealthy servers
|
||||
* - Footer link to full MCP settings dialog
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <McpServersSelector
|
||||
* onSettingsClick={() => showMcpSettings = true}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as McpServersSelector } from './McpServersSelector.svelte';
|
||||
|
||||
/**
|
||||
* **McpCapabilitiesBadges** - Server capabilities display
|
||||
*
|
||||
* Displays MCP server capabilities as colored badges.
|
||||
* Shows which features the server supports (tools, resources, prompts, etc.).
|
||||
*
|
||||
* **Features:**
|
||||
* - Tools badge (green) - server provides callable tools
|
||||
* - Resources badge (blue) - server provides data resources
|
||||
* - Prompts badge (purple) - server provides prompt templates
|
||||
* - Logging badge (orange) - server supports logging
|
||||
* - Completions badge (cyan) - server provides completions
|
||||
* - Tasks badge (pink) - server supports task management
|
||||
*/
|
||||
export { default as McpCapabilitiesBadges } from './McpCapabilitiesBadges.svelte';
|
||||
|
||||
/**
|
||||
* **McpConnectionLogs** - Connection log viewer
|
||||
*
|
||||
* Collapsible panel showing MCP server connection logs.
|
||||
* Displays timestamped log entries with level-based styling.
|
||||
*
|
||||
* **Features:**
|
||||
* - Collapsible log list with entry count
|
||||
* - Connection time display in milliseconds
|
||||
* - Log level icons and color coding
|
||||
* - Scrollable log container with max height
|
||||
* - Monospace font for log readability
|
||||
*/
|
||||
export { default as McpConnectionLogs } from './McpConnectionLogs.svelte';
|
||||
|
||||
/**
|
||||
* **McpServerForm** - Server URL and headers input form
|
||||
*
|
||||
* Reusable form for entering MCP server connection details.
|
||||
* Used in both add new server and edit server flows.
|
||||
*
|
||||
* **Features:**
|
||||
* - URL input with validation error display
|
||||
* - Custom headers key-value pairs editor
|
||||
* - Controlled component with change callbacks
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <McpServerForm
|
||||
* url={serverUrl}
|
||||
* headers={serverHeaders}
|
||||
* onUrlChange={(v) => serverUrl = v}
|
||||
* onHeadersChange={(v) => serverHeaders = v}
|
||||
* urlError={validationError}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as McpServerForm } from './McpServerForm.svelte';
|
||||
|
||||
/**
|
||||
* MCP protocol logo SVG component. Renders the official MCP icon
|
||||
* with customizable size via class and style props.
|
||||
*/
|
||||
export { default as McpLogo } from './McpLogo.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* SERVER CARD
|
||||
*
|
||||
* Components for displaying individual MCP server status and controls.
|
||||
* McpServerCard is the main component, with sub-components for specific sections.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **McpServerCard** - Individual server display card
|
||||
*
|
||||
* Main component for displaying a single MCP server with all its details.
|
||||
* Manages edit mode, delete confirmation, and health check actions.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Composes header, tools list, logs, and actions sub-components
|
||||
* - Manages local edit/delete state
|
||||
* - Reads health state from mcpStore
|
||||
* - Triggers health checks via mcpStore
|
||||
*
|
||||
* **Features:**
|
||||
* - Server header with favicon, name, version, and toggle
|
||||
* - Capabilities badges display
|
||||
* - Tools list with descriptions
|
||||
* - Connection logs viewer
|
||||
* - Edit form for URL and headers
|
||||
* - Delete confirmation dialog
|
||||
* - Skeleton loading states
|
||||
*/
|
||||
export { default as McpServerCard } from './McpServerCard/McpServerCard.svelte';
|
||||
|
||||
/** Server card header with favicon, name, version badge, and enable toggle. */
|
||||
export { default as McpServerCardHeader } from './McpServerCard/McpServerCardHeader.svelte';
|
||||
|
||||
/** Action buttons row: edit, refresh, delete. */
|
||||
export { default as McpServerCardActions } from './McpServerCard/McpServerCardActions.svelte';
|
||||
|
||||
/** Collapsible tools list showing available server tools with descriptions. */
|
||||
export { default as McpServerCardToolsList } from './McpServerCard/McpServerCardToolsList.svelte';
|
||||
|
||||
/** Inline edit form for server URL and custom headers. */
|
||||
export { default as McpServerCardEditForm } from './McpServerCard/McpServerCardEditForm.svelte';
|
||||
|
||||
/** Delete confirmation dialog with server name display. */
|
||||
export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerCardDeleteDialog.svelte';
|
||||
|
||||
/** Skeleton loading state for server card during health checks. */
|
||||
export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
|
||||
|
||||
/**
|
||||
* **McpServerInfo** - Server instructions display
|
||||
*
|
||||
* Collapsible panel showing server-provided instructions.
|
||||
* Displays guidance text from the MCP server for users.
|
||||
*/
|
||||
export { default as McpServerInfo } from './McpServerInfo.svelte';
|
||||
|
||||
/**
|
||||
* **McpResourceBrowser** - MCP resources tree browser
|
||||
*
|
||||
* Tree view component showing resources grouped by server.
|
||||
* Supports resource selection and quick attach actions.
|
||||
*
|
||||
* **Features:**
|
||||
* - Collapsible server sections
|
||||
* - Resource icons based on MIME type
|
||||
* - Resource selection highlighting
|
||||
* - Quick attach button per resource
|
||||
* - Refresh all resources action
|
||||
* - Loading states per server
|
||||
*/
|
||||
export { default as McpResourceBrowser } from './McpResourceBrowser/McpResourceBrowser.svelte';
|
||||
|
||||
/**
|
||||
* **McpResourcePreview** - MCP resource content preview
|
||||
*
|
||||
* Preview panel showing resource content with metadata.
|
||||
* Supports text and binary content display.
|
||||
*
|
||||
* **Features:**
|
||||
* - Text content display with monospace formatting
|
||||
* - Image preview for image MIME types
|
||||
* - Copy to clipboard action
|
||||
* - Download content action
|
||||
* - Resource metadata display (MIME type, priority, server)
|
||||
* - Loading and error states
|
||||
*/
|
||||
export { default as McpResourcePreview } from './McpResourcePreview.svelte';
|
||||
|
||||
/**
|
||||
* **McpResourceTemplateForm** - MCP resource template variable form
|
||||
*
|
||||
* Form for filling in resource template variables with auto-completion
|
||||
* via the Completions API. Shows live URI preview as variables are filled.
|
||||
*
|
||||
* **Features:**
|
||||
* - Template variable input fields
|
||||
* - Completions API integration for variable auto-complete
|
||||
* - Live URI preview as variables are filled
|
||||
* - Read resolved resource action
|
||||
*/
|
||||
export { default as McpResourceTemplateForm } from './McpResourceTemplateForm.svelte';
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
children?: Snippet;
|
||||
gapSize?: string;
|
||||
onScrollableChange?: (isScrollable: boolean) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@
|
|||
interface Props {
|
||||
text: string;
|
||||
class?: string;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
let { text, class: className = '' }: Props = $props();
|
||||
let { text, class: className = '', showTooltip = true }: Props = $props();
|
||||
|
||||
let textElement: HTMLSpanElement | undefined = $state();
|
||||
let isTruncated = $state(false);
|
||||
|
|
@ -29,7 +30,7 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
{#if isTruncated}
|
||||
{#if isTruncated && showTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class={className}>
|
||||
<span bind:this={textElement} class="block truncate">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
|
|
@ -30,11 +31,11 @@
|
|||
</script>
|
||||
|
||||
{#if resolvedShowRaw}
|
||||
<span class="min-w-0 truncate font-medium {className}">{modelId}</span>
|
||||
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} />
|
||||
{:else}
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}">
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{#if showOrgName}{parsed.orgName}/{/if}{parsed.modelName ?? modelId}
|
||||
{#if showOrgName && parsed.orgName}{parsed.orgName}/{/if}{parsed.modelName ?? modelId}
|
||||
</span>
|
||||
|
||||
{#if parsed.params}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,408 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import {
|
||||
modelsStore,
|
||||
modelOptions,
|
||||
modelsLoading,
|
||||
modelsUpdating,
|
||||
selectedModelId,
|
||||
singleModelName
|
||||
} from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import {
|
||||
DialogModelInformation,
|
||||
SearchInput,
|
||||
TruncatedText,
|
||||
ModelsSelectorOption
|
||||
} from '$lib/components/app';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
currentModel?: string | null;
|
||||
/** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
|
||||
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
|
||||
disabled?: boolean;
|
||||
forceForegroundText?: boolean;
|
||||
/** When true, user's global selection takes priority over currentModel (for form selector) */
|
||||
useGlobalSelection?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
currentModel = null,
|
||||
onModelChange,
|
||||
disabled = false,
|
||||
forceForegroundText = false,
|
||||
useGlobalSelection = false
|
||||
}: Props = $props();
|
||||
|
||||
let options = $derived(
|
||||
modelOptions().filter((option) => {
|
||||
const modelProps = modelsStore.getModelProps(option.model);
|
||||
return modelProps?.webui !== false;
|
||||
})
|
||||
);
|
||||
let loading = $derived(modelsLoading());
|
||||
let updating = $derived(modelsUpdating());
|
||||
let activeId = $derived(selectedModelId());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let serverModel = $derived(singleModelName());
|
||||
|
||||
let isLoadingModel = $state(false);
|
||||
|
||||
let isHighlightedCurrentModelActive = $derived(
|
||||
!isRouter || !currentModel
|
||||
? false
|
||||
: (() => {
|
||||
const currentOption = options.find((option) => option.model === currentModel);
|
||||
|
||||
return currentOption ? currentOption.id === activeId : false;
|
||||
})()
|
||||
);
|
||||
|
||||
let isCurrentModelInCache = $derived.by(() => {
|
||||
if (!isRouter || !currentModel) return true;
|
||||
|
||||
return options.some((option) => option.model === currentModel);
|
||||
});
|
||||
|
||||
let searchTerm = $state('');
|
||||
|
||||
let filteredOptions: ModelOption[] = $derived.by(() => {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
if (!term) return options;
|
||||
|
||||
return options.filter(
|
||||
(option) =>
|
||||
option.model.toLowerCase().includes(term) ||
|
||||
option.name?.toLowerCase().includes(term) ||
|
||||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
|
||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
|
||||
);
|
||||
});
|
||||
|
||||
let groupedFilteredOptions = $derived.by(() => {
|
||||
const favIds = modelsStore.favouriteModelIds;
|
||||
const result: {
|
||||
orgName: string | null;
|
||||
isFavouritesGroup: boolean;
|
||||
isLoadedGroup: boolean;
|
||||
items: { option: ModelOption; flatIndex: number }[];
|
||||
}[] = [];
|
||||
|
||||
// Loaded models group (top)
|
||||
const loadedItems: { option: ModelOption; flatIndex: number }[] = [];
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
if (modelsStore.isModelLoaded(filteredOptions[i].model)) {
|
||||
loadedItems.push({ option: filteredOptions[i], flatIndex: i });
|
||||
}
|
||||
}
|
||||
if (loadedItems.length > 0) {
|
||||
result.push({
|
||||
orgName: null,
|
||||
isFavouritesGroup: false,
|
||||
isLoadedGroup: true,
|
||||
items: loadedItems
|
||||
});
|
||||
}
|
||||
|
||||
// Favourites group
|
||||
const loadedModelIds = new Set(loadedItems.map((item) => item.option.model));
|
||||
const favItems: { option: ModelOption; flatIndex: number }[] = [];
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
if (favIds.has(filteredOptions[i].model) && !loadedModelIds.has(filteredOptions[i].model)) {
|
||||
favItems.push({ option: filteredOptions[i], flatIndex: i });
|
||||
}
|
||||
}
|
||||
if (favItems.length > 0) {
|
||||
result.push({
|
||||
orgName: null,
|
||||
isFavouritesGroup: true,
|
||||
isLoadedGroup: false,
|
||||
items: favItems
|
||||
});
|
||||
}
|
||||
|
||||
// Org groups (excluding loaded and favourites)
|
||||
const orgGroups = new SvelteMap<string, { option: ModelOption; flatIndex: number }[]>();
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
const option = filteredOptions[i];
|
||||
if (loadedModelIds.has(option.model) || favIds.has(option.model)) continue;
|
||||
const orgName = option.parsedId?.orgName ?? null;
|
||||
const key = orgName ?? '';
|
||||
if (!orgGroups.has(key)) orgGroups.set(key, []);
|
||||
orgGroups.get(key)!.push({ option, flatIndex: i });
|
||||
}
|
||||
for (const [orgName, items] of orgGroups) {
|
||||
result.push({
|
||||
orgName: orgName || null,
|
||||
isFavouritesGroup: false,
|
||||
isLoadedGroup: false,
|
||||
items
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
let sheetOpen = $state(false);
|
||||
let showModelDialog = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
modelsStore.fetch().catch((error) => {
|
||||
console.error('Unable to load models:', error);
|
||||
});
|
||||
});
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (loading || updating) return;
|
||||
|
||||
if (isRouter) {
|
||||
if (open) {
|
||||
sheetOpen = true;
|
||||
searchTerm = '';
|
||||
|
||||
modelsStore.fetchRouterModels().then(() => {
|
||||
modelsStore.fetchModalitiesForLoadedModels();
|
||||
});
|
||||
} else {
|
||||
sheetOpen = false;
|
||||
searchTerm = '';
|
||||
}
|
||||
} else {
|
||||
showModelDialog = open;
|
||||
}
|
||||
}
|
||||
|
||||
export function open() {
|
||||
handleOpenChange(true);
|
||||
}
|
||||
|
||||
function handleSheetOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelect(modelId: string) {
|
||||
const option = options.find((opt) => opt.id === modelId);
|
||||
if (!option) return;
|
||||
|
||||
let shouldCloseMenu = true;
|
||||
|
||||
if (onModelChange) {
|
||||
const result = await onModelChange(option.id, option.model);
|
||||
|
||||
if (result === false) {
|
||||
shouldCloseMenu = false;
|
||||
}
|
||||
} else {
|
||||
await modelsStore.selectModelById(option.id);
|
||||
}
|
||||
|
||||
if (shouldCloseMenu) {
|
||||
handleOpenChange(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>(
|
||||
'[data-slot="chat-form"] textarea'
|
||||
);
|
||||
textarea?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
|
||||
isLoadingModel = true;
|
||||
modelsStore
|
||||
.loadModel(option.model)
|
||||
.catch((error) => console.error('Failed to load model:', error))
|
||||
.finally(() => (isLoadingModel = false));
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayOption(): ModelOption | undefined {
|
||||
if (!isRouter) {
|
||||
if (serverModel) {
|
||||
return {
|
||||
id: 'current',
|
||||
model: serverModel,
|
||||
name: serverModel.split('/').pop() || serverModel,
|
||||
capabilities: []
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (useGlobalSelection && activeId) {
|
||||
const selected = options.find((option) => option.id === activeId);
|
||||
if (selected) return selected;
|
||||
}
|
||||
|
||||
if (currentModel) {
|
||||
if (!isCurrentModelInCache) {
|
||||
return {
|
||||
id: 'not-in-cache',
|
||||
model: currentModel,
|
||||
name: currentModel.split('/').pop() || currentModel,
|
||||
capabilities: []
|
||||
};
|
||||
}
|
||||
|
||||
return options.find((option) => option.model === currentModel);
|
||||
}
|
||||
|
||||
if (activeId) {
|
||||
return options.find((option) => option.id === activeId);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
|
||||
{#if loading && options.length === 0 && isRouter}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if options.length === 0 && isRouter}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
{:else}
|
||||
{@const selectedOption = getDisplayOption()}
|
||||
|
||||
{#if isRouter}
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
|
||||
!isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground',
|
||||
sheetOpen ? 'text-foreground' : ''
|
||||
)}
|
||||
style="max-width: min(calc(100cqw - 9rem), 20rem)"
|
||||
disabled={disabled || updating}
|
||||
onclick={() => handleOpenChange(true)}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
||||
<TruncatedText text={selectedOption?.model || 'Select model'} class="min-w-0 font-medium" />
|
||||
|
||||
{#if updating || isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>
|
||||
<Sheet.Content side="bottom" class="max-h-[85vh] gap-1">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>Select Model</Sheet.Title>
|
||||
|
||||
<Sheet.Description class="sr-only">
|
||||
Choose a model to use for the conversation
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex flex-col gap-1 pb-4">
|
||||
<div class="mb-3 px-4">
|
||||
<SearchInput placeholder="Search models..." bind:value={searchTerm} />
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto px-2">
|
||||
{#if !isCurrentModelInCache && currentModel}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-not-allowed items-center rounded-md bg-red-400/10 px-3 py-2.5 text-left text-sm text-red-400"
|
||||
disabled
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{selectedOption?.name || currentModel}
|
||||
</span>
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{/if}
|
||||
|
||||
{#if filteredOptions.length === 0}
|
||||
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#each groupedFilteredOptions as group (group.isLoadedGroup ? '__loaded__' : group.isFavouritesGroup ? '__favourites__' : group.orgName)}
|
||||
{#if group.isLoadedGroup}
|
||||
<p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
|
||||
Loaded models
|
||||
</p>
|
||||
{:else if group.isFavouritesGroup}
|
||||
<p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
|
||||
Favourite models
|
||||
</p>
|
||||
{:else if group.orgName}
|
||||
<p
|
||||
class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
|
||||
>
|
||||
{group.orgName}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#each group.items as { option } (group.isLoadedGroup ? `loaded-${option.id}` : group.isFavouritesGroup ? `fav-${option.id}` : option.id)}
|
||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||
{@const isFav = modelsStore.favouriteModelIds.has(option.model)}
|
||||
<ModelsSelectorOption
|
||||
{option}
|
||||
{isSelected}
|
||||
isHighlighted={false}
|
||||
{isFav}
|
||||
showOrgName={group.isFavouritesGroup || group.isLoadedGroup}
|
||||
onSelect={handleSelect}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
{:else}
|
||||
<button
|
||||
class={cn(
|
||||
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
|
||||
!isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||
onclick={() => handleOpenChange(true)}
|
||||
disabled={disabled || updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
||||
<TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
|
||||
|
||||
{#if updating}
|
||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showModelDialog && !isRouter}
|
||||
<DialogModelInformation bind:open={showModelDialog} />
|
||||
{/if}
|
||||
|
|
@ -44,6 +44,15 @@
|
|||
*/
|
||||
export { default as ModelsSelector } from './ModelsSelector.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorSheet** - Mobile model selection sheet
|
||||
*
|
||||
* Bottom sheet variant of ModelsSelector optimized for touch interaction
|
||||
* on mobile devices. Same functionality as ModelsSelector but uses Sheet UI
|
||||
* instead of DropdownMenu.
|
||||
*/
|
||||
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
|
||||
|
||||
/**
|
||||
* **ModelBadge** - Model name display badge
|
||||
*
|
||||
|
|
|
|||
|
|
@ -14,14 +14,16 @@
|
|||
'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:
|
||||
'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',
|
||||
ghost: 'hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9'
|
||||
'icon-lg': 'size-10',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-5 rounded-sm'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from 'tailwind-variants';
|
||||
export const sheetVariants = tv({
|
||||
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
base: `border-border/30 dark:border-border/20 data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-sm transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 ${PANEL_CLASSES}`,
|
||||
variants: {
|
||||
side: {
|
||||
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
import type { Snippet } from 'svelte';
|
||||
import SheetOverlay from './sheet-overlay.svelte';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
|
||||
import { PANEL_CLASSES } from '$lib/constants';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@
|
|||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="rounded-full backdrop-blur-lg {className} h-9! w-9!"
|
||||
size="icon-lg"
|
||||
class="rounded-full backdrop-blur-lg {className} md:left-{sidebar.open
|
||||
? 'unset'
|
||||
: '2'} -top-2 -left-2 md:top-0"
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
onclick?.(e);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,20 @@
|
|||
import type { AgenticConfig } from '$lib/types/agentic';
|
||||
|
||||
export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/;
|
||||
|
||||
export const NEWLINE_SEPARATOR = '\n';
|
||||
|
||||
export const TURN_LIMIT_MESSAGE = '\n\n```\nTurn limit reached\n```\n';
|
||||
|
||||
export const LLM_ERROR_BLOCK_START = '\n\n```\nUpstream LLM error:\n';
|
||||
export const LLM_ERROR_BLOCK_END = '\n```\n';
|
||||
|
||||
export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
|
||||
enabled: true,
|
||||
maxTurns: 100,
|
||||
maxToolPreviewLines: 25
|
||||
} as const;
|
||||
|
||||
// Agentic tool call tag markers
|
||||
export const AGENTIC_TAGS = {
|
||||
TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>',
|
||||
|
|
@ -13,6 +30,9 @@ export const REASONING_TAGS = {
|
|||
END: '<<<reasoning_content_end>>>'
|
||||
} as const;
|
||||
|
||||
// Regex for trimming leading/trailing newlines
|
||||
export const TRIM_NEWLINES_REGEX = /^\n+|\n+$/g;
|
||||
|
||||
// Regex patterns for parsing agentic content
|
||||
export const AGENTIC_REGEX = {
|
||||
// Matches completed tool calls (with END marker)
|
||||
|
|
@ -32,6 +52,10 @@ export const AGENTIC_REGEX = {
|
|||
REASONING_BLOCK: /<<<reasoning_content_start>>>[\s\S]*?<<<reasoning_content_end>>>/g,
|
||||
// Matches an opening reasoning tag and any remaining content (unterminated)
|
||||
REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/,
|
||||
// Matches a complete agentic tool call display block (start to end marker)
|
||||
AGENTIC_TOOL_CALL_BLOCK: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*?<<<AGENTIC_TOOL_CALL_END>>>/g,
|
||||
// Matches a pending/partial agentic tool call (start marker with no matching end)
|
||||
AGENTIC_TOOL_CALL_OPEN: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*$/,
|
||||
// Matches tool name inside content
|
||||
TOOL_NAME_EXTRACT: /<<<TOOL_NAME:([^>]+)>>>/
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -3,3 +3,6 @@ export const API_MODELS = {
|
|||
LOAD: '/models/load',
|
||||
UNLOAD: '/models/unload'
|
||||
};
|
||||
|
||||
/** CORS proxy endpoint path */
|
||||
export const CORS_PROXY_ENDPOINT = '/cors-proxy';
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
export const ATTACHMENT_LABEL_FILE = 'File';
|
||||
export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File';
|
||||
export const ATTACHMENT_LABEL_MCP_PROMPT = 'MCP Prompt';
|
||||
export const ATTACHMENT_LABEL_MCP_RESOURCE = 'MCP Resource';
|
||||
|
|
|
|||
|
|
@ -27,6 +27,18 @@ export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000;
|
|||
*/
|
||||
export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50;
|
||||
|
||||
/**
|
||||
* Maximum number of MCP resources to cache
|
||||
* @default 50
|
||||
*/
|
||||
export const MCP_RESOURCE_CACHE_MAX_ENTRIES = 50;
|
||||
|
||||
/**
|
||||
* TTL for MCP resource cache entries in milliseconds
|
||||
* @default 5 minutes
|
||||
*/
|
||||
export const MCP_RESOURCE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Maximum number of inactive conversation states to keep in memory
|
||||
* States for conversations beyond this limit will be cleaned up
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export const INITIAL_FILE_SIZE = 0;
|
||||
export const PROMPT_CONTENT_SEPARATOR = '\n\n';
|
||||
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
|
||||
export const PROMPT_TRIGGER_PREFIX = '/';
|
||||
export const RESOURCE_TRIGGER_PREFIX = '@';
|
||||
|
|
|
|||
|
|
@ -8,3 +8,12 @@ export const INPUT_CLASSES = `
|
|||
outline-none
|
||||
text-foreground
|
||||
`;
|
||||
|
||||
export const PANEL_CLASSES = `
|
||||
bg-background
|
||||
border border-border/30 dark:border-border/20
|
||||
shadow-sm backdrop-blur-lg!
|
||||
rounded-t-lg!
|
||||
`;
|
||||
|
||||
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
export const GOOGLE_FAVICON_BASE_URL = 'https://www.google.com/s2/favicons';
|
||||
export const DEFAULT_FAVICON_SIZE = 32;
|
||||
export const DOMAIN_SEPARATOR = '.';
|
||||
export const ROOT_DOMAIN_MIN_PARTS = 2;
|
||||
|
|
@ -11,14 +11,19 @@ export * from './chat-form';
|
|||
export * from './code-blocks';
|
||||
export * from './code';
|
||||
export * from './css-classes';
|
||||
export * from './favicon';
|
||||
export * from './floating-ui-constraints';
|
||||
export * from './formatters';
|
||||
export * from './key-value-pairs';
|
||||
export * from './icons';
|
||||
export * from './latex-protection';
|
||||
export * from './literal-html';
|
||||
export * from './localstorage-keys';
|
||||
export * from './markdown';
|
||||
export * from './max-bundle-size';
|
||||
export * from './mcp';
|
||||
export * from './mcp-form';
|
||||
export * from './mcp-resource';
|
||||
export * from './model-id';
|
||||
export * from './precision';
|
||||
export * from './processing-info';
|
||||
|
|
@ -30,4 +35,5 @@ export * from './supported-file-types';
|
|||
export * from './table-html-restorer';
|
||||
export * from './tooltip-config';
|
||||
export * from './ui';
|
||||
export * from './uri-template';
|
||||
export * from './viewport';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Key-value pair form constraints and sanitization patterns.
|
||||
*
|
||||
* Both regexes target characters dangerous in HTTP-header / env-var contexts:
|
||||
* \x00 – null byte (injection)
|
||||
* \x0A (\n) – LF (HTTP header injection / response splitting)
|
||||
* \x0D (\r) – CR (HTTP header injection / response splitting)
|
||||
* \x01–\x08, \x0B–\x0C, \x0E–\x1F, \x7F – other C0/DEL control chars
|
||||
*
|
||||
* KEY_UNSAFE_RE additionally strips TAB (\x09); values keep TAB because it is
|
||||
* a valid header-value continuation character per RFC 7230.
|
||||
*/
|
||||
|
||||
export const KEY_VALUE_PAIR_KEY_MAX_LENGTH = 256;
|
||||
export const KEY_VALUE_PAIR_VALUE_MAX_LENGTH = 8192;
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
export const KEY_VALUE_PAIR_UNSAFE_KEY_RE = /[\x00-\x1F\x7F]/g;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
export const KEY_VALUE_PAIR_UNSAFE_VALUE_RE = /[\x00-\x08\x0A-\x0D\x0E-\x1F\x7F]/g;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export const MCP_SERVER_URL_PLACEHOLDER = 'https://mcp.example.com/sse';
|
||||
export const MIN_AUTOCOMPLETE_INPUT_LENGTH = 1;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue