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"},
// webui configs
bool webui = true;
+ bool webui_mcp_proxy = false;
std::string webui_config_json;
// "advanced" endpoints are disabled by default for better security
--- /dev/null
+#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");
+};
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
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;
#include "server-context.h"
#include "server-http.h"
#include "server-models.h"
+#include "server-cors-proxy.h"
#include "arg.h"
#include "common.h"
// 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
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"]
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"]
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"]
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
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
S3 --> SV2 & SV3
S4 --> SV3
S5 --> SV5
+ S6 --> SV6
+ S7 --> SV6
%% Services → Storage
SV4 --> ST1
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 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
```
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"]
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"]
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
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()"]
RE36["theme()"]
RE37["isInitialized()"]
end
+ subgraph MCPExports["mcpStore / mcpResourceStore"]
+ RE38["mcpResources()"]
+ RE39["mcpResourceAttachments()"]
+ RE40["mcpHasResourceAttachments()"]
+ RE41["mcpTotalResourceCount()"]
+ RE42["mcpResourcesLoading()"]
+ end
end
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()"]
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
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"]
ST5["LocalStorage"]
ST6["config"]
ST7["userOverrides"]
+ ST8["mcpServers"]
end
subgraph APIs["🌐 llama-server API"]
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
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
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
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
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
```
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
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)
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
- Note over UI,API: 🌊 STREAMING
+ Note over UI,API: 🌊 STREAMING (with agentic flow detection)
%% ═══════════════════════════════════════════════════════════════════════════
activate chatStore
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
loop SSE chunks
API-->>ChatSvc: data: {"choices":[{"delta":{...}}]}
- ChatSvc->>ChatSvc: parseSSEChunk(line)
+ ChatSvc->>ChatSvc: handleStreamResponse(response)
alt content chunk
ChatSvc-->>chatStore: onChunk(content)
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
%% ═══════════════════════════════════════════════════════════════════════════
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
```
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
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
%% ═══════════════════════════════════════════════════════════════════════════
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
%% ═══════════════════════════════════════════════════════════════════════════
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: � 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.
+
+ UI->>convStore: setMcpServerOverride(convId, serverName, override)
+ Note right of convStore: override = {enabled: boolean}
+
+ 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
- Note over convStore: Scans activeMessages for attachments:<br/>- IMAGE → vision: true<br/>- PDF (processedAsImages) → vision: true<br/>- AUDIO → audio: true
+ UI->>convStore: getAllMcpServerOverrides(convId)
+ Note right of convStore: Returns all overrides for a conversation
- UI->>convStore: getModalitiesUpToMessage(msgId)
- Note right of convStore: Used for regeneration validation<br/>Only checks messages BEFORE target
+ UI->>convStore: removeMcpServerOverride(convId, serverName)
+ UI->>convStore: getMcpServerOverride(convId, serverName)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 📤 EXPORT / 📥 IMPORT
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
```
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
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
%% ═══════════════════════════════════════════════════════════════════════════
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
--- /dev/null
+```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: � 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: �📝 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
+```
"name": "webui",
"version": "1.0.0",
"dependencies": {
+ "@modelcontextprotocol/sdk": "^1.25.1",
"highlight.js": "^11.11.1",
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^5.4.54",
"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"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.0",
"dev": true,
"license": "MIT"
},
+ "node_modules/@hono/node-server": {
+ "version": "1.19.9",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
+ "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"react": ">=16"
}
},
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
+ "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"node_modules/@napi-rs/canvas": {
"version": "0.1.76",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.76.tgz",
}
},
"node_modules/@sveltejs/kit": {
- "version": "2.52.0",
- "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.52.0.tgz",
- "integrity": "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg==",
+ "version": "2.50.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.2.tgz",
+ "integrity": "sha512-875hTUkEbz+MyJIxWbQjfMaekqdmEKUUfR7JyKcpfMRZqcGyrO9Gd+iS1D/Dx8LpE5FEtutWGOtlAh4ReSAiOA==",
"dev": true,
"license": "MIT",
"peer": true,
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ajv-formats/node_modules/ajv": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
}
},
"node_modules/bits-ui": {
- "version": "2.15.7",
- "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.7.tgz",
- "integrity": "sha512-M9VrQAJXnT3xfhN/joEtVXhO794yBPmadZfNtDT4t4QwI8wgCBmDuv8FlH6K4v0q0Ugw07tumAPfym9MU2BGpg==",
+ "version": "2.15.5",
+ "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.5.tgz",
+ "integrity": "sha512-WhS+P+E//ClLfKU6KqjKC17nGDRLnz+vkwoP6ClFUPd5m1fFVDxTElPX8QVsduLj5V1KFDxlnv6sW2G5Lqk+vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"svelte": "^5.30.2"
}
},
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"dev": true,
"license": "MIT"
},
+ "node_modules/content-disposition": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+ "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"node": ">= 0.6"
}
},
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/corser": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"dev": true,
"license": "MIT"
},
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.18.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
"@esbuild/win32-x64": "0.25.8"
}
},
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
}
},
"node_modules/eslint-plugin-storybook": {
- "version": "10.2.9",
- "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.9.tgz",
- "integrity": "sha512-nmPxjPw2KfmosqAUb/W0jmEfAZzK97kyJ8W5KMuweCblwjIL0hI/GMsWSP8CCBPnhQ9LnuxtT8JtQUOsslcbwA==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.4.tgz",
+ "integrity": "sha512-D8a6Y+iun2MSOpgps0Vd/t8y9Y5ZZ7O2VeKqw2PCv2+b7yInqogOS2VBMSRZVfP8TTGQgDpbUK67k7KZEUC7Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
},
"peerDependencies": {
"eslint": ">=8",
- "storybook": "^10.2.9"
+ "storybook": "^10.2.4"
+ }
+ },
+ "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/project-service": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz",
+ "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.54.0",
+ "@typescript-eslint/types": "^8.54.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz",
+ "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.54.0",
+ "@typescript-eslint/visitor-keys": "8.54.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz",
+ "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/types": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz",
+ "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz",
+ "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.54.0",
+ "@typescript-eslint/tsconfig-utils": "8.54.0",
+ "@typescript-eslint/types": "8.54.0",
+ "@typescript-eslint/visitor-keys": "8.54.0",
+ "debug": "^4.4.3",
+ "minimatch": "^9.0.5",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/utils": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz",
+ "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.54.0",
+ "@typescript-eslint/types": "8.54.0",
+ "@typescript-eslint/typescript-estree": "8.54.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz",
+ "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.54.0",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/eslint-plugin-storybook/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-storybook/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/eslint-plugin-svelte": {
"node": ">=0.10.0"
}
},
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/expect-type": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
"node": ">=12.0.0"
}
},
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
+ "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "10.0.1"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"node": ">=8"
}
},
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
"node": ">=12.0.0"
}
},
+ "node_modules/hono": {
+ "version": "4.11.7",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
+ "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
"node_modules/html-encoding-sniffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"node": ">=8"
}
},
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
"node_modules/inline-style-parser": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
"integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==",
"license": "MIT"
},
+ "node_modules/ip-address": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
+ "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
"node_modules/is-wsl": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
+ "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
"node": ">=4"
}
},
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"license": "MIT",
"optional": true
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
"node_modules/open": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/path-to-regexp": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+ "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
"node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
}
},
"node_modules/qs": {
- "version": "6.15.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
- "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
- "dev": true,
+ "version": "6.14.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/raw-body/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"fsevents": "~2.3.2"
}
},
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/run-applescript": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
- "dev": true,
"license": "MIT"
},
"node_modules/sass": {
"node": ">=10"
}
},
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/set-cookie-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"dev": true,
"license": "MIT"
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/std-env": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
"license": "MIT"
},
"node_modules/storybook": {
- "version": "10.2.9",
- "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.9.tgz",
- "integrity": "sha512-DGok7XwIwdPWF+a49Yw+4madER5DZWRo9CdyySBLT3zeuxiEPt0Ua7ouJHm/y6ojnb/FVKZcQe8YmrE71s0qPQ==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.4.tgz",
+ "integrity": "sha512-LwF0VZsT4qkgx66Ad/q0QgZZrU2a5WftaADDEcJ3bGq3O2fHvwWPlSZjM1HiXD4vqP9U5JiMqQkV1gkyH0XJkw==",
"dev": true,
"license": "MIT",
"peer": true,
}
},
"node_modules/tar": {
- "version": "7.5.9",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
- "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
+ "version": "7.5.7",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
+ "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"node": ">=8.0"
}
},
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"node": ">= 10.0.0"
}
},
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
"uuid": "dist-node/bin/uuid"
}
},
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"license": "MIT"
},
+ "node_modules/zod": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
+ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.1",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
+ "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25 || ^4"
+ }
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
"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",
"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"
}
}
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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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}
<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 {
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);
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();
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}
{: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}
{imageClass}
{activeModelId}
/>
+
+{#if mcpResourcePreviewExtra}
+ <DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
+{/if}
<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,
disabled?: boolean;
isLoading?: boolean;
placeholder?: string;
+ showMcpPromptButton?: boolean;
// Event Handlers
onAttachmentRemove?: (index: number) => void;
onSubmit?: () => void;
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
onUploadedFileRemove?: (fileId: string) => void;
+ onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
onValueChange?: (value: string) => void;
}
disabled = false,
isLoading = false,
placeholder = 'Type a message...',
+ showMcpPromptButton = false,
uploadedFiles = $bindable([]),
value = $bindable(''),
onAttachmentRemove,
onSubmit,
onSystemPromptClick,
onUploadedFileRemove,
+ onUploadedFilesChange,
onValueChange
}: Props = $props();
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);
+
/**
*
*
*
*/
+ 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();
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);
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);
}
}
+ /**
+ *
+ *
+ * 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;
+ }
+
/**
*
*
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'
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}
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;
+ }
+ }}
+/>
<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 {
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
+ hasMcpPromptsSupport = false,
+ hasMcpResourcesSupport = false,
onFileUpload,
- onSystemPromptClick
+ onSystemPromptClick,
+ onMcpPromptClick,
+ onMcpSettingsClick,
+ onMcpResourcesClick
}: Props = $props();
let isNewChat = $derived(!page.params.id);
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>
<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>
--- /dev/null
+<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>
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;
onMicClick?: () => void;
onStop?: () => void;
onSystemPromptClick?: () => void;
+ onMcpPromptClick?: () => void;
+ onMcpResourcesClick?: () => void;
}
let {
onFileUpload,
onMicClick,
onStop,
- onSystemPromptClick
+ onSystemPromptClick,
+ onMcpPromptClick,
+ onMcpResourcesClick
}: Props = $props();
let currentConfig = $derived(config());
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}
/>
{/if}
</div>
+
+<DialogChatSettings
+ open={showChatSettingsDialogWithMcpSection}
+ onOpenChange={(open) => (showChatSettingsDialogWithMcpSection = open)}
+ initialSection={SETTINGS_SECTION_TITLES.MCP}
+/>
</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
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
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;
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();
{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}
--- /dev/null
+<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>
<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';
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,
}
}
- 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 {
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;
});
}
+ 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(
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">
{: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">
{/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}
{#if liveStats || genStats}
<ChatMessageStatistics
- isLive={true}
+ isLive
isProcessingPrompt={!!isStillProcessingPrompt}
promptTokens={liveStats?.tokensProcessed}
promptMs={liveStats?.timeMs}
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
}
+ function handleUploadedFilesChange(files: ChatUploadedFile[]) {
+ editCtx.setUploadedFiles(files);
+ }
+
$effect(() => {
chatStore.setEditModeActive(handleFilesAdd);
attachments={editCtx.editedExtras}
uploadedFiles={editCtx.editedUploadedFiles}
placeholder="Edit your message..."
+ showMcpPromptButton
onValueChange={editCtx.setContent}
onAttachmentRemove={handleAttachmentRemove}
onUploadedFileRemove={handleUploadedFileRemove}
+ onUploadedFilesChange={handleUploadedFilesChange}
onFilesAdd={handleFilesAdd}
onSubmit={handleSubmit}
/>
--- /dev/null
+<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>
--- /dev/null
+<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>
<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';
isLive?: boolean;
isProcessingPrompt?: boolean;
initialView?: ChatMessageStatsView;
+ agenticTimings?: ChatMessageAgenticTimings;
onActiveViewChange?: (view: ChatMessageStatsView) => void;
+ hideSummary?: boolean;
}
let {
isLive = false,
isProcessingPrompt = false,
initialView = ChatMessageStatsView.GENERATION,
- onActiveViewChange
+ agenticTimings,
+ onActiveViewChange,
+ hideSummary = false
}: Props = $props();
let activeView: ChatMessageStatsView = $derived(initialView);
// 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">
</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">
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"
<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
: '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>
+++ /dev/null
-<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>
{: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}
>
<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
<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}
{isServerLoading ? 'Retrying...' : 'Retry'}
</button>
</Alert.Title>
+
<Alert.Description>{serverError()}</Alert.Description>
</Alert.Root>
</div>
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd}
- showHelperText={true}
+ showHelperText
bind:uploadedFiles
/>
</div>
class={className}
{disabled}
{isLoading}
+ showMcpPromptButton
onFilesAdd={handleFilesAdd}
{onStop}
onSubmit={handleSubmit}
</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"
>
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';
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,
<!-- 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
{#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
* - 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
*/
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.
*/
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).
* **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:**
* - 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
*
*/
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
*/
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
*
* **User Messages:**
* - Shows attachments via ChatAttachmentsList
+ * - Displays MCP prompts if present
* - Edit creates new branch or preserves responses
*
* **Assistant Messages:**
*/
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.
*/
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.
/**
* 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';
* @example
* ```svelte
* <!-- In chat route -->
- * <ChatScreen showCenteredEmpty={true} />
+ * <ChatScreen showCenteredEmpty />
*
* <!-- In conversation route -->
* <ChatScreen showCenteredEmpty={false} />
* - **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:**
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';
// 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;
$effect(() => {
const currentMode = mode.current;
- const isDark = currentMode === 'dark';
+ const isDark = currentMode === ColorMode.DARK;
loadHighlightTheme(isDark);
});
<ActionIconsCodeBlock
code={incompleteCodeBlock.code}
language={incompleteCodeBlock.language || 'text'}
- disabled={true}
+ disabled
onPreview={(code, lang) => {
previewCode = code;
previewLanguage = lang;
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;
$effect(() => {
const currentMode = mode.current;
- const isDark = currentMode === 'dark';
+ const isDark = currentMode === ColorMode.DARK;
loadHighlightTheme(isDark);
});
* bind:open
* icon={BrainIcon}
* title="Thinking..."
- * isStreaming={true}
+ * isStreaming
* >
* {reasoningContent}
* </CollapsibleContentBlock>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
* ```
*/
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';
--- /dev/null
+<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>
<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 {
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}>
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"
/>
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>
*/
/**
- * **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
* - 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';
export * from './content';
export * from './dialogs';
export * from './forms';
+export * from './mcp';
export * from './misc';
export * from './models';
export * from './navigation';
--- /dev/null
+<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}
--- /dev/null
+<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}
--- /dev/null
+<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}
--- /dev/null
+<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
+>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+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);
+ });
+}
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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}
+/>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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}
--- /dev/null
+<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}
--- /dev/null
+<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>
--- /dev/null
+/**
+ *
+ * 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';
<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;
}
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);
});
</script>
-{#if isTruncated}
+{#if isTruncated && showTooltip}
<Tooltip.Root>
<Tooltip.Trigger class={className}>
<span bind:this={textElement} class="block truncate">
<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;
</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}
--- /dev/null
+<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}
*/
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
*
'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: {
<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',
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),
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);
+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>>>',
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)
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;
LOAD: '/models/load',
UNLOAD: '/models/unload'
};
+
+/** CORS proxy endpoint path */
+export const CORS_PROXY_ENDPOINT = '/cors-proxy';
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';
*/
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
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 = '@';
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';
--- /dev/null
+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;
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';
export * from './table-html-restorer';
export * from './tooltip-config';
export * from './ui';
+export * from './uri-template';
export * from './viewport';
--- /dev/null
+/**
+ * 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;
--- /dev/null
+export const MCP_SERVER_URL_PLACEHOLDER = 'https://mcp.example.com/sse';
+export const MIN_AUTOCOMPLETE_INPUT_LENGTH = 1;
--- /dev/null
+import { MimeTypeImage } from '$lib/enums';
+
+// File extension patterns for resource type detection
+export const IMAGE_FILE_EXTENSION_REGEX = /\.(png|jpg|jpeg|gif|svg|webp)$/i;
+export const CODE_FILE_EXTENSION_REGEX =
+ /\.(js|ts|json|yaml|yml|xml|html|css|py|rs|go|java|cpp|c|h|rb|sh|toml)$/i;
+export const TEXT_FILE_EXTENSION_REGEX = /\.(txt|md|log)$/i;
+
+// URI protocol prefix pattern
+export const PROTOCOL_PREFIX_REGEX = /^[a-z]+:\/\//;
+
+// File extension regex for display name extraction
+export const FILE_EXTENSION_REGEX = /\.[^.]+$/;
+
+// Separator regex for splitting display names (kebab-case/snake_case)
+export const DISPLAY_NAME_SEPARATOR_REGEX = /[-_]/;
+
+// Regex for matching base64-encoded data URIs
+export const DATA_URI_BASE64_REGEX = /^data:([^;]+);base64,([A-Za-z0-9+/]+=*)$/;
+
+// Prefix for MCP attachment filenames
+export const MCP_ATTACHMENT_NAME_PREFIX = 'mcp-attachment';
+
+// Prefix for MCP resource attachment IDs
+export const MCP_RESOURCE_ATTACHMENT_ID_PREFIX = 'res';
+
+// Default file extension for unknown image types
+export const DEFAULT_IMAGE_EXTENSION = 'img';
+
+// Default filename for resource content downloads
+export const DEFAULT_RESOURCE_FILENAME = 'resource.txt';
+
+// Path separator for resource URI parsing
+export const PATH_SEPARATOR = '/';
+
+// Separator for joining text content from multiple resource parts
+export const RESOURCE_TEXT_CONTENT_SEPARATOR = '\n\n';
+
+// Fallback text for unknown content types
+export const RESOURCE_UNKNOWN_TYPE = 'unknown type';
+
+// Label prefix for binary blob content
+export const BINARY_CONTENT_LABEL = 'Binary content';
+
+/**
+ * Mapping from image MIME types to file extensions.
+ * Used for generating attachment filenames from MIME types.
+ */
+export const IMAGE_MIME_TO_EXTENSION: Record<string, string> = {
+ [MimeTypeImage.JPEG]: 'jpg',
+ [MimeTypeImage.JPG]: 'jpg',
+ [MimeTypeImage.PNG]: 'png',
+ [MimeTypeImage.GIF]: 'gif',
+ [MimeTypeImage.WEBP]: 'webp'
+} as const;
--- /dev/null
+import { Zap, Globe, Radio } from '@lucide/svelte';
+import { MCPTransportType } from '$lib/enums';
+import type { ClientCapabilities, Implementation } from '$lib/types';
+import type { Component } from 'svelte';
+import { MimeTypeImage } from '$lib/enums/files';
+
+export const DEFAULT_CLIENT_VERSION = '1.0.0';
+export const DEFAULT_IMAGE_MIME_TYPE = MimeTypeImage.PNG;
+
+/** MIME types considered safe for rendering MCP server icons */
+export const MCP_ALLOWED_ICON_MIME_TYPES = new Set([
+ MimeTypeImage.PNG,
+ MimeTypeImage.JPEG,
+ MimeTypeImage.JPG,
+ MimeTypeImage.SVG,
+ MimeTypeImage.WEBP
+]);
+
+/**
+ * MCP specification version this client targets.
+ * Update when the upstream MCP spec introduces a new stable version:
+ * https://spec.modelcontextprotocol.io/
+ */
+export const MCP_PROTOCOL_VERSION = '2025-06-18';
+
+export const DEFAULT_MCP_CONFIG = {
+ protocolVersion: MCP_PROTOCOL_VERSION,
+ capabilities: { tools: { listChanged: true } } as ClientCapabilities,
+ clientInfo: { name: 'llama-webui-mcp', version: DEFAULT_CLIENT_VERSION } as Implementation,
+ requestTimeoutSeconds: 300, // 5 minutes for long-running tools
+ connectionTimeoutMs: 10_000 // 10 seconds for connection establishment
+} as const;
+
+export const MCP_SERVER_ID_PREFIX = 'LlamaCpp-WebUI-MCP-Server';
+
+export const MCP_RECONNECT_INITIAL_DELAY = 1000;
+export const MCP_RECONNECT_BACKOFF_MULTIPLIER = 2;
+export const MCP_RECONNECT_MAX_DELAY = 30000;
+/** Per-attempt timeout for a single reconnection attempt before giving up and backing off. */
+export const MCP_RECONNECT_ATTEMPT_TIMEOUT_MS = 15_000;
+
+/** Maximum number of MCP server avatars to display in the chat form */
+export const MAX_DISPLAYED_MCP_AVATARS = 3;
+
+/** Expected count when two theme-less icons represent a light/dark pair */
+export const EXPECTED_THEMED_ICON_PAIR_COUNT = 2;
+
+/** CORS proxy URL query parameter name */
+export const CORS_PROXY_URL_PARAM = 'url';
+
+/** Human-readable labels for MCP transport types */
+export const MCP_TRANSPORT_LABELS: Record<MCPTransportType, string> = {
+ [MCPTransportType.WEBSOCKET]: 'WebSocket',
+ [MCPTransportType.STREAMABLE_HTTP]: 'HTTP',
+ [MCPTransportType.SSE]: 'SSE'
+};
+
+/** Icon components for MCP transport types */
+export const MCP_TRANSPORT_ICONS: Record<MCPTransportType, Component> = {
+ [MCPTransportType.WEBSOCKET]: Zap,
+ [MCPTransportType.STREAMABLE_HTTP]: Globe,
+ [MCPTransportType.SSE]: Radio
+};
autoMicOnEmpty: false,
fullHeightCodeBlocks: false,
showRawModelNames: false,
+ mcpServers: '[]',
+ mcpServerUsageStats: '{}', // JSON object: { [serverId]: usageCount }
+ agenticMaxTurns: 10,
+ agenticMaxToolPreviewLines: 25,
+ showToolCallInProgress: false,
+ alwaysShowAgenticTurns: false,
// make sure these default values are in sync with `common.h`
samplers: 'top_k;typ_p;top_p;min_p;temperature',
backend_sampling: false,
'Always display code blocks at their full natural height, overriding any height limits.',
showRawModelNames:
'Display full raw model identifiers (e.g. "unsloth/Qwen3.5-27B-GGUF:BF16") instead of parsed names with badges.',
+ mcpServers:
+ 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
+ mcpServerUsageStats:
+ 'Usage statistics for MCP servers. Tracks how many times tools from each server have been used.',
+ agenticMaxTurns:
+ 'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
+ agenticMaxToolPreviewLines:
+ 'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.',
+ showToolCallInProgress:
+ 'Automatically expand tool call details while executing and keep them expanded after completion.',
pyInterpreterEnabled:
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
enableContinueGeneration:
DRY_BASE: 'dry_base',
DRY_ALLOWED_LENGTH: 'dry_allowed_length',
DRY_PENALTY_LAST_N: 'dry_penalty_last_n',
+ // MCP
+ AGENTIC_MAX_TURNS: 'agenticMaxTurns',
+ ALWAYS_SHOW_AGENTIC_TURNS: 'alwaysShowAgenticTurns',
+ AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
+ SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress',
// Developer
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
/**
* Settings section titles constants for ChatSettings component.
+ *
+ * These titles define the navigation sections in the settings dialog.
+ * Used for both sidebar navigation and mobile horizontal scroll menu.
*/
export const SETTINGS_SECTION_TITLES = {
GENERAL: 'General',
SAMPLING: 'Sampling',
PENALTIES: 'Penalties',
IMPORT_EXPORT: 'Import/Export',
+ MCP: 'MCP',
DEVELOPER: 'Developer'
} as const;
+/** Type for settings section titles */
export type SettingsSectionTitle =
(typeof SETTINGS_SECTION_TITLES)[keyof typeof SETTINGS_SECTION_TITLES];
--- /dev/null
+/**
+ * URI Template constants for RFC 6570 template processing.
+ */
+
+/** URI scheme separator */
+export const URI_SCHEME_SEPARATOR = '://';
+
+/** Regex to match template expressions like {var}, {+var}, {#var}, {/var} */
+export const TEMPLATE_EXPRESSION_REGEX = /\{([+#./;?&]?)([^}]+)\}/g;
+
+/** RFC 6570 URI template operators */
+export const URI_TEMPLATE_OPERATORS = {
+ /** Simple string expansion (default) */
+ SIMPLE: '',
+ /** Reserved expansion */
+ RESERVED: '+',
+ /** Fragment expansion */
+ FRAGMENT: '#',
+ /** Path segment expansion */
+ PATH_SEGMENT: '/',
+ /** Label expansion */
+ LABEL: '.',
+ /** Path-style parameters */
+ PATH_PARAM: ';',
+ /** Form-style query */
+ FORM_QUERY: '?',
+ /** Form-style query continuation */
+ FORM_CONTINUATION: '&'
+} as const;
+
+/** URI template separators used in expansion */
+export const URI_TEMPLATE_SEPARATORS = {
+ /** Comma separator for list expansion */
+ COMMA: ',',
+ /** Slash separator for path segments */
+ SLASH: '/',
+ /** Period separator for label expansion */
+ PERIOD: '.',
+ /** Semicolon separator for path parameters */
+ SEMICOLON: ';',
+ /** Question mark prefix for query string */
+ QUERY_PREFIX: '?',
+ /** Ampersand prefix for query continuation */
+ QUERY_CONTINUATION: '&'
+} as const;
+
+/** Maximum number of leading slashes to strip during URI normalization */
+export const MAX_LEADING_SLASHES_TO_STRIP = 3;
+
+/** Regex to strip explode modifier (*) from variable names */
+export const VARIABLE_EXPLODE_MODIFIER_REGEX = /[*]$/;
+
+/** Regex to strip prefix modifier (:N) from variable names */
+export const VARIABLE_PREFIX_MODIFIER_REGEX = /:[\d]+$/;
+
+/** Regex to strip one or more leading slashes */
+export const LEADING_SLASHES_REGEX = /^\/+/;
--- /dev/null
+/**
+ * OpenAI-compatible tool call type.
+ */
+export enum ToolCallType {
+ FUNCTION = 'function'
+}
+
+/**
+ * Types of sections in agentic content display.
+ */
+export enum AgenticSectionType {
+ TEXT = 'text',
+ TOOL_CALL = 'tool_call',
+ TOOL_CALL_PENDING = 'tool_call_pending',
+ TOOL_CALL_STREAMING = 'tool_call_streaming',
+ REASONING = 'reasoning',
+ REASONING_PENDING = 'reasoning_pending'
+}
export enum AttachmentType {
AUDIO = 'AUDIO',
IMAGE = 'IMAGE',
+ MCP_PROMPT = 'MCP_PROMPT',
+ MCP_RESOURCE = 'MCP_RESOURCE',
PDF = 'PDF',
TEXT = 'TEXT',
LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility
TEXT = 'text'
}
+/**
+ * Special file types for internal use (not MIME types)
+ */
+export enum SpecialFileType {
+ MCP_PROMPT = 'mcp-prompt'
+}
+
// Specific file type enums for each category
export enum FileTypeImage {
JPEG = 'jpeg',
export { AttachmentType } from './attachment';
+export { AgenticSectionType, ToolCallType } from './agentic';
+
export {
ChatMessageStatsView,
ContentPartType,
MimeTypeApplication,
MimeTypeAudio,
MimeTypeImage,
- MimeTypeText
+ MimeTypeText,
+ SpecialFileType
} from './files';
+export {
+ MCPConnectionPhase,
+ MCPLogLevel,
+ MCPTransportType,
+ HealthCheckStatus,
+ MCPContentType,
+ MCPRefType,
+ JsonSchemaType
+} from './mcp';
+
export { ModelModality } from './model';
export { ServerRole, ServerModelStatus } from './server';
export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings';
-export { ColorMode, UrlPrefix } from './ui';
+export { ColorMode, McpPromptVariant, UrlProtocol } from './ui';
export { KeyboardKey } from './keyboard';
--- /dev/null
+/**
+ * Connection lifecycle phases for MCP protocol
+ */
+export enum MCPConnectionPhase {
+ IDLE = 'idle',
+ TRANSPORT_CREATING = 'transport_creating',
+ TRANSPORT_READY = 'transport_ready',
+ INITIALIZING = 'initializing',
+ CAPABILITIES_EXCHANGED = 'capabilities_exchanged',
+ LISTING_TOOLS = 'listing_tools',
+ CONNECTED = 'connected',
+ ERROR = 'error',
+ DISCONNECTED = 'disconnected'
+}
+
+/**
+ * Log level for connection events
+ */
+export enum MCPLogLevel {
+ INFO = 'info',
+ WARN = 'warn',
+ ERROR = 'error'
+}
+
+/**
+ * Transport types for MCP connections
+ */
+export enum MCPTransportType {
+ WEBSOCKET = 'websocket',
+ STREAMABLE_HTTP = 'streamable_http',
+ SSE = 'sse'
+}
+
+/**
+ * Health check status for MCP servers
+ */
+export enum HealthCheckStatus {
+ IDLE = 'idle',
+ CONNECTING = 'connecting',
+ SUCCESS = 'success',
+ ERROR = 'error'
+}
+
+/**
+ * Content types for MCP tool results
+ */
+export enum MCPContentType {
+ TEXT = 'text',
+ IMAGE = 'image',
+ RESOURCE = 'resource'
+}
+
+/**
+ * JSON Schema types used in MCP tool definitions
+ */
+export enum JsonSchemaType {
+ OBJECT = 'object'
+}
+
+/**
+ * Reference types for MCP completions
+ */
+export enum MCPRefType {
+ PROMPT = 'ref/prompt',
+ RESOURCE = 'ref/resource'
+}
SYSTEM = 'system'
}
+/**
+ * MCP prompt display variant
+ */
+export enum McpPromptVariant {
+ MESSAGE = 'message',
+ ATTACHMENT = 'attachment'
+}
+
/**
* URL prefixes for protocol detection
*/
-export enum UrlPrefix {
+export enum UrlProtocol {
DATA = 'data:',
HTTP = 'http://',
HTTPS = 'https://',
import type { Root as HastRoot } from 'hast';
import { visit } from 'unist-util-visit';
import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
-import { AttachmentType, UrlPrefix } from '$lib/enums';
+import { AttachmentType, UrlProtocol } from '$lib/enums';
/**
* Rehype plugin to resolve attachment image sources.
- * Converts attachment names to base64 data URLs.
+ * Converts attachment names (e.g., "mcp-attachment-xxx.png") to base64 data URLs.
*/
export function rehypeResolveAttachmentImages(options: { attachments?: DatabaseMessageExtra[] }) {
return (tree: HastRoot) => {
if (node.tagName === 'img' && node.properties?.src) {
const src = String(node.properties.src);
- if (src.startsWith(UrlPrefix.DATA) || src.startsWith(UrlPrefix.HTTP)) {
+ // Skip data URLs and external URLs
+ if (src.startsWith(UrlProtocol.DATA) || src.startsWith(UrlProtocol.HTTP)) {
return;
}
+ // Find matching attachment
const attachment = options.attachments?.find(
(a): a is DatabaseMessageExtraImageFile =>
a.type === AttachmentType.IMAGE && a.name === src
);
+ // Replace with base64 URL if found
if (attachment?.base64Url) {
node.properties.src = attachment.base64Url;
}
import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
-import { AGENTIC_REGEX, ATTACHMENT_LABEL_PDF_FILE } from '$lib/constants';
+import {
+ AGENTIC_REGEX,
+ ATTACHMENT_LABEL_PDF_FILE,
+ ATTACHMENT_LABEL_MCP_PROMPT,
+ ATTACHMENT_LABEL_MCP_RESOURCE
+} from '$lib/constants';
import {
AttachmentType,
ContentPartType,
MessageRole,
ReasoningFormat,
- UrlPrefix
+ UrlProtocol
} from '$lib/enums';
import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
+import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types';
import { modelsStore } from '$lib/stores/models.svelte';
export class ChatService {
if (typeof content === 'string') {
return content
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
- .replace(AGENTIC_REGEX.REASONING_OPEN, '');
+ .replace(AGENTIC_REGEX.REASONING_OPEN, '')
+ .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
+ .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
}
if (!Array.isArray(content)) {
text: part.text
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '')
+ .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
+ .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
};
});
}
for (const line of lines) {
if (abortSignal?.aborted) break;
- if (line.startsWith(UrlPrefix.DATA)) {
+ if (line.startsWith(UrlProtocol.DATA)) {
const data = line.slice(6);
if (data === '[DONE]') {
streamFinished = true;
}
}
+ const mcpPrompts = message.extra.filter(
+ (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpPrompt =>
+ extra.type === AttachmentType.MCP_PROMPT
+ );
+
+ for (const mcpPrompt of mcpPrompts) {
+ contentParts.push({
+ type: ContentPartType.TEXT,
+ text: formatAttachmentText(
+ ATTACHMENT_LABEL_MCP_PROMPT,
+ mcpPrompt.name,
+ mcpPrompt.content,
+ mcpPrompt.serverName
+ )
+ });
+ }
+
+ const mcpResources = message.extra.filter(
+ (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpResource =>
+ extra.type === AttachmentType.MCP_RESOURCE
+ );
+
+ for (const mcpResource of mcpResources) {
+ contentParts.push({
+ type: ContentPartType.TEXT,
+ text: formatAttachmentText(
+ ATTACHMENT_LABEL_MCP_RESOURCE,
+ mcpResource.name,
+ mcpResource.content,
+ mcpResource.serverName
+ )
+ });
+ }
+
const result: ApiChatMessageData = {
role: message.role as MessageRole,
content: contentParts
};
-
+ if (toolCalls && toolCalls.length > 0) {
+ result.tool_calls = toolCalls;
+ }
return result;
}
import Dexie, { type EntityTable } from 'dexie';
-import { findDescendantMessages } from '$lib/utils';
+import { findDescendantMessages, uuid } from '$lib/utils';
class LlamacppDatabase extends Dexie {
conversations!: EntityTable<DatabaseConversation, string>;
}
const db = new LlamacppDatabase();
-import { v4 as uuid } from 'uuid';
import { MessageRole } from '$lib/enums';
export class DatabaseService {
* @see ChatSettingsParameterSourceIndicator — displays parameter source badges in UI
*/
export { ParameterSyncService } from './parameter-sync.service';
+
+/**
+ * **MCPService** - Low-level MCP protocol communication layer
+ *
+ * Implements the client-side MCP (Model Context Protocol) SDK operations for connecting
+ * to MCP servers, discovering capabilities, and executing protocol operations.
+ * Supports multiple transport types: WebSocket, StreamableHTTP, and SSE (legacy fallback).
+ *
+ * **Architecture & Relationships:**
+ * - **MCPService** (this class): Stateless protocol communication
+ * - Creates and manages transport connections (WebSocket, StreamableHTTP, SSE)
+ * - Wraps MCP SDK client operations with error handling
+ * - Formats tool results and extracts server info
+ * - Provides abort signal support for cancellable operations
+ *
+ * - **mcpStore**: Reactive business logic facade
+ * - Uses MCPService for all protocol-level operations
+ * - Manages connection lifecycle, health checks, reconnection
+ * - Handles tool name conflict resolution and server coordination
+ *
+ * - **mcpResourceStore**: Reactive resource state
+ * - Receives resource data fetched via MCPService
+ * - Manages resource caching, subscriptions, and attachments
+ *
+ * - **agenticStore**: Agentic loop orchestration
+ * - Executes tool calls via mcpStore → MCPService chain
+ *
+ * **Key Responsibilities:**
+ * - Transport creation with automatic fallback (StreamableHTTP → SSE)
+ * - Server connection with detailed phase tracking and progress callbacks
+ * - Tool discovery (`listTools`) and execution (`callTool`) with abort support
+ * - Prompt listing (`listPrompts`) and retrieval (`getPrompt`) with arguments
+ * - Resource operations: list, read, subscribe/unsubscribe, template support
+ * - Completion suggestions for prompt arguments and resource URI templates
+ * - CORS proxy routing via llama-server for cross-origin MCP servers
+ * - Tool result formatting (text, images, embedded resources)
+ *
+ * **Transport Hierarchy:**
+ * 1. **WebSocket** — bidirectional, no CORS proxy support
+ * 2. **StreamableHTTP** — modern HTTP-based, supports CORS proxy
+ * 3. **SSE** — legacy fallback, supports CORS proxy
+ *
+ * @see mcpStore in stores/mcp.svelte.ts — reactive business logic facade on top of MCPService
+ * @see mcpResourceStore in stores/mcp-resources.svelte.ts — reactive resource state management
+ * @see agenticStore in stores/agentic.svelte.ts — uses MCPService (via mcpStore) for tool execution
+ * @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18
+ */
+export { MCPService } from './mcp.service';
--- /dev/null
+import { Client } from '@modelcontextprotocol/sdk/client';
+import {
+ StreamableHTTPClientTransport,
+ StreamableHTTPError
+} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
+import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
+import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
+import type {
+ Tool,
+ Prompt,
+ GetPromptResult,
+ ListChangedHandlers
+} from '@modelcontextprotocol/sdk/types.js';
+import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
+import {
+ DEFAULT_MCP_CONFIG,
+ DEFAULT_CLIENT_VERSION,
+ DEFAULT_IMAGE_MIME_TYPE
+} from '$lib/constants';
+import {
+ MCPConnectionPhase,
+ MCPLogLevel,
+ MCPTransportType,
+ MCPContentType,
+ MCPRefType
+} from '$lib/enums';
+import type {
+ MCPServerConfig,
+ ToolCallParams,
+ ToolExecutionResult,
+ Implementation,
+ ClientCapabilities,
+ MCPConnection,
+ MCPPhaseCallback,
+ MCPConnectionLog,
+ MCPServerInfo,
+ MCPResource,
+ MCPResourceTemplate,
+ MCPResourceContent,
+ MCPReadResourceResult
+} from '$lib/types';
+import { buildProxiedUrl, throwIfAborted, isAbortError, createBase64DataUrl } from '$lib/utils';
+
+interface ToolResultContentItem {
+ type: string;
+ text?: string;
+ data?: string;
+ mimeType?: string;
+ resource?: { text?: string; blob?: string; uri?: string };
+}
+
+interface ToolCallResult {
+ content?: ToolResultContentItem[];
+ isError?: boolean;
+ _meta?: Record<string, unknown>;
+}
+
+export class MCPService {
+ /**
+ * Create a connection log entry for phase tracking.
+ *
+ * @param phase - The connection phase this log belongs to
+ * @param message - Human-readable log message
+ * @param level - Log severity level (default: INFO)
+ * @param details - Optional structured details for debugging
+ * @returns Formatted connection log entry
+ */
+ private static createLog(
+ phase: MCPConnectionPhase,
+ message: string,
+ level: MCPLogLevel = MCPLogLevel.INFO,
+ details?: unknown
+ ): MCPConnectionLog {
+ return {
+ timestamp: new Date(),
+ phase,
+ message,
+ level,
+ details
+ };
+ }
+
+ /**
+ * Detect if an error indicates an expired/invalidated MCP session.
+ * Per MCP spec 2025-11-25: HTTP 404 means session invalidated, client MUST
+ * discard its session ID and start a new session with a fresh initialize request.
+ *
+ * @param error - The caught error to inspect
+ * @returns true if the error is a StreamableHTTP 404 (session not found)
+ */
+ static isSessionExpiredError(error: unknown): boolean {
+ return error instanceof StreamableHTTPError && error.code === 404;
+ }
+
+ /**
+ * Create transport based on server configuration.
+ * Supports WebSocket, StreamableHTTP (modern), and SSE (legacy) transports.
+ * When `useProxy` is enabled, routes HTTP requests through llama-server's CORS proxy.
+ *
+ * **Fallback Order:**
+ * 1. WebSocket — if explicitly configured (no CORS proxy support)
+ * 2. StreamableHTTP — default for HTTP connections
+ * 3. SSE — automatic fallback if StreamableHTTP fails
+ *
+ * @param config - Server configuration with url, transport type, proxy, and auth settings
+ * @returns Object containing the created transport and the transport type used
+ * @throws {Error} If url is missing, WebSocket + proxy combination, or all transports fail
+ */
+ static createTransport(config: MCPServerConfig): {
+ transport: Transport;
+ type: MCPTransportType;
+ } {
+ if (!config.url) {
+ throw new Error('MCP server configuration is missing url');
+ }
+
+ const useProxy = config.useProxy ?? false;
+ const requestInit: RequestInit = {};
+
+ if (config.headers) {
+ requestInit.headers = config.headers;
+ }
+
+ if (config.credentials) {
+ requestInit.credentials = config.credentials;
+ }
+
+ if (config.transport === MCPTransportType.WEBSOCKET) {
+ if (useProxy) {
+ throw new Error(
+ 'WebSocket transport is not supported when using CORS proxy. Use HTTP transport instead.'
+ );
+ }
+
+ const url = new URL(config.url);
+
+ if (import.meta.env.DEV) {
+ console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
+ }
+
+ return {
+ transport: new WebSocketClientTransport(url),
+ type: MCPTransportType.WEBSOCKET
+ };
+ }
+
+ const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url);
+
+ if (useProxy && import.meta.env.DEV) {
+ console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`);
+ }
+
+ try {
+ if (import.meta.env.DEV) {
+ console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
+ }
+
+ return {
+ transport: new StreamableHTTPClientTransport(url, {
+ requestInit
+ }),
+ type: MCPTransportType.STREAMABLE_HTTP
+ };
+ } catch (httpError) {
+ console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError);
+
+ try {
+ return {
+ transport: new SSEClientTransport(url, { requestInit }),
+ type: MCPTransportType.SSE
+ };
+ } catch (sseError) {
+ const httpMsg = httpError instanceof Error ? httpError.message : String(httpError);
+ const sseMsg = sseError instanceof Error ? sseError.message : String(sseError);
+
+ throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`);
+ }
+ }
+ }
+
+ /**
+ * Extract server info from SDK Implementation type.
+ * Normalizes the SDK's server version response into our MCPServerInfo type.
+ *
+ * @param impl - Raw Implementation object from MCP SDK
+ * @returns Normalized server info or undefined if input is empty
+ */
+ private static extractServerInfo(impl: Implementation | undefined): MCPServerInfo | undefined {
+ if (!impl) {
+ return undefined;
+ }
+
+ return {
+ name: impl.name,
+ version: impl.version,
+ title: impl.title,
+ description: impl.description,
+ websiteUrl: impl.websiteUrl,
+ icons: impl.icons?.map((icon: { src: string; mimeType?: string; sizes?: string }) => ({
+ src: icon.src,
+ mimeType: icon.mimeType,
+ sizes: icon.sizes
+ }))
+ };
+ }
+
+ /**
+ * Connect to a single MCP server with detailed phase tracking.
+ *
+ * Performs the full MCP connection lifecycle:
+ * 1. Transport creation (with automatic fallback)
+ * 2. Client initialization and capability exchange
+ * 3. Tool discovery via `listTools`
+ *
+ * Reports progress via `onPhase` callback at each step, enabling
+ * UI progress indicators during connection.
+ *
+ * @param serverName - Display name for the server (used in logging)
+ * @param serverConfig - Server URL, transport type, proxy, and auth configuration
+ * @param clientInfo - Optional client identification (defaults to app info)
+ * @param capabilities - Optional client capability declaration
+ * @param onPhase - Optional callback for connection phase progress updates
+ * @param listChangedHandlers - Optional handlers for server-initiated list change notifications
+ * @returns Full connection object with client, transport, tools, server info, and timing
+ * @throws {Error} If transport creation or connection fails
+ */
+ static async connect(
+ serverName: string,
+ serverConfig: MCPServerConfig,
+ clientInfo?: Implementation,
+ capabilities?: ClientCapabilities,
+ onPhase?: MCPPhaseCallback,
+ listChangedHandlers?: ListChangedHandlers
+ ): Promise<MCPConnection> {
+ const startTime = performance.now();
+ const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
+ const effectiveCapabilities = capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
+
+ // Phase: Creating transport
+ onPhase?.(
+ MCPConnectionPhase.TRANSPORT_CREATING,
+ this.createLog(
+ MCPConnectionPhase.TRANSPORT_CREATING,
+ `Creating transport for ${serverConfig.url}`
+ )
+ );
+
+ if (import.meta.env.DEV) {
+ console.log(`[MCPService][${serverName}] Creating transport...`);
+ }
+
+ const { transport, type: transportType } = this.createTransport(serverConfig);
+
+ // Setup WebSocket reconnection handler
+ if (transportType === MCPTransportType.WEBSOCKET) {
+ transport.onclose = () => {
+ console.log(`[MCPService][${serverName}] WebSocket closed, notifying for reconnection`);
+ onPhase?.(
+ MCPConnectionPhase.DISCONNECTED,
+ this.createLog(MCPConnectionPhase.DISCONNECTED, 'WebSocket connection closed')
+ );
+ };
+ }
+
+ // Phase: Transport ready
+ onPhase?.(
+ MCPConnectionPhase.TRANSPORT_READY,
+ this.createLog(MCPConnectionPhase.TRANSPORT_READY, `Transport ready (${transportType})`),
+ { transportType }
+ );
+
+ const client = new Client(
+ {
+ name: effectiveClientInfo.name,
+ version: effectiveClientInfo.version ?? DEFAULT_CLIENT_VERSION
+ },
+ {
+ capabilities: effectiveCapabilities,
+ listChanged: listChangedHandlers
+ }
+ );
+
+ // Phase: Initializing
+ onPhase?.(
+ MCPConnectionPhase.INITIALIZING,
+ this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...')
+ );
+
+ console.log(`[MCPService][${serverName}] Connecting to server...`);
+ await client.connect(transport);
+
+ const serverVersion = client.getServerVersion();
+ const serverCapabilities = client.getServerCapabilities();
+ const instructions = client.getInstructions();
+ const serverInfo = this.extractServerInfo(serverVersion);
+
+ // Phase: Capabilities exchanged
+ onPhase?.(
+ MCPConnectionPhase.CAPABILITIES_EXCHANGED,
+ this.createLog(
+ MCPConnectionPhase.CAPABILITIES_EXCHANGED,
+ 'Capabilities exchanged successfully',
+ MCPLogLevel.INFO,
+ {
+ serverCapabilities,
+ serverInfo
+ }
+ ),
+ {
+ serverInfo,
+ serverCapabilities,
+ clientCapabilities: effectiveCapabilities,
+ instructions
+ }
+ );
+
+ // Phase: Listing tools
+ onPhase?.(
+ MCPConnectionPhase.LISTING_TOOLS,
+ this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...')
+ );
+
+ console.log(`[MCPService][${serverName}] Connected, listing tools...`);
+ const tools = await this.listTools({
+ client,
+ transport,
+ tools: [],
+ serverName,
+ transportType,
+ connectionTimeMs: 0
+ });
+
+ const connectionTimeMs = Math.round(performance.now() - startTime);
+
+ // Phase: Connected
+ onPhase?.(
+ MCPConnectionPhase.CONNECTED,
+ this.createLog(
+ MCPConnectionPhase.CONNECTED,
+ `Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
+ )
+ );
+
+ console.log(
+ `[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
+ );
+
+ return {
+ client,
+ transport,
+ tools,
+ serverName,
+ transportType,
+ serverInfo,
+ serverCapabilities,
+ clientCapabilities: effectiveCapabilities,
+ protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
+ instructions,
+ connectionTimeMs
+ };
+ }
+
+ /**
+ * Disconnect from a server.
+ * Clears the `onclose` handler to prevent reconnection attempts on voluntary disconnect.
+ *
+ * @param connection - The active MCP connection to close
+ */
+ static async disconnect(connection: MCPConnection): Promise<void> {
+ console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
+ try {
+ // Prevent reconnection on voluntary disconnect
+ if (connection.transport.onclose) {
+ connection.transport.onclose = undefined;
+ }
+
+ await connection.client.close();
+ } catch (error) {
+ console.warn(`[MCPService][${connection.serverName}] Error during disconnect:`, error);
+ }
+ }
+
+ /**
+ * List tools from a connection.
+ * Silently returns empty array on failure (logged as warning).
+ *
+ * @param connection - The MCP connection to query
+ * @returns Array of available tools, or empty array on error
+ */
+ static async listTools(connection: MCPConnection): Promise<Tool[]> {
+ try {
+ const result = await connection.client.listTools();
+
+ return result.tools ?? [];
+ } catch (error) {
+ // Let session-expired errors propagate for reconnection handling
+ if (this.isSessionExpiredError(error)) {
+ throw error;
+ }
+
+ console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error);
+
+ return [];
+ }
+ }
+
+ /**
+ * List prompts from a connection.
+ * Silently returns empty array on failure (logged as warning).
+ *
+ * @param connection - The MCP connection to query
+ * @returns Array of available prompts, or empty array on error
+ */
+ static async listPrompts(connection: MCPConnection): Promise<Prompt[]> {
+ try {
+ const result = await connection.client.listPrompts();
+
+ return result.prompts ?? [];
+ } catch (error) {
+ // Let session-expired errors propagate for reconnection handling
+ if (this.isSessionExpiredError(error)) {
+ throw error;
+ }
+
+ console.warn(`[MCPService][${connection.serverName}] Failed to list prompts:`, error);
+
+ return [];
+ }
+ }
+
+ /**
+ * Get a specific prompt with arguments.
+ * Unlike list operations, this throws on failure since the caller explicitly
+ * requested a specific prompt and needs to handle the error.
+ *
+ * @param connection - The MCP connection to use
+ * @param name - The prompt name to retrieve
+ * @param args - Optional key-value arguments to pass to the prompt
+ * @returns The prompt result with messages and metadata
+ * @throws {Error} If the prompt retrieval fails
+ */
+ static async getPrompt(
+ connection: MCPConnection,
+ name: string,
+ args?: Record<string, string>
+ ): Promise<GetPromptResult> {
+ try {
+ return await connection.client.getPrompt({ name, arguments: args });
+ } catch (error) {
+ console.error(`[MCPService][${connection.serverName}] Failed to get prompt:`, error);
+
+ throw error;
+ }
+ }
+
+ /**
+ * Execute a tool call on a connection.
+ * Supports abort signal for cancellable operations (e.g., when user stops generation).
+ * Formats the raw tool result into a string representation.
+ *
+ * @param connection - The MCP connection to execute against
+ * @param params - Tool name and arguments to execute
+ * @param signal - Optional AbortSignal for cancellation support
+ * @returns Formatted tool execution result with content string and error flag
+ * @throws {Error} If tool execution fails or is aborted
+ */
+ static async callTool(
+ connection: MCPConnection,
+ params: ToolCallParams,
+ signal?: AbortSignal
+ ): Promise<ToolExecutionResult> {
+ throwIfAborted(signal);
+
+ try {
+ const result = await connection.client.callTool(
+ { name: params.name, arguments: params.arguments },
+ undefined,
+ { signal }
+ );
+
+ return {
+ content: this.formatToolResult(result as ToolCallResult),
+ isError: (result as ToolCallResult).isError ?? false
+ };
+ } catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
+
+ // Let session-expired errors propagate unwrapped for reconnection handling
+ if (this.isSessionExpiredError(error)) {
+ throw error;
+ }
+
+ const message = error instanceof Error ? error.message : String(error);
+
+ throw new Error(
+ `Tool "${params.name}" execution failed on server "${connection.serverName}": ${message}`,
+ { cause: error instanceof Error ? error : undefined }
+ );
+ }
+ }
+
+ /**
+ * Format tool result content items to a single string.
+ * Handles text, image (base64 data URL), and embedded resource content types.
+ *
+ * @param result - Raw tool call result from MCP SDK
+ * @returns Concatenated string representation of all content items
+ */
+ private static formatToolResult(result: ToolCallResult): string {
+ const content = result.content;
+ if (!Array.isArray(content)) return '';
+
+ return content
+ .map((item) => this.formatSingleContent(item))
+ .filter(Boolean)
+ .join('\n');
+ }
+
+ private static formatSingleContent(content: ToolResultContentItem): string {
+ if (content.type === MCPContentType.TEXT && content.text) {
+ return content.text;
+ }
+
+ if (content.type === MCPContentType.IMAGE && content.data) {
+ return createBase64DataUrl(content.mimeType ?? DEFAULT_IMAGE_MIME_TYPE, content.data);
+ }
+
+ if (content.type === MCPContentType.RESOURCE && content.resource) {
+ const resource = content.resource;
+
+ if (resource.text) return resource.text;
+ if (resource.blob) return resource.blob;
+
+ return JSON.stringify(resource);
+ }
+
+ if (content.data && content.mimeType) {
+ return createBase64DataUrl(content.mimeType, content.data);
+ }
+
+ return JSON.stringify(content);
+ }
+
+ /**
+ *
+ *
+ * Completions Operations
+ *
+ *
+ */
+
+ /**
+ * Request completion suggestions from a server.
+ * Used for autocompleting prompt arguments or resource URI templates.
+ *
+ * @param connection - The MCP connection to use
+ * @param ref - Reference to the prompt or resource template
+ * @param argument - The argument being completed (name and current value)
+ * @returns Completion result with suggested values
+ */
+ static async complete(
+ connection: MCPConnection,
+ ref: { type: MCPRefType.PROMPT; name: string } | { type: MCPRefType.RESOURCE; uri: string },
+ argument: { name: string; value: string }
+ ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
+ try {
+ const result = await connection.client.complete({
+ ref,
+ argument
+ });
+
+ return result.completion;
+ } catch (error) {
+ console.error(`[MCPService] Failed to get completions:`, error);
+
+ return null;
+ }
+ }
+
+ /**
+ *
+ *
+ * Resources Operations
+ *
+ *
+ */
+
+ /**
+ * List resources from a connection.
+ * @param connection - The MCP connection to use
+ * @param cursor - Optional pagination cursor
+ * @returns Array of available resources and optional next cursor
+ */
+ static async listResources(
+ connection: MCPConnection,
+ cursor?: string
+ ): Promise<{ resources: MCPResource[]; nextCursor?: string }> {
+ try {
+ const result = await connection.client.listResources(cursor ? { cursor } : undefined);
+
+ return {
+ resources: (result.resources ?? []) as MCPResource[],
+ nextCursor: result.nextCursor
+ };
+ } catch (error) {
+ if (this.isSessionExpiredError(error)) {
+ throw error;
+ }
+
+ console.warn(`[MCPService][${connection.serverName}] Failed to list resources:`, error);
+
+ return { resources: [] };
+ }
+ }
+
+ /**
+ * List all resources from a connection (handles pagination automatically).
+ * @param connection - The MCP connection to use
+ * @returns Array of all available resources
+ */
+ static async listAllResources(connection: MCPConnection): Promise<MCPResource[]> {
+ const allResources: MCPResource[] = [];
+ let cursor: string | undefined;
+
+ do {
+ const result = await this.listResources(connection, cursor);
+ allResources.push(...result.resources);
+ cursor = result.nextCursor;
+ } while (cursor);
+
+ return allResources;
+ }
+
+ /**
+ * List resource templates from a connection.
+ * @param connection - The MCP connection to use
+ * @param cursor - Optional pagination cursor
+ * @returns Array of available resource templates and optional next cursor
+ */
+ static async listResourceTemplates(
+ connection: MCPConnection,
+ cursor?: string
+ ): Promise<{ resourceTemplates: MCPResourceTemplate[]; nextCursor?: string }> {
+ try {
+ const result = await connection.client.listResourceTemplates(cursor ? { cursor } : undefined);
+
+ return {
+ resourceTemplates: (result.resourceTemplates ?? []) as MCPResourceTemplate[],
+ nextCursor: result.nextCursor
+ };
+ } catch (error) {
+ if (this.isSessionExpiredError(error)) {
+ throw error;
+ }
+
+ console.warn(
+ `[MCPService][${connection.serverName}] Failed to list resource templates:`,
+ error
+ );
+
+ return { resourceTemplates: [] };
+ }
+ }
+
+ /**
+ * List all resource templates from a connection (handles pagination automatically).
+ * @param connection - The MCP connection to use
+ * @returns Array of all available resource templates
+ */
+ static async listAllResourceTemplates(connection: MCPConnection): Promise<MCPResourceTemplate[]> {
+ const allTemplates: MCPResourceTemplate[] = [];
+ let cursor: string | undefined;
+
+ do {
+ const result = await this.listResourceTemplates(connection, cursor);
+ allTemplates.push(...result.resourceTemplates);
+ cursor = result.nextCursor;
+ } while (cursor);
+
+ return allTemplates;
+ }
+
+ /**
+ * Read the contents of a resource.
+ * @param connection - The MCP connection to use
+ * @param uri - The URI of the resource to read
+ * @returns The resource contents
+ */
+ static async readResource(
+ connection: MCPConnection,
+ uri: string
+ ): Promise<MCPReadResourceResult> {
+ try {
+ const result = await connection.client.readResource({ uri });
+
+ return {
+ contents: (result.contents ?? []) as MCPResourceContent[],
+ _meta: result._meta
+ };
+ } catch (error) {
+ console.error(`[MCPService][${connection.serverName}] Failed to read resource:`, error);
+
+ throw error;
+ }
+ }
+
+ /**
+ * Subscribe to updates for a resource.
+ * The server will send notifications/resources/updated when the resource changes.
+ * @param connection - The MCP connection to use
+ * @param uri - The URI of the resource to subscribe to
+ */
+ static async subscribeResource(connection: MCPConnection, uri: string): Promise<void> {
+ try {
+ await connection.client.subscribeResource({ uri });
+
+ console.log(`[MCPService][${connection.serverName}] Subscribed to resource: ${uri}`);
+ } catch (error) {
+ console.error(
+ `[MCPService][${connection.serverName}] Failed to subscribe to resource:`,
+ error
+ );
+
+ throw error;
+ }
+ }
+
+ /**
+ * Unsubscribe from updates for a resource.
+ * @param connection - The MCP connection to use
+ * @param uri - The URI of the resource to unsubscribe from
+ */
+ static async unsubscribeResource(connection: MCPConnection, uri: string): Promise<void> {
+ try {
+ await connection.client.unsubscribeResource({ uri });
+
+ console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
+ } catch (error) {
+ console.error(
+ `[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`,
+ error
+ );
+
+ throw error;
+ }
+ }
+
+ /**
+ * Check if a connection supports resources.
+ * Per MCP spec: presence of the `resources` key (even as empty object `{}`) indicates support.
+ * Empty object means resources are supported but no sub-features (subscribe, listChanged).
+ *
+ * @param connection - The MCP connection to check
+ * @returns Whether the server declares the resources capability
+ */
+ static supportsResources(connection: MCPConnection): boolean {
+ // Per MCP spec: "Servers that support resources MUST declare the resources capability"
+ // The presence of the key indicates support, even if it's an empty object
+ return connection.serverCapabilities?.resources !== undefined;
+ }
+
+ /**
+ * Check if a connection supports resource subscriptions.
+ * @param connection - The MCP connection to check
+ * @returns Whether the server supports resource subscriptions
+ */
+ static supportsResourceSubscriptions(connection: MCPConnection): boolean {
+ return !!connection.serverCapabilities?.resources?.subscribe;
+ }
+}
import { describe, it, expect } from 'vitest';
import { ParameterSyncService } from './parameter-sync.service';
+import { ColorMode } from '$lib/enums';
describe('ParameterSyncService', () => {
describe('roundFloatingPoint', () => {
pasteLongTextToFileLen: 0,
pdfAsImage: true,
renderUserContentAsMarkdown: false,
- theme: 'dark'
+ theme: ColorMode.DARK
});
expect(result.pasteLongTextToFileLen).toBe(0);
--- /dev/null
+/**
+ * agenticStore - Reactive State Store for Agentic Loop Orchestration
+ *
+ * Manages multi-turn agentic loop with MCP tools:
+ * - LLM streaming with tool call detection
+ * - Tool execution via mcpStore
+ * - Session state management
+ * - Turn limit enforcement
+ *
+ * **Architecture & Relationships:**
+ * - **ChatService**: Stateless API layer (sendMessage, streaming)
+ * - **mcpStore**: MCP connection management and tool execution
+ * - **agenticStore** (this): Reactive state + business logic
+ *
+ * @see ChatService in services/chat.service.ts for API operations
+ * @see mcpStore in stores/mcp.svelte.ts for MCP operations
+ */
+
+import { SvelteMap } from 'svelte/reactivity';
+import { ChatService } from '$lib/services';
+import { config } from '$lib/stores/settings.svelte';
+import { mcpStore } from '$lib/stores/mcp.svelte';
+import { modelsStore } from '$lib/stores/models.svelte';
+import { isAbortError } from '$lib/utils';
+import {
+ DEFAULT_AGENTIC_CONFIG,
+ AGENTIC_TAGS,
+ NEWLINE_SEPARATOR,
+ TURN_LIMIT_MESSAGE,
+ LLM_ERROR_BLOCK_START,
+ LLM_ERROR_BLOCK_END
+} from '$lib/constants';
+import {
+ IMAGE_MIME_TO_EXTENSION,
+ DATA_URI_BASE64_REGEX,
+ MCP_ATTACHMENT_NAME_PREFIX,
+ DEFAULT_IMAGE_EXTENSION
+} from '$lib/constants';
+import {
+ AttachmentType,
+ ContentPartType,
+ MessageRole,
+ MimeTypePrefix,
+ ToolCallType
+} from '$lib/enums';
+import type {
+ AgenticFlowParams,
+ AgenticFlowResult,
+ AgenticSession,
+ AgenticConfig,
+ SettingsConfigType,
+ McpServerOverride,
+ MCPToolCall
+} from '$lib/types';
+import type {
+ AgenticMessage,
+ AgenticToolCallList,
+ AgenticFlowCallbacks,
+ AgenticFlowOptions
+} from '$lib/types/agentic';
+import type {
+ ApiChatCompletionToolCall,
+ ApiChatMessageData,
+ ApiChatMessageContentPart
+} from '$lib/types/api';
+import type {
+ ChatMessagePromptProgress,
+ ChatMessageTimings,
+ ChatMessageAgenticTimings,
+ ChatMessageToolCallTiming,
+ ChatMessageAgenticTurnStats
+} from '$lib/types/chat';
+import type {
+ DatabaseMessage,
+ DatabaseMessageExtra,
+ DatabaseMessageExtraImageFile
+} from '$lib/types/database';
+
+function createDefaultSession(): AgenticSession {
+ return {
+ isRunning: false,
+ currentTurn: 0,
+ totalToolCalls: 0,
+ lastError: null,
+ streamingToolCall: null
+ };
+}
+
+function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] {
+ return messages.map((message) => {
+ if (
+ message.role === MessageRole.ASSISTANT &&
+ message.tool_calls &&
+ message.tool_calls.length > 0
+ ) {
+ return {
+ role: MessageRole.ASSISTANT,
+ content: message.content,
+ tool_calls: message.tool_calls.map((call, index) => ({
+ id: call.id ?? `call_${index}`,
+ type: (call.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION,
+ function: { name: call.function?.name ?? '', arguments: call.function?.arguments ?? '' }
+ }))
+ } satisfies AgenticMessage;
+ }
+ if (message.role === MessageRole.TOOL && message.tool_call_id) {
+ return {
+ role: MessageRole.TOOL,
+ tool_call_id: message.tool_call_id,
+ content: typeof message.content === 'string' ? message.content : ''
+ } satisfies AgenticMessage;
+ }
+ return {
+ role: message.role as MessageRole.SYSTEM | MessageRole.USER,
+ content: message.content
+ } satisfies AgenticMessage;
+ });
+}
+
+class AgenticStore {
+ private _sessions = $state<Map<string, AgenticSession>>(new Map());
+
+ get isReady(): boolean {
+ return true;
+ }
+ get isAnyRunning(): boolean {
+ for (const session of this._sessions.values()) {
+ if (session.isRunning) return true;
+ }
+ return false;
+ }
+
+ getSession(conversationId: string): AgenticSession {
+ let session = this._sessions.get(conversationId);
+ if (!session) {
+ session = createDefaultSession();
+ this._sessions.set(conversationId, session);
+ }
+ return session;
+ }
+
+ private updateSession(conversationId: string, update: Partial<AgenticSession>): void {
+ const session = this.getSession(conversationId);
+ this._sessions.set(conversationId, { ...session, ...update });
+ }
+
+ clearSession(conversationId: string): void {
+ this._sessions.delete(conversationId);
+ }
+
+ getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> {
+ const active: Array<{ conversationId: string; session: AgenticSession }> = [];
+ for (const [conversationId, session] of this._sessions.entries()) {
+ if (session.isRunning) active.push({ conversationId, session });
+ }
+ return active;
+ }
+
+ isRunning(conversationId: string): boolean {
+ return this.getSession(conversationId).isRunning;
+ }
+
+ currentTurn(conversationId: string): number {
+ return this.getSession(conversationId).currentTurn;
+ }
+
+ totalToolCalls(conversationId: string): number {
+ return this.getSession(conversationId).totalToolCalls;
+ }
+
+ lastError(conversationId: string): Error | null {
+ return this.getSession(conversationId).lastError;
+ }
+
+ streamingToolCall(conversationId: string): { name: string; arguments: string } | null {
+ return this.getSession(conversationId).streamingToolCall;
+ }
+
+ clearError(conversationId: string): void {
+ this.updateSession(conversationId, { lastError: null });
+ }
+
+ getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
+ const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
+ const maxToolPreviewLines =
+ Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines;
+ return {
+ enabled: mcpStore.hasEnabledServers(perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
+ maxTurns,
+ maxToolPreviewLines
+ };
+ }
+
+ async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
+ const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
+ const {
+ onChunk,
+ onReasoningChunk,
+ onToolCallChunk,
+ onAttachments,
+ onModel,
+ onComplete,
+ onError,
+ onTimings,
+ onTurnComplete
+ } = callbacks;
+
+ const agenticConfig = this.getConfig(config(), perChatOverrides);
+ if (!agenticConfig.enabled) return { handled: false };
+
+ const initialized = await mcpStore.ensureInitialized(perChatOverrides);
+ if (!initialized) {
+ console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
+ return { handled: false };
+ }
+
+ const tools = mcpStore.getToolDefinitionsForLLM();
+ if (tools.length === 0) {
+ console.log('[AgenticStore] No tools available, falling back to standard chat');
+ return { handled: false };
+ }
+
+ console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`);
+
+ const normalizedMessages: ApiChatMessageData[] = messages
+ .map((msg) => {
+ if ('id' in msg && 'convId' in msg && 'timestamp' in msg)
+ return ChatService.convertDbMessageToApiChatMessageData(
+ msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
+ );
+ return msg as ApiChatMessageData;
+ })
+ .filter((msg) => {
+ if (msg.role === MessageRole.SYSTEM) {
+ const content = typeof msg.content === 'string' ? msg.content : '';
+ return content.trim().length > 0;
+ }
+ return true;
+ });
+
+ this.updateSession(conversationId, {
+ isRunning: true,
+ currentTurn: 0,
+ totalToolCalls: 0,
+ lastError: null
+ });
+ mcpStore.acquireConnection();
+
+ try {
+ await this.executeAgenticLoop({
+ conversationId,
+ messages: normalizedMessages,
+ options,
+ tools,
+ agenticConfig,
+ callbacks: {
+ onChunk,
+ onReasoningChunk,
+ onToolCallChunk,
+ onAttachments,
+ onModel,
+ onComplete,
+ onError,
+ onTimings,
+ onTurnComplete
+ },
+ signal
+ });
+ return { handled: true };
+ } catch (error) {
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
+ this.updateSession(conversationId, { lastError: normalizedError });
+ onError?.(normalizedError);
+ return { handled: true, error: normalizedError };
+ } finally {
+ this.updateSession(conversationId, { isRunning: false });
+ await mcpStore
+ .releaseConnection()
+ .catch((err: unknown) =>
+ console.warn('[AgenticStore] Failed to release MCP connection:', err)
+ );
+ }
+ }
+
+ private async executeAgenticLoop(params: {
+ conversationId: string;
+ messages: ApiChatMessageData[];
+ options: AgenticFlowOptions;
+ tools: ReturnType<typeof mcpStore.getToolDefinitionsForLLM>;
+ agenticConfig: AgenticConfig;
+ callbacks: AgenticFlowCallbacks;
+ signal?: AbortSignal;
+ }): Promise<void> {
+ const { conversationId, messages, options, tools, agenticConfig, callbacks, signal } = params;
+ const {
+ onChunk,
+ onReasoningChunk,
+ onToolCallChunk,
+ onAttachments,
+ onModel,
+ onComplete,
+ onTimings,
+ onTurnComplete
+ } = callbacks;
+
+ const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
+ const allToolCalls: ApiChatCompletionToolCall[] = [];
+ let capturedTimings: ChatMessageTimings | undefined;
+
+ const agenticTimings: ChatMessageAgenticTimings = {
+ turns: 0,
+ toolCallsCount: 0,
+ toolsMs: 0,
+ toolCalls: [],
+ perTurn: [],
+ llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 }
+ };
+ const maxTurns = agenticConfig.maxTurns;
+ const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
+
+ for (let turn = 0; turn < maxTurns; turn++) {
+ this.updateSession(conversationId, { currentTurn: turn + 1 });
+ agenticTimings.turns = turn + 1;
+
+ if (signal?.aborted) {
+ onComplete?.(
+ '',
+ undefined,
+ this.buildFinalTimings(capturedTimings, agenticTimings),
+ undefined
+ );
+ return;
+ }
+
+ let turnContent = '';
+ let turnToolCalls: ApiChatCompletionToolCall[] = [];
+ let lastStreamingToolCallName = '';
+ let lastStreamingToolCallArgsLength = 0;
+ const emittedToolCallStates = new SvelteMap<
+ number,
+ { emittedOnce: boolean; lastArgs: string }
+ >();
+ let turnTimings: ChatMessageTimings | undefined;
+
+ const turnStats: ChatMessageAgenticTurnStats = {
+ turn: turn + 1,
+ llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 },
+ toolCalls: [],
+ toolsMs: 0
+ };
+
+ try {
+ await ChatService.sendMessage(
+ sessionMessages as ApiChatMessageData[],
+ {
+ ...options,
+ stream: true,
+ tools: tools.length > 0 ? tools : undefined,
+ onChunk: (chunk: string) => {
+ turnContent += chunk;
+ onChunk?.(chunk);
+ },
+ onReasoningChunk,
+ onToolCallChunk: (serialized: string) => {
+ try {
+ turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
+ for (let i = 0; i < turnToolCalls.length; i++) {
+ const toolCall = turnToolCalls[i];
+ const toolName = toolCall.function?.name ?? '';
+ const toolArgs = toolCall.function?.arguments ?? '';
+ const state = emittedToolCallStates.get(i) || {
+ emittedOnce: false,
+ lastArgs: ''
+ };
+ if (!state.emittedOnce) {
+ const output = `\n\n${AGENTIC_TAGS.TOOL_CALL_START}\n${AGENTIC_TAGS.TOOL_NAME_PREFIX}${toolName}${AGENTIC_TAGS.TAG_SUFFIX}\n${AGENTIC_TAGS.TOOL_ARGS_START}\n${toolArgs}`;
+ onChunk?.(output);
+ state.emittedOnce = true;
+ state.lastArgs = toolArgs;
+ emittedToolCallStates.set(i, state);
+ } else if (toolArgs.length > state.lastArgs.length) {
+ onChunk?.(toolArgs.slice(state.lastArgs.length));
+ state.lastArgs = toolArgs;
+ emittedToolCallStates.set(i, state);
+ }
+ }
+ if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
+ const name = turnToolCalls[0].function.name || '';
+ const args = turnToolCalls[0].function.arguments || '';
+ const argsLengthBucket = Math.floor(args.length / 100);
+ if (
+ name !== lastStreamingToolCallName ||
+ argsLengthBucket !== lastStreamingToolCallArgsLength
+ ) {
+ lastStreamingToolCallName = name;
+ lastStreamingToolCallArgsLength = argsLengthBucket;
+ this.updateSession(conversationId, {
+ streamingToolCall: { name, arguments: args }
+ });
+ }
+ }
+ } catch {
+ /* Ignore parse errors during streaming */
+ }
+ },
+ onModel,
+ onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => {
+ onTimings?.(timings, progress);
+ if (timings) {
+ capturedTimings = timings;
+ turnTimings = timings;
+ }
+ },
+ onComplete: () => {
+ /* Completion handled after sendMessage resolves */
+ },
+ onError: (error: Error) => {
+ throw error;
+ }
+ },
+ undefined,
+ signal
+ );
+
+ this.updateSession(conversationId, { streamingToolCall: null });
+
+ if (turnTimings) {
+ agenticTimings.llm.predicted_n += turnTimings.predicted_n || 0;
+ agenticTimings.llm.predicted_ms += turnTimings.predicted_ms || 0;
+ agenticTimings.llm.prompt_n += turnTimings.prompt_n || 0;
+ agenticTimings.llm.prompt_ms += turnTimings.prompt_ms || 0;
+ turnStats.llm.predicted_n = turnTimings.predicted_n || 0;
+ turnStats.llm.predicted_ms = turnTimings.predicted_ms || 0;
+ turnStats.llm.prompt_n = turnTimings.prompt_n || 0;
+ turnStats.llm.prompt_ms = turnTimings.prompt_ms || 0;
+ }
+ } catch (error) {
+ if (signal?.aborted) {
+ onComplete?.(
+ '',
+ undefined,
+ this.buildFinalTimings(capturedTimings, agenticTimings),
+ undefined
+ );
+
+ return;
+ }
+ const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
+ onChunk?.(`${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`);
+ onComplete?.(
+ '',
+ undefined,
+ this.buildFinalTimings(capturedTimings, agenticTimings),
+ undefined
+ );
+
+ throw normalizedError;
+ }
+
+ if (turnToolCalls.length === 0) {
+ agenticTimings.perTurn!.push(turnStats);
+
+ onComplete?.(
+ '',
+ undefined,
+ this.buildFinalTimings(capturedTimings, agenticTimings),
+ undefined
+ );
+
+ return;
+ }
+
+ const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
+ if (normalizedCalls.length === 0) {
+ onComplete?.(
+ '',
+ undefined,
+ this.buildFinalTimings(capturedTimings, agenticTimings),
+ undefined
+ );
+ return;
+ }
+
+ for (const call of normalizedCalls) {
+ allToolCalls.push({
+ id: call.id,
+ type: call.type,
+ function: call.function ? { ...call.function } : undefined
+ });
+ }
+
+ this.updateSession(conversationId, { totalToolCalls: allToolCalls.length });
+ onToolCallChunk?.(JSON.stringify(allToolCalls));
+
+ sessionMessages.push({
+ role: MessageRole.ASSISTANT,
+ content: turnContent || undefined,
+ tool_calls: normalizedCalls
+ });
+
+ for (const toolCall of normalizedCalls) {
+ if (signal?.aborted) {
+ onComplete?.(
+ '',
+ undefined,
+ this.buildFinalTimings(capturedTimings, agenticTimings),
+ undefined
+ );
+
+ return;
+ }
+
+ const toolStartTime = performance.now();
+ const mcpCall: MCPToolCall = {
+ id: toolCall.id,
+ function: { name: toolCall.function.name, arguments: toolCall.function.arguments }
+ };
+
+ let result: string;
+ let toolSuccess = true;
+
+ try {
+ const executionResult = await mcpStore.executeTool(mcpCall, signal);
+ result = executionResult.content;
+ } catch (error) {
+ if (isAbortError(error)) {
+ onComplete?.(
+ '',
+ undefined,
+ this.buildFinalTimings(capturedTimings, agenticTimings),
+ undefined
+ );
+
+ return;
+ }
+ result = `Error: ${error instanceof Error ? error.message : String(error)}`;
+ toolSuccess = false;
+ }
+
+ const toolDurationMs = performance.now() - toolStartTime;
+ const toolTiming: ChatMessageToolCallTiming = {
+ name: toolCall.function.name,
+ duration_ms: Math.round(toolDurationMs),
+ success: toolSuccess
+ };
+
+ agenticTimings.toolCalls!.push(toolTiming);
+ agenticTimings.toolCallsCount++;
+ agenticTimings.toolsMs += Math.round(toolDurationMs);
+ turnStats.toolCalls.push(toolTiming);
+ turnStats.toolsMs += Math.round(toolDurationMs);
+
+ if (signal?.aborted) {
+ onComplete?.(
+ '',
+ undefined,
+ this.buildFinalTimings(capturedTimings, agenticTimings),
+ undefined
+ );
+
+ return;
+ }
+
+ const { cleanedResult, attachments } = this.extractBase64Attachments(result);
+ if (attachments.length > 0) onAttachments?.(attachments);
+
+ this.emitToolCallResult(cleanedResult, maxToolPreviewLines, onChunk);
+
+ const contentParts: ApiChatMessageContentPart[] = [
+ { type: ContentPartType.TEXT, text: cleanedResult }
+ ];
+ for (const attachment of attachments) {
+ if (attachment.type === AttachmentType.IMAGE) {
+ if (modelsStore.modelSupportsVision(options.model ?? '')) {
+ contentParts.push({
+ type: ContentPartType.IMAGE_URL,
+ image_url: { url: (attachment as DatabaseMessageExtraImageFile).base64Url }
+ });
+ } else {
+ console.info(
+ `[AgenticStore] Skipping image attachment (model "${options.model}" does not support vision)`
+ );
+ }
+ }
+ }
+
+ sessionMessages.push({
+ role: MessageRole.TOOL,
+ tool_call_id: toolCall.id,
+ content: contentParts.length === 1 ? cleanedResult : contentParts
+ });
+ }
+
+ if (turnStats.toolCalls.length > 0) {
+ agenticTimings.perTurn!.push(turnStats);
+
+ const intermediateTimings = this.buildFinalTimings(capturedTimings, agenticTimings);
+ if (intermediateTimings) onTurnComplete?.(intermediateTimings);
+ }
+ }
+
+ onChunk?.(TURN_LIMIT_MESSAGE);
+ onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined);
+ }
+
+ private buildFinalTimings(
+ capturedTimings: ChatMessageTimings | undefined,
+ agenticTimings: ChatMessageAgenticTimings
+ ): ChatMessageTimings | undefined {
+ if (agenticTimings.toolCallsCount === 0) return capturedTimings;
+ return {
+ predicted_n: capturedTimings?.predicted_n,
+ predicted_ms: capturedTimings?.predicted_ms,
+ prompt_n: capturedTimings?.prompt_n,
+ prompt_ms: capturedTimings?.prompt_ms,
+ cache_n: capturedTimings?.cache_n,
+ agentic: agenticTimings
+ };
+ }
+
+ private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
+ if (!toolCalls) return [];
+ return toolCalls.map((call, index) => ({
+ id: call?.id ?? `tool_${index}`,
+ type: (call?.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION,
+ function: { name: call?.function?.name ?? '', arguments: call?.function?.arguments ?? '' }
+ }));
+ }
+
+ private emitToolCallResult(
+ result: string,
+ maxLines: number,
+ emit?: (chunk: string) => void
+ ): void {
+ if (!emit) {
+ return;
+ }
+
+ let output = `${NEWLINE_SEPARATOR}${AGENTIC_TAGS.TOOL_ARGS_END}`;
+ const lines = result.split(NEWLINE_SEPARATOR);
+ const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
+
+ output += `${NEWLINE_SEPARATOR}${trimmedLines.join(NEWLINE_SEPARATOR)}${NEWLINE_SEPARATOR}${AGENTIC_TAGS.TOOL_CALL_END}${NEWLINE_SEPARATOR}`;
+ emit(output);
+ }
+
+ private extractBase64Attachments(result: string): {
+ cleanedResult: string;
+ attachments: DatabaseMessageExtra[];
+ } {
+ if (!result.trim()) {
+ return { cleanedResult: result, attachments: [] };
+ }
+
+ const lines = result.split(NEWLINE_SEPARATOR);
+ const attachments: DatabaseMessageExtra[] = [];
+ let attachmentIndex = 0;
+
+ const cleanedLines = lines.map((line) => {
+ const trimmedLine = line.trim();
+
+ const match = trimmedLine.match(DATA_URI_BASE64_REGEX);
+ if (!match) {
+ return line;
+ }
+
+ const mimeType = match[1].toLowerCase();
+ const base64Data = match[2];
+
+ if (!base64Data) {
+ return line;
+ }
+
+ attachmentIndex += 1;
+ const name = this.buildAttachmentName(mimeType, attachmentIndex);
+
+ if (mimeType.startsWith(MimeTypePrefix.IMAGE)) {
+ attachments.push({ type: AttachmentType.IMAGE, name, base64Url: trimmedLine });
+
+ return `[Attachment saved: ${name}]`;
+ }
+
+ return line;
+ });
+
+ return { cleanedResult: cleanedLines.join(NEWLINE_SEPARATOR), attachments };
+ }
+
+ private buildAttachmentName(mimeType: string, index: number): string {
+ const extension = IMAGE_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_IMAGE_EXTENSION;
+
+ return `${MCP_ATTACHMENT_NAME_PREFIX}-${Date.now()}-${index}.${extension}`;
+ }
+}
+
+export const agenticStore = new AgenticStore();
+
+export function agenticIsRunning(conversationId: string) {
+ return agenticStore.isRunning(conversationId);
+}
+
+export function agenticCurrentTurn(conversationId: string) {
+ return agenticStore.currentTurn(conversationId);
+}
+
+export function agenticTotalToolCalls(conversationId: string) {
+ return agenticStore.totalToolCalls(conversationId);
+}
+
+export function agenticLastError(conversationId: string) {
+ return agenticStore.lastError(conversationId);
+}
+
+export function agenticStreamingToolCall(conversationId: string) {
+ return agenticStore.streamingToolCall(conversationId);
+}
+
+export function agenticIsAnyRunning() {
+ return agenticStore.isAnyRunning;
+}
import { DatabaseService, ChatService } from '$lib/services';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
+import { agenticStore } from '$lib/stores/agentic.svelte';
+import { mcpStore } from '$lib/stores/mcp.svelte';
import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
import {
selectedModelName,
const activeConv = conversationsStore.activeConversation;
if (activeConv && this.isChatLoadingInternal(activeConv.id)) return;
+ // Consume MCP resource attachments - converts them to extras and clears the live store
+ const resourceExtras = mcpStore.consumeResourceAttachmentsAsExtras();
+ const allExtras = resourceExtras.length > 0 ? [...(extras || []), ...resourceExtras] : extras;
+
let isNewConversation = false;
if (!activeConv) {
await conversationsStore.createConversation();
content,
MessageType.TEXT,
parentIdForUserMessage ?? '-1',
- extras
+ allExtras
);
if (isNewConversation && content)
await conversationsStore.updateConversationName(currentConv.id, content.trim());
);
},
onModel: (modelName: string) => recordModel(modelName),
+ onTurnComplete: (intermediateTimings: ChatMessageTimings) => {
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings });
+ },
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
const tokensPerSecond =
timings?.predicted_ms && timings?.predicted_n
if (onError) onError(error);
}
};
+ const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
+
+ const agenticConfig = agenticStore.getConfig(config(), perChatOverrides);
+ if (agenticConfig.enabled) {
+ const agenticResult = await agenticStore.runAgenticFlow({
+ conversationId: assistantMessage.convId,
+ messages: allMessages,
+ options: { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}) },
+ callbacks: streamCallbacks,
+ signal: abortController.signal,
+ perChatOverrides
+ });
+ if (agenticResult.handled) return;
+ }
const completionOptions = {
...this.getApiOptions(),
/**
* conversationsStore - Reactive State Store for Conversations
*
- * Manages conversation lifecycle, persistence, navigation.
+ * Manages conversation lifecycle, persistence, navigation, and MCP server overrides.
*
* **Architecture & Relationships:**
* - **DatabaseService**: Stateless IndexedDB layer
* **Key Responsibilities:**
* - Conversation CRUD (create, load, delete)
* - Message management and tree navigation
+ * - MCP server per-chat overrides
* - Import/Export functionality
* - Title management with confirmation
*
import { DatabaseService } from '$lib/services/database.service';
import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
+import type { McpServerOverride } from '$lib/types/database';
import { MessageRole } from '$lib/enums';
class ConversationsStore {
/** Whether the store has been initialized */
isInitialized = $state(false);
+ /** Pending MCP server overrides for new conversations (before first message) */
+ pendingMcpServerOverrides = $state<McpServerOverride[]>([]);
+
/** Callback for title update confirmation dialog */
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
+ /**
+ * Callback for updating message content in chatStore.
+ * Registered by chatStore to enable cross-store updates without circular dependency.
+ */
+ private messageUpdateCallback:
+ | ((messageId: string, updates: Partial<DatabaseMessage>) => void)
+ | null = null;
+
/**
*
*
return this.init();
}
+ /**
+ * Register a callback for message updates from other stores.
+ * Called by chatStore during initialization.
+ */
+ registerMessageUpdateCallback(
+ callback: (messageId: string, updates: Partial<DatabaseMessage>) => void
+ ): void {
+ this.messageUpdateCallback = callback;
+ }
+
/**
*
*
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
const conversation = await DatabaseService.createConversation(conversationName);
+ if (this.pendingMcpServerOverrides.length > 0) {
+ // Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB)
+ const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({
+ serverId: o.serverId,
+ enabled: o.enabled
+ }));
+ conversation.mcpServerOverrides = plainOverrides;
+ await DatabaseService.updateConversation(conversation.id, {
+ mcpServerOverrides: plainOverrides
+ });
+ this.pendingMcpServerOverrides = [];
+ }
+
this.conversations = [conversation, ...this.conversations];
this.activeConversation = conversation;
this.activeMessages = [];
return false;
}
+ this.pendingMcpServerOverrides = [];
this.activeConversation = conversation;
if (conversation.currNode) {
}
}
+ /**
+ *
+ *
+ * MCP Server Overrides
+ *
+ *
+ */
+
+ /**
+ * Gets MCP server override for a specific server in the active conversation.
+ * Falls back to pending overrides if no active conversation exists.
+ * @param serverId - The server ID to check
+ * @returns The override if set, undefined if using global setting
+ */
+ getMcpServerOverride(serverId: string): McpServerOverride | undefined {
+ if (this.activeConversation) {
+ return this.activeConversation.mcpServerOverrides?.find(
+ (o: McpServerOverride) => o.serverId === serverId
+ );
+ }
+ return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
+ }
+
+ /**
+ * Get all MCP server overrides for the current conversation.
+ * Returns pending overrides if no active conversation.
+ */
+ getAllMcpServerOverrides(): McpServerOverride[] {
+ if (this.activeConversation?.mcpServerOverrides) {
+ return this.activeConversation.mcpServerOverrides;
+ }
+ return this.pendingMcpServerOverrides;
+ }
+
+ /**
+ * Checks if an MCP server is enabled for the active conversation.
+ * @param serverId - The server ID to check
+ * @returns True if server is enabled for this conversation
+ */
+ isMcpServerEnabledForChat(serverId: string): boolean {
+ const override = this.getMcpServerOverride(serverId);
+ return override?.enabled ?? false;
+ }
+
+ /**
+ * Sets or removes MCP server override for the active conversation.
+ * If no conversation exists, stores as pending override.
+ * @param serverId - The server ID to override
+ * @param enabled - The enabled state, or undefined to remove override
+ */
+ async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
+ if (!this.activeConversation) {
+ this.setPendingMcpServerOverride(serverId, enabled);
+ return;
+ }
+
+ // Clone to plain objects to avoid Proxy serialization issues with IndexedDB
+ const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map(
+ (o: McpServerOverride) => ({
+ serverId: o.serverId,
+ enabled: o.enabled
+ })
+ );
+ let newOverrides: McpServerOverride[];
+
+ if (enabled === undefined) {
+ newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
+ } else {
+ const existingIndex = currentOverrides.findIndex(
+ (o: McpServerOverride) => o.serverId === serverId
+ );
+ if (existingIndex >= 0) {
+ newOverrides = [...currentOverrides];
+ newOverrides[existingIndex] = { serverId, enabled };
+ } else {
+ newOverrides = [...currentOverrides, { serverId, enabled }];
+ }
+ }
+
+ await DatabaseService.updateConversation(this.activeConversation.id, {
+ mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
+ });
+
+ this.activeConversation = {
+ ...this.activeConversation,
+ mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
+ };
+
+ const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
+ if (convIndex !== -1) {
+ this.conversations[convIndex].mcpServerOverrides =
+ newOverrides.length > 0 ? newOverrides : undefined;
+ this.conversations = [...this.conversations];
+ }
+ }
+
+ /**
+ * Sets or removes a pending MCP server override (for new conversations).
+ */
+ private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
+ if (enabled === undefined) {
+ this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter(
+ (o) => o.serverId !== serverId
+ );
+ } else {
+ const existingIndex = this.pendingMcpServerOverrides.findIndex(
+ (o) => o.serverId === serverId
+ );
+ if (existingIndex >= 0) {
+ const newOverrides = [...this.pendingMcpServerOverrides];
+ newOverrides[existingIndex] = { serverId, enabled };
+ this.pendingMcpServerOverrides = newOverrides;
+ } else {
+ this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }];
+ }
+ }
+ }
+
+ /**
+ * Toggles MCP server enabled state for the active conversation.
+ * @param serverId - The server ID to toggle
+ */
+ async toggleMcpServerForChat(serverId: string): Promise<void> {
+ const currentEnabled = this.isMcpServerEnabledForChat(serverId);
+ await this.setMcpServerOverride(serverId, !currentEnabled);
+ }
+
+ /**
+ * Removes MCP server override for the active conversation.
+ * @param serverId - The server ID to remove override for
+ */
+ async removeMcpServerOverride(serverId: string): Promise<void> {
+ await this.setMcpServerOverride(serverId, undefined);
+ }
+
+ /**
+ * Clears all pending MCP server overrides.
+ */
+ clearPendingMcpServerOverrides(): void {
+ this.pendingMcpServerOverrides = [];
+ }
+
/**
*
*
--- /dev/null
+/**
+ * mcpResourceStore - Reactive State Store for MCP Resources
+ *
+ * Manages MCP protocol resources:
+ * - Resource discovery and listing per server
+ * - Resource content caching
+ * - Resource subscriptions
+ * - Resource attachments for chat context
+ *
+ * @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18/server/resources
+ */
+
+import { SvelteMap } from 'svelte/reactivity';
+import { AttachmentType } from '$lib/enums';
+import {
+ MCP_RESOURCE_ATTACHMENT_ID_PREFIX,
+ MCP_RESOURCE_CACHE_MAX_ENTRIES,
+ MCP_RESOURCE_CACHE_TTL_MS,
+ NEWLINE_SEPARATOR,
+ RESOURCE_UNKNOWN_TYPE,
+ BINARY_CONTENT_LABEL
+} from '$lib/constants';
+import { normalizeResourceUri } from '$lib/utils';
+import type {
+ MCPResource,
+ MCPResourceTemplate,
+ MCPResourceContent,
+ MCPResourceInfo,
+ MCPResourceTemplateInfo,
+ MCPCachedResource,
+ MCPResourceAttachment,
+ MCPResourceSubscription,
+ MCPServerResources,
+ DatabaseMessageExtraMcpResource
+} from '$lib/types';
+
+function generateAttachmentId(): string {
+ return `${MCP_RESOURCE_ATTACHMENT_ID_PREFIX}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+}
+
+class MCPResourceStore {
+ private _serverResources = $state<SvelteMap<string, MCPServerResources>>(new SvelteMap());
+ private _cachedResources = $state<SvelteMap<string, MCPCachedResource>>(new SvelteMap());
+ private _subscriptions = $state<SvelteMap<string, MCPResourceSubscription>>(new SvelteMap());
+ private _attachments = $state<MCPResourceAttachment[]>([]);
+ private _isLoading = $state(false);
+
+ get serverResources(): Map<string, MCPServerResources> {
+ return this._serverResources;
+ }
+
+ get cachedResources(): Map<string, MCPCachedResource> {
+ return this._cachedResources;
+ }
+
+ get subscriptions(): Map<string, MCPResourceSubscription> {
+ return this._subscriptions;
+ }
+
+ get attachments(): MCPResourceAttachment[] {
+ return this._attachments;
+ }
+
+ get isLoading(): boolean {
+ return this._isLoading;
+ }
+
+ get totalResourceCount(): number {
+ let count = 0;
+ for (const serverRes of this._serverResources.values()) {
+ count += serverRes.resources.length;
+ }
+
+ return count;
+ }
+
+ get totalTemplateCount(): number {
+ let count = 0;
+ for (const serverRes of this._serverResources.values()) {
+ count += serverRes.templates.length;
+ }
+
+ return count;
+ }
+
+ get attachmentCount(): number {
+ return this._attachments.length;
+ }
+
+ get hasAttachments(): boolean {
+ return this._attachments.length > 0;
+ }
+
+ /**
+ *
+ *
+ * Server Resources Management
+ *
+ *
+ */
+
+ /**
+ * Set resources for a server (called after listResources)
+ */
+ setServerResources(
+ serverName: string,
+ resources: MCPResource[],
+ templates: MCPResourceTemplate[]
+ ): void {
+ this._serverResources.set(serverName, {
+ serverName,
+ resources,
+ templates,
+ lastFetched: new Date(),
+ loading: false,
+ error: undefined
+ });
+ console.log(
+ `[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates`
+ );
+ }
+
+ /**
+ * Set loading state for a server's resources
+ */
+ setServerLoading(serverName: string, loading: boolean): void {
+ const existing = this._serverResources.get(serverName);
+ if (existing) {
+ this._serverResources.set(serverName, { ...existing, loading });
+ } else {
+ this._serverResources.set(serverName, {
+ serverName,
+ resources: [],
+ templates: [],
+ loading,
+ error: undefined
+ });
+ }
+ }
+
+ /**
+ * Set error state for a server's resources
+ */
+ setServerError(serverName: string, error: string): void {
+ const existing = this._serverResources.get(serverName);
+
+ if (existing) {
+ this._serverResources.set(serverName, { ...existing, loading: false, error });
+ } else {
+ this._serverResources.set(serverName, {
+ serverName,
+ resources: [],
+ templates: [],
+ loading: false,
+ error
+ });
+ }
+ }
+
+ /**
+ * Get resources for a specific server
+ */
+ getServerResources(serverName: string): MCPServerResources | undefined {
+ return this._serverResources.get(serverName);
+ }
+
+ /**
+ * Get all resources as MCPResourceInfo array (flattened with server names)
+ */
+ getAllResourceInfos(): MCPResourceInfo[] {
+ const result: MCPResourceInfo[] = [];
+
+ for (const [serverName, serverRes] of this._serverResources) {
+ for (const resource of serverRes.resources) {
+ result.push({
+ uri: resource.uri,
+ name: resource.name,
+ title: resource.title,
+ description: resource.description,
+ mimeType: resource.mimeType,
+ serverName,
+ annotations: resource.annotations,
+ icons: resource.icons
+ });
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Get all templates as MCPResourceTemplateInfo array (flattened with server names)
+ */
+ getAllTemplateInfos(): MCPResourceTemplateInfo[] {
+ const result: MCPResourceTemplateInfo[] = [];
+
+ for (const [serverName, serverRes] of this._serverResources) {
+ for (const template of serverRes.templates) {
+ result.push({
+ uriTemplate: template.uriTemplate,
+ name: template.name,
+ title: template.title,
+ description: template.description,
+ mimeType: template.mimeType,
+ serverName,
+ annotations: template.annotations,
+ icons: template.icons
+ });
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Clear resources for a server (e.g., when disconnected)
+ */
+ clearServerResources(serverName: string): void {
+ this._serverResources.delete(serverName);
+
+ // Also clear cached content for this server's resources
+ for (const [uri, cached] of this._cachedResources) {
+ if (cached.resource.serverName === serverName) {
+ this._cachedResources.delete(uri);
+ }
+ }
+
+ // Clear subscriptions for this server
+ for (const [uri, sub] of this._subscriptions) {
+ if (sub.serverName === serverName) {
+ this._subscriptions.delete(uri);
+ }
+ }
+
+ console.log(`[MCPResources][${serverName}] Cleared all resources`);
+ }
+
+ /**
+ *
+ *
+ * Resource Content Caching
+ *
+ *
+ */
+
+ /**
+ * Cache resource content after reading
+ */
+ cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void {
+ // Enforce cache size limit
+ if (this._cachedResources.size >= MCP_RESOURCE_CACHE_MAX_ENTRIES) {
+ // Remove oldest entry
+ const oldestKey = this._cachedResources.keys().next().value;
+
+ if (oldestKey) {
+ this._cachedResources.delete(oldestKey);
+ }
+ }
+
+ this._cachedResources.set(resource.uri, {
+ resource,
+ content,
+ fetchedAt: new Date(),
+ subscribed: this._subscriptions.has(resource.uri)
+ });
+ console.log(`[MCPResources] Cached content for: ${resource.uri}`);
+ }
+
+ /**
+ * Get cached content for a resource
+ */
+ getCachedContent(uri: string): MCPCachedResource | undefined {
+ const cached = this._cachedResources.get(uri);
+ if (!cached) return undefined;
+
+ // Check if cache is still valid
+ const age = Date.now() - cached.fetchedAt.getTime();
+
+ if (age > MCP_RESOURCE_CACHE_TTL_MS && !cached.subscribed) {
+ // Cache expired and not subscribed, remove it
+ this._cachedResources.delete(uri);
+
+ return undefined;
+ }
+
+ return cached;
+ }
+
+ /**
+ * Invalidate cached content for a resource (e.g., on update notification)
+ */
+ invalidateCache(uri: string): void {
+ this._cachedResources.delete(uri);
+ console.log(`[MCPResources] Invalidated cache for: ${uri}`);
+ }
+
+ /**
+ * Clear all cached content
+ */
+ clearCache(): void {
+ this._cachedResources.clear();
+ console.log(`[MCPResources] Cleared all cached content`);
+ }
+
+ /**
+ *
+ *
+ * Subscriptions
+ *
+ *
+ */
+
+ /**
+ * Register a subscription for a resource
+ */
+ addSubscription(uri: string, serverName: string): void {
+ this._subscriptions.set(uri, {
+ uri,
+ serverName,
+ subscribedAt: new Date()
+ });
+
+ // Update cached resource if exists
+ const cached = this._cachedResources.get(uri);
+ if (cached) {
+ this._cachedResources.set(uri, { ...cached, subscribed: true });
+ }
+
+ console.log(`[MCPResources] Added subscription: ${uri}`);
+ }
+
+ /**
+ * Remove a subscription for a resource
+ */
+ removeSubscription(uri: string): void {
+ this._subscriptions.delete(uri);
+
+ // Update cached resource if exists
+ const cached = this._cachedResources.get(uri);
+ if (cached) {
+ this._cachedResources.set(uri, { ...cached, subscribed: false });
+ }
+
+ console.log(`[MCPResources] Removed subscription: ${uri}`);
+ }
+
+ /**
+ * Check if a resource is subscribed
+ */
+ isSubscribed(uri: string): boolean {
+ return this._subscriptions.has(uri);
+ }
+
+ /**
+ * Handle resource update notification
+ */
+ handleResourceUpdate(uri: string): void {
+ // Invalidate cache so next read gets fresh content
+ this.invalidateCache(uri);
+
+ // Update subscription last update time
+ const sub = this._subscriptions.get(uri);
+ if (sub) {
+ this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() });
+ }
+
+ console.log(`[MCPResources] Resource updated: ${uri}`);
+ }
+
+ /**
+ * Handle resources list changed notification
+ */
+ handleResourcesListChanged(serverName: string): void {
+ // Mark server resources as needing refresh
+ const existing = this._serverResources.get(serverName);
+ if (existing) {
+ this._serverResources.set(serverName, {
+ ...existing,
+ lastFetched: undefined // Mark as stale
+ });
+ }
+ console.log(`[MCPResources][${serverName}] Resources list changed, needs refresh`);
+ }
+
+ /**
+ *
+ *
+ * Attachments (for chat context)
+ *
+ *
+ */
+
+ /**
+ * Add a resource attachment to the current chat context
+ */
+ addAttachment(resource: MCPResourceInfo): MCPResourceAttachment {
+ const attachment: MCPResourceAttachment = {
+ id: generateAttachmentId(),
+ resource,
+ loading: true
+ };
+
+ this._attachments = [...this._attachments, attachment];
+ console.log(`[MCPResources] Added attachment: ${resource.uri}`);
+
+ return attachment;
+ }
+
+ /**
+ * Update attachment with fetched content
+ */
+ updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void {
+ this._attachments = this._attachments.map((att) =>
+ att.id === attachmentId ? { ...att, content, loading: false, error: undefined } : att
+ );
+ }
+
+ /**
+ * Update attachment with error
+ */
+ updateAttachmentError(attachmentId: string, error: string): void {
+ this._attachments = this._attachments.map((att) =>
+ att.id === attachmentId ? { ...att, loading: false, error } : att
+ );
+ }
+
+ /**
+ * Remove an attachment
+ */
+ removeAttachment(attachmentId: string): void {
+ this._attachments = this._attachments.filter((att) => att.id !== attachmentId);
+ console.log(`[MCPResources] Removed attachment: ${attachmentId}`);
+ }
+
+ /**
+ * Clear all attachments
+ */
+ clearAttachments(): void {
+ this._attachments = [];
+ console.log(`[MCPResources] Cleared all attachments`);
+ }
+
+ /**
+ * Get attachment by ID
+ */
+ getAttachment(attachmentId: string): MCPResourceAttachment | undefined {
+ return this._attachments.find((att) => att.id === attachmentId);
+ }
+
+ /**
+ * Check if a resource is already attached
+ */
+ isAttached(uri: string): boolean {
+ const normalizedUri = normalizeResourceUri(uri);
+
+ return this._attachments.some(
+ (att) => att.resource.uri === uri || normalizeResourceUri(att.resource.uri) === normalizedUri
+ );
+ }
+
+ /**
+ *
+ *
+ * Utility Methods
+ *
+ *
+ */
+
+ /**
+ * Set global loading state
+ */
+ setLoading(loading: boolean): void {
+ this._isLoading = loading;
+ }
+
+ /**
+ * Find resource info by URI across all servers
+ */
+ findResourceByUri(uri: string): MCPResourceInfo | undefined {
+ const normalizedUri = normalizeResourceUri(uri);
+
+ for (const [serverName, serverRes] of this._serverResources) {
+ const resource =
+ serverRes.resources.find((r) => r.uri === uri) ??
+ serverRes.resources.find((r) => normalizeResourceUri(r.uri) === normalizedUri);
+
+ if (resource) {
+ return {
+ uri: resource.uri,
+ name: resource.name,
+ title: resource.title,
+ description: resource.description,
+ mimeType: resource.mimeType,
+ serverName,
+ annotations: resource.annotations,
+ icons: resource.icons
+ };
+ }
+ }
+
+ return undefined;
+ }
+
+ /**
+ * Find server name for a resource URI
+ */
+ findServerForUri(uri: string): string | undefined {
+ for (const [serverName, serverRes] of this._serverResources) {
+ if (serverRes.resources.some((r) => r.uri === uri)) {
+ return serverName;
+ }
+ }
+
+ return undefined;
+ }
+
+ /**
+ * Clear all state (e.g., on full reset)
+ */
+ clear(): void {
+ this._serverResources.clear();
+ this._cachedResources.clear();
+ this._subscriptions.clear();
+ this._attachments = [];
+ this._isLoading = false;
+ console.log(`[MCPResources] Cleared all state`);
+ }
+
+ /**
+ * Get resource content as text for chat context
+ * Formats content for inclusion in LLM prompts
+ */
+ formatAttachmentsForContext(): string {
+ if (this._attachments.length === 0) return '';
+
+ const parts: string[] = [];
+
+ for (const attachment of this._attachments) {
+ if (attachment.error) continue;
+ if (!attachment.content || attachment.content.length === 0) continue;
+
+ const resourceName = attachment.resource.title || attachment.resource.name;
+ const serverName = attachment.resource.serverName;
+
+ for (const content of attachment.content) {
+ if ('text' in content && content.text) {
+ parts.push(`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n${content.text}`);
+ } else if ('blob' in content && content.blob) {
+ // For binary content, just note it exists
+ parts.push(
+ `\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]`
+ );
+ }
+ }
+ }
+
+ return parts.join('');
+ }
+
+ /**
+ * Convert current resource attachments to DatabaseMessageExtra[] for persisting with a message.
+ * Each attachment becomes a DatabaseMessageExtraMcpResource stored on the user message.
+ */
+ toMessageExtras(): DatabaseMessageExtraMcpResource[] {
+ const extras: DatabaseMessageExtraMcpResource[] = [];
+
+ for (const attachment of this._attachments) {
+ if (attachment.error) continue;
+ if (!attachment.content || attachment.content.length === 0) continue;
+
+ const resourceName = attachment.resource.title || attachment.resource.name;
+ const contentParts: string[] = [];
+
+ for (const content of attachment.content) {
+ if ('text' in content && content.text) {
+ contentParts.push(content.text);
+ } else if ('blob' in content && content.blob) {
+ contentParts.push(
+ `[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]`
+ );
+ }
+ }
+
+ if (contentParts.length > 0) {
+ extras.push({
+ type: AttachmentType.MCP_RESOURCE,
+ name: resourceName,
+ uri: attachment.resource.uri,
+ serverName: attachment.resource.serverName,
+ content: contentParts.join(NEWLINE_SEPARATOR),
+ mimeType: attachment.resource.mimeType
+ });
+ }
+ }
+
+ return extras;
+ }
+}
+
+export const mcpResourceStore = new MCPResourceStore();
+
+// Export convenience functions
+export const mcpResources = () => mcpResourceStore.serverResources;
+export const mcpResourceAttachments = () => mcpResourceStore.attachments;
+export const mcpResourceAttachmentCount = () => mcpResourceStore.attachmentCount;
+export const mcpHasResourceAttachments = () => mcpResourceStore.hasAttachments;
+export const mcpTotalResourceCount = () => mcpResourceStore.totalResourceCount;
+export const mcpResourcesLoading = () => mcpResourceStore.isLoading;
--- /dev/null
+/**
+ * mcpStore - Reactive State Store for MCP Operations
+ *
+ * Implements the "Host" role in MCP architecture, coordinating multiple server
+ * connections and providing a unified interface for tool operations.
+ *
+ * **Architecture & Relationships:**
+ * - **MCPService**: Stateless protocol layer (transport, connect, callTool)
+ * - **mcpStore** (this): Reactive state + business logic
+ *
+ * **Key Responsibilities:**
+ * - Lifecycle management (initialize, shutdown)
+ * - Multi-server coordination
+ * - Tool name conflict detection and resolution
+ * - OpenAI-compatible tool definition generation
+ * - Automatic tool-to-server routing
+ * - Health checks
+ *
+ * @see MCPService in services/mcp.service.ts for protocol operations
+ */
+
+import { browser } from '$app/environment';
+import { MCPService } from '$lib/services/mcp.service';
+import { config, settingsStore } from '$lib/stores/settings.svelte';
+import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
+import { mode } from 'mode-watcher';
+import {
+ getProxiedUrlString,
+ parseMcpServerSettings,
+ detectMcpTransportFromUrl,
+ getFaviconUrl,
+ uuid
+} from '$lib/utils';
+import {
+ MCPConnectionPhase,
+ MCPLogLevel,
+ HealthCheckStatus,
+ MCPRefType,
+ ColorMode,
+ UrlProtocol,
+ JsonSchemaType,
+ ToolCallType
+} from '$lib/enums';
+import {
+ DEFAULT_CACHE_TTL_MS,
+ DEFAULT_MCP_CONFIG,
+ EXPECTED_THEMED_ICON_PAIR_COUNT,
+ MCP_ALLOWED_ICON_MIME_TYPES,
+ MCP_SERVER_ID_PREFIX,
+ MCP_RECONNECT_INITIAL_DELAY,
+ MCP_RECONNECT_BACKOFF_MULTIPLIER,
+ MCP_RECONNECT_MAX_DELAY,
+ MCP_RECONNECT_ATTEMPT_TIMEOUT_MS
+} from '$lib/constants';
+import type {
+ MCPToolCall,
+ OpenAIToolDefinition,
+ ServerStatus,
+ ToolExecutionResult,
+ MCPClientConfig,
+ MCPConnection,
+ HealthCheckParams,
+ ServerCapabilities,
+ ClientCapabilities,
+ MCPCapabilitiesInfo,
+ MCPConnectionLog,
+ MCPPromptInfo,
+ GetPromptResult,
+ Tool,
+ HealthCheckState,
+ MCPServerSettingsEntry,
+ MCPServerConfig,
+ MCPResourceIcon,
+ MCPResourceAttachment,
+ MCPResourceContent
+} from '$lib/types';
+import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
+import type { DatabaseMessageExtraMcpResource, McpServerOverride } from '$lib/types/database';
+import type { SettingsConfigType } from '$lib/types/settings';
+
+export function buildMcpClientConfig(
+ cfg: SettingsConfigType,
+ perChatOverrides?: McpServerOverride[]
+): MCPClientConfig | undefined {
+ return buildMcpClientConfigInternal(cfg, perChatOverrides);
+}
+
+/**
+ * Internal helper to build MCP client config.
+ * Kept as standalone function for external use and tests.
+ */
+export function buildMcpClientConfigInternal(
+ cfg: SettingsConfigType,
+ perChatOverrides?: McpServerOverride[]
+): MCPClientConfig | undefined {
+ const rawServers = parseServerSettings(cfg.mcpServers);
+ if (!rawServers.length) {
+ return undefined;
+ }
+
+ const servers: Record<string, MCPServerConfig> = {};
+
+ for (const [index, entry] of rawServers.entries()) {
+ if (!checkServerEnabled(entry, perChatOverrides)) continue;
+ const normalized = buildServerConfig(entry);
+ if (normalized) servers[generateMcpServerId(entry.id, index)] = normalized;
+ }
+
+ if (Object.keys(servers).length === 0) {
+ return undefined;
+ }
+
+ return {
+ protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
+ capabilities: DEFAULT_MCP_CONFIG.capabilities,
+ clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
+ requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
+ servers
+ };
+}
+
+/**
+ * Generates a unique server ID from an optional ID string or index.
+ * @deprecated Use MCPStore.#generateServerId instead
+ */
+function generateMcpServerId(id: unknown, index: number): string {
+ if (typeof id === 'string' && id.trim()) {
+ return id.trim();
+ }
+
+ return `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
+}
+
+/**
+ * Parses raw server settings from config into MCPServerSettingsEntry array.
+ * @deprecated Use MCPStore.#parseServerSettings instead
+ */
+function parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
+ if (!rawServers) {
+ return [];
+ }
+
+ let parsed: unknown;
+ if (typeof rawServers === 'string') {
+ const trimmed = rawServers.trim();
+ if (!trimmed) {
+ return [];
+ }
+
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch (error) {
+ console.warn('[MCP] Failed to parse mcpServers JSON:', error);
+
+ return [];
+ }
+ } else {
+ parsed = rawServers;
+ }
+ if (!Array.isArray(parsed)) {
+ return [];
+ }
+
+ return parsed.map((entry, index) => {
+ const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
+ const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
+
+ return {
+ id: generateMcpServerId((entry as { id?: unknown })?.id, index),
+ enabled: Boolean((entry as { enabled?: unknown })?.enabled),
+ url,
+ name: (entry as { name?: string })?.name,
+ requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
+ headers: headers || undefined,
+ useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
+ } satisfies MCPServerSettingsEntry;
+ });
+}
+
+/**
+ * Builds server configuration from a settings entry.
+ * @deprecated Use MCPStore.#buildServerConfig instead
+ */
+function buildServerConfig(
+ entry: MCPServerSettingsEntry,
+ connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
+): MCPServerConfig | undefined {
+ if (!entry?.url) {
+ return undefined;
+ }
+
+ let headers: Record<string, string> | undefined;
+ if (entry.headers) {
+ try {
+ const parsed = JSON.parse(entry.headers);
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
+ headers = parsed as Record<string, string>;
+ } catch {
+ console.warn('[MCP] Failed to parse custom headers JSON:', entry.headers);
+ }
+ }
+
+ return {
+ url: entry.url,
+ transport: detectMcpTransportFromUrl(entry.url),
+ handshakeTimeoutMs: connectionTimeoutMs,
+ requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
+ headers,
+ useProxy: entry.useProxy
+ };
+}
+
+/**
+ * Checks if a server is enabled, considering per-chat overrides.
+ * @deprecated Use MCPStore.#checkServerEnabled instead
+ */
+function checkServerEnabled(
+ server: MCPServerSettingsEntry,
+ perChatOverrides?: McpServerOverride[]
+): boolean {
+ if (!server.enabled) {
+ return false;
+ }
+
+ if (perChatOverrides) {
+ const override = perChatOverrides.find((o) => o.serverId === server.id);
+
+ return override?.enabled ?? false;
+ }
+
+ return false;
+}
+
+class MCPStore {
+ private _isInitializing = $state(false);
+ private _error = $state<string | null>(null);
+ private _toolCount = $state(0);
+ private _connectedServers = $state<string[]>([]);
+ private _healthChecks = $state<Record<string, HealthCheckState>>({});
+
+ private connections = new Map<string, MCPConnection>();
+ private toolsIndex = new Map<string, string>();
+ private serverConfigs = new Map<string, MCPServerConfig>(); // Store configs for reconnection
+ private reconnectingServers = new Set<string>(); // Guard against concurrent reconnections
+ private configSignature: string | null = null;
+ private initPromise: Promise<boolean> | null = null;
+ private activeFlowCount = 0;
+
+ /**
+ * Generates a unique server ID from an optional ID string or index.
+ */
+ #generateServerId(id: unknown, index: number): string {
+ if (typeof id === 'string' && id.trim()) {
+ return id.trim();
+ }
+
+ return `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
+ }
+
+ /**
+ * Parses raw server settings from config into MCPServerSettingsEntry array.
+ */
+ #parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
+ if (!rawServers) {
+ return [];
+ }
+
+ let parsed: unknown;
+ if (typeof rawServers === 'string') {
+ const trimmed = rawServers.trim();
+ if (!trimmed) {
+ return [];
+ }
+
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch (error) {
+ console.warn('[MCP] Failed to parse mcpServers JSON:', error);
+
+ return [];
+ }
+ } else {
+ parsed = rawServers;
+ }
+ if (!Array.isArray(parsed)) {
+ return [];
+ }
+
+ return parsed.map((entry, index) => {
+ const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
+ const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
+
+ return {
+ id: this.#generateServerId((entry as { id?: unknown })?.id, index),
+ enabled: Boolean((entry as { enabled?: unknown })?.enabled),
+ url,
+ name: (entry as { name?: string })?.name,
+ requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
+ headers: headers || undefined,
+ useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
+ } satisfies MCPServerSettingsEntry;
+ });
+ }
+
+ /**
+ * Builds server configuration from a settings entry.
+ */
+ #buildServerConfig(
+ entry: MCPServerSettingsEntry,
+ connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs
+ ): MCPServerConfig | undefined {
+ if (!entry?.url) {
+ return undefined;
+ }
+
+ let headers: Record<string, string> | undefined;
+ if (entry.headers) {
+ try {
+ const parsed = JSON.parse(entry.headers);
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
+ headers = parsed as Record<string, string>;
+ } catch {
+ console.warn('[MCP] Failed to parse custom headers JSON:', entry.headers);
+ }
+ }
+
+ return {
+ url: entry.url,
+ transport: detectMcpTransportFromUrl(entry.url),
+ handshakeTimeoutMs: connectionTimeoutMs,
+ requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
+ headers,
+ useProxy: entry.useProxy
+ };
+ }
+
+ /**
+ * Checks if a server is enabled, considering per-chat overrides.
+ */
+ #checkServerEnabled(
+ server: MCPServerSettingsEntry,
+ perChatOverrides?: McpServerOverride[]
+ ): boolean {
+ if (!server.enabled) {
+ return false;
+ }
+
+ if (perChatOverrides) {
+ const override = perChatOverrides.find((o) => o.serverId === server.id);
+
+ return override?.enabled ?? false;
+ }
+
+ return false;
+ }
+
+ /**
+ * Builds MCP client configuration from settings.
+ */
+ #buildMcpClientConfig(
+ cfg: SettingsConfigType,
+ perChatOverrides?: McpServerOverride[]
+ ): MCPClientConfig | undefined {
+ const rawServers = this.#parseServerSettings(cfg.mcpServers);
+ if (!rawServers.length) {
+ return undefined;
+ }
+
+ const servers: Record<string, MCPServerConfig> = {};
+
+ for (const [index, entry] of rawServers.entries()) {
+ if (!this.#checkServerEnabled(entry, perChatOverrides)) continue;
+ const normalized = this.#buildServerConfig(entry);
+ if (normalized) servers[this.#generateServerId(entry.id, index)] = normalized;
+ }
+
+ if (Object.keys(servers).length === 0) {
+ return undefined;
+ }
+
+ return {
+ protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
+ capabilities: DEFAULT_MCP_CONFIG.capabilities,
+ clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
+ requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
+ servers
+ };
+ }
+
+ /**
+ * Builds capabilities info from server and client capabilities.
+ */
+ #buildCapabilitiesInfo(
+ serverCaps?: ServerCapabilities,
+ clientCaps?: ClientCapabilities
+ ): MCPCapabilitiesInfo {
+ return {
+ server: {
+ tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined,
+ prompts: serverCaps?.prompts ? { listChanged: serverCaps.prompts.listChanged } : undefined,
+ resources: serverCaps?.resources
+ ? {
+ subscribe: serverCaps.resources.subscribe,
+ listChanged: serverCaps.resources.listChanged
+ }
+ : undefined,
+ logging: !!serverCaps?.logging,
+ completions: !!serverCaps?.completions,
+ tasks: !!serverCaps?.tasks
+ },
+ client: {
+ roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined,
+ sampling: !!clientCaps?.sampling,
+ elicitation: clientCaps?.elicitation
+ ? { form: !!clientCaps.elicitation.form, url: !!clientCaps.elicitation.url }
+ : undefined,
+ tasks: !!clientCaps?.tasks
+ }
+ };
+ }
+
+ get isInitializing(): boolean {
+ return this._isInitializing;
+ }
+
+ get isInitialized(): boolean {
+ return this.connections.size > 0;
+ }
+
+ get error(): string | null {
+ return this._error;
+ }
+
+ get toolCount(): number {
+ return this._toolCount;
+ }
+
+ get connectedServerCount(): number {
+ return this._connectedServers.length;
+ }
+
+ get connectedServerNames(): string[] {
+ return this._connectedServers;
+ }
+
+ get isEnabled(): boolean {
+ const mcpConfig = this.#buildMcpClientConfig(config());
+ return (
+ mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0
+ );
+ }
+
+ get availableTools(): string[] {
+ return Array.from(this.toolsIndex.keys());
+ }
+
+ private updateState(state: {
+ isInitializing?: boolean;
+ error?: string | null;
+ toolCount?: number;
+ connectedServers?: string[];
+ }): void {
+ if (state.isInitializing !== undefined) {
+ this._isInitializing = state.isInitializing;
+ }
+
+ if (state.error !== undefined) {
+ this._error = state.error;
+ }
+
+ if (state.toolCount !== undefined) {
+ this._toolCount = state.toolCount;
+ }
+
+ if (state.connectedServers !== undefined) {
+ this._connectedServers = state.connectedServers;
+ }
+ }
+
+ updateHealthCheck(serverId: string, state: HealthCheckState): void {
+ this._healthChecks = { ...this._healthChecks, [serverId]: state };
+ }
+
+ getHealthCheckState(serverId: string): HealthCheckState {
+ return this._healthChecks[serverId] ?? { status: HealthCheckStatus.IDLE };
+ }
+
+ hasHealthCheck(serverId: string): boolean {
+ return (
+ serverId in this._healthChecks &&
+ this._healthChecks[serverId].status !== HealthCheckStatus.IDLE
+ );
+ }
+
+ clearHealthCheck(serverId: string): void {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { [serverId]: _removed, ...rest } = this._healthChecks;
+ this._healthChecks = rest;
+ }
+
+ clearAllHealthChecks(): void {
+ this._healthChecks = {};
+ }
+
+ clearError(): void {
+ this._error = null;
+ }
+
+ getServers(): MCPServerSettingsEntry[] {
+ return parseMcpServerSettings(config().mcpServers);
+ }
+
+ /**
+ * Get all active MCP connections.
+ * @returns Map of server names to connections
+ */
+ getConnections(): Map<string, MCPConnection> {
+ return this.connections;
+ }
+
+ getServerLabel(server: MCPServerSettingsEntry): string {
+ const healthState = this.getHealthCheckState(server.id);
+ if (healthState?.status === HealthCheckStatus.SUCCESS)
+ return (
+ healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
+ );
+ return server.url;
+ }
+
+ getServerById(serverId: string): MCPServerSettingsEntry | undefined {
+ return this.getServers().find((s) => s.id === serverId);
+ }
+
+ /**
+ * Get display name for an MCP server by its ID.
+ * Falls back to the server ID if server is not found.
+ */
+ getServerDisplayName(serverId: string): string {
+ const server = this.getServerById(serverId);
+ return server ? this.getServerLabel(server) : serverId;
+ }
+
+ /**
+ * Validates that an icon URI uses a safe scheme (https: or data:).
+ */
+ #isValidIconUri(src: string): boolean {
+ try {
+ if (src.startsWith(UrlProtocol.DATA)) return true;
+ const url = new URL(src);
+ return url.protocol === UrlProtocol.HTTPS;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Selects the best icon URL from an MCP icons array.
+ * Follows security guidelines from the MCP specification:
+ * - Only allows https: and data: URIs
+ * - Filters to supported MIME types
+ *
+ * Selection priority:
+ * 1. Icon matching the current color scheme (dark/light)
+ * 2. Universal icon (no theme specified); if exactly 2, assumes [0]=light, [1]=dark
+ * 3. First valid icon as last resort
+ */
+ #getMcpIconUrl(icons: MCPResourceIcon[] | undefined, isDark = false): string | null {
+ if (!icons?.length) return null;
+
+ const validIcons = icons.filter((icon) => {
+ if (!icon.src || !this.#isValidIconUri(icon.src)) return false;
+ if (icon.mimeType && !MCP_ALLOWED_ICON_MIME_TYPES.has(icon.mimeType)) return false;
+ return true;
+ });
+
+ if (validIcons.length === 0) return null;
+
+ const preferredTheme = isDark ? ColorMode.DARK : ColorMode.LIGHT;
+
+ // 1. Prefer icon explicitly matching the current color scheme
+ const themedIcon = validIcons.find((icon) => icon.theme === preferredTheme);
+ if (themedIcon) return this.#proxyIconSrc(themedIcon.src);
+
+ // 2. Handle universal icons (no theme specified)
+ const universalIcons = validIcons.filter((icon) => !icon.theme);
+
+ if (universalIcons.length === EXPECTED_THEMED_ICON_PAIR_COUNT) {
+ // Heuristic: two theme-less icons → assume [0] = light, [1] = dark
+ return this.#proxyIconSrc(universalIcons[isDark ? 1 : 0].src);
+ }
+
+ if (universalIcons.length > 0) {
+ return this.#proxyIconSrc(universalIcons[0].src);
+ }
+
+ // 3. Last resort: use opposite-theme icon
+ return this.#proxyIconSrc(validIcons[0].src);
+ }
+
+ /**
+ * Route an icon src through the CORS proxy if it's an HTTPS URL.
+ * Data URIs are returned as-is.
+ */
+ #proxyIconSrc(src: string): string {
+ if (src.startsWith('data:')) return src;
+
+ return getProxiedUrlString(src);
+ }
+
+ /**
+ * Get icon URL for an MCP server by its ID.
+ * Prefers the server's own icons (from MCP spec) and falls back
+ * to Google's favicon service.
+ * Returns null if server is not found.
+ */
+ getServerFavicon(serverId: string): string | null {
+ const server = this.getServerById(serverId);
+ if (!server) {
+ return null;
+ }
+
+ const isDark = mode.current === ColorMode.DARK;
+ const healthState = this.getHealthCheckState(serverId);
+ if (healthState.status === HealthCheckStatus.SUCCESS && healthState.serverInfo?.icons) {
+ const mcpIconUrl = this.#getMcpIconUrl(healthState.serverInfo.icons, isDark);
+
+ if (mcpIconUrl) {
+ return mcpIconUrl;
+ }
+ }
+
+ return getFaviconUrl(server.url);
+ }
+
+ isAnyServerLoading(): boolean {
+ return this.getServers().some((s) => {
+ const state = this.getHealthCheckState(s.id);
+
+ return (
+ state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING
+ );
+ });
+ }
+
+ getServersSorted(): MCPServerSettingsEntry[] {
+ const servers = this.getServers();
+ if (this.isAnyServerLoading()) {
+ return servers;
+ }
+
+ return [...servers].sort((a, b) =>
+ this.getServerLabel(a).localeCompare(this.getServerLabel(b))
+ );
+ }
+
+ addServer(
+ serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
+ ): void {
+ const servers = this.getServers();
+ const newServer: MCPServerSettingsEntry = {
+ id: serverData.id || (uuid() ?? `server-${Date.now()}`),
+ enabled: serverData.enabled,
+ url: serverData.url.trim(),
+ name: serverData.name,
+ headers: serverData.headers?.trim() || undefined,
+ requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
+ useProxy: serverData.useProxy
+ };
+ settingsStore.updateConfig('mcpServers', JSON.stringify([...servers, newServer]));
+ }
+
+ updateServer(id: string, updates: Partial<MCPServerSettingsEntry>): void {
+ const servers = this.getServers();
+ settingsStore.updateConfig(
+ 'mcpServers',
+ JSON.stringify(
+ servers.map((server) => (server.id === id ? { ...server, ...updates } : server))
+ )
+ );
+ }
+
+ removeServer(id: string): void {
+ const servers = this.getServers();
+ settingsStore.updateConfig('mcpServers', JSON.stringify(servers.filter((s) => s.id !== id)));
+ this.clearHealthCheck(id);
+ }
+
+ hasAvailableServers(): boolean {
+ return parseMcpServerSettings(config().mcpServers).some((s) => s.enabled && s.url.trim());
+ }
+ hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean {
+ return Boolean(this.#buildMcpClientConfig(config(), perChatOverrides));
+ }
+
+ getEnabledServersForConversation(
+ perChatOverrides?: McpServerOverride[]
+ ): MCPServerSettingsEntry[] {
+ if (!perChatOverrides?.length) {
+ return [];
+ }
+
+ return this.getServers().filter((server) => {
+ if (!server.enabled) {
+ return false;
+ }
+
+ const override = perChatOverrides.find((o) => o.serverId === server.id);
+
+ return override?.enabled ?? false;
+ });
+ }
+
+ async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
+ if (!browser) {
+ return false;
+ }
+
+ const mcpConfig = this.#buildMcpClientConfig(config(), perChatOverrides);
+ const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
+ if (!signature) {
+ await this.shutdown();
+
+ return false;
+ }
+ if (this.isInitialized && this.configSignature === signature) {
+ return true;
+ }
+
+ if (this.initPromise && this.configSignature === signature) {
+ return this.initPromise;
+ }
+
+ if (this.connections.size > 0 || this.initPromise) await this.shutdown();
+ return this.initialize(signature, mcpConfig!);
+ }
+
+ private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise<boolean> {
+ this.updateState({ isInitializing: true, error: null });
+ this.configSignature = signature;
+
+ const serverEntries = Object.entries(mcpConfig.servers);
+
+ if (serverEntries.length === 0) {
+ this.updateState({ isInitializing: false, toolCount: 0, connectedServers: [] });
+
+ return false;
+ }
+ this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries);
+
+ return this.initPromise;
+ }
+
+ private async doInitialize(
+ signature: string,
+ mcpConfig: MCPClientConfig,
+ serverEntries: [string, MCPClientConfig['servers'][string]][]
+ ): Promise<boolean> {
+ const clientInfo = mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
+ const capabilities = mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
+ const results = await Promise.allSettled(
+ serverEntries.map(async ([name, serverConfig]) => {
+ // Store config for reconnection
+ this.serverConfigs.set(name, serverConfig);
+
+ const listChangedHandlers = this.createListChangedHandlers(name);
+ const connection = await MCPService.connect(
+ name,
+ serverConfig,
+ clientInfo,
+ capabilities,
+ (phase) => {
+ // Handle WebSocket disconnection
+ if (phase === MCPConnectionPhase.DISCONNECTED) {
+ console.log(`[MCPStore][${name}] Connection lost, starting auto-reconnect`);
+ this.autoReconnect(name);
+ }
+ },
+ listChangedHandlers
+ );
+
+ return { name, connection };
+ })
+ );
+ if (this.configSignature !== signature) {
+ for (const result of results) {
+ if (result.status === 'fulfilled')
+ await MCPService.disconnect(result.value.connection).catch(console.warn);
+ }
+
+ return false;
+ }
+ for (const result of results) {
+ if (result.status === 'fulfilled') {
+ const { name, connection } = result.value;
+
+ this.connections.set(name, connection);
+
+ for (const tool of connection.tools) {
+ if (this.toolsIndex.has(tool.name))
+ console.warn(
+ `[MCPStore] Tool name conflict: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${name}". Using tool from "${name}".`
+ );
+ this.toolsIndex.set(tool.name, name);
+ }
+ } else {
+ console.error(`[MCPStore] Failed to connect:`, result.reason);
+ }
+ }
+
+ const successCount = this.connections.size;
+ if (successCount === 0 && serverEntries.length > 0) {
+ this.updateState({
+ isInitializing: false,
+ error: 'All MCP server connections failed',
+ toolCount: 0,
+ connectedServers: []
+ });
+ this.initPromise = null;
+
+ return false;
+ }
+
+ this.updateState({
+ isInitializing: false,
+ error: null,
+ toolCount: this.toolsIndex.size,
+ connectedServers: Array.from(this.connections.keys())
+ });
+ this.initPromise = null;
+
+ return true;
+ }
+
+ private createListChangedHandlers(serverName: string): ListChangedHandlers {
+ return {
+ tools: {
+ onChanged: (error: Error | null, tools: Tool[] | null) => {
+ if (error) {
+ console.warn(`[MCPStore][${serverName}] Tools list changed error:`, error);
+ return;
+ }
+ this.handleToolsListChanged(serverName, tools ?? []);
+ }
+ },
+ prompts: {
+ onChanged: (error: Error | null) => {
+ if (error) {
+ console.warn(`[MCPStore][${serverName}] Prompts list changed error:`, error);
+ return;
+ }
+ }
+ }
+ };
+ }
+
+ private handleToolsListChanged(serverName: string, tools: Tool[]): void {
+ const connection = this.connections.get(serverName);
+ if (!connection) {
+ return;
+ }
+
+ for (const [toolName, ownerServer] of this.toolsIndex.entries()) {
+ if (ownerServer === serverName) this.toolsIndex.delete(toolName);
+ }
+
+ connection.tools = tools;
+
+ for (const tool of tools) {
+ if (this.toolsIndex.has(tool.name))
+ console.warn(
+ `[MCPStore] Tool name conflict after list change: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverName}". Using tool from "${serverName}".`
+ );
+ this.toolsIndex.set(tool.name, serverName);
+ }
+ this.updateState({ toolCount: this.toolsIndex.size });
+ }
+
+ acquireConnection(): void {
+ this.activeFlowCount++;
+ }
+
+ /**
+ * Release a connection reference.
+ * By default, keeps connections alive for reuse (shutdownIfUnused=false).
+ * MCP spec encourages long-lived sessions to avoid reconnection overhead.
+ */
+ async releaseConnection(shutdownIfUnused = false): Promise<void> {
+ this.activeFlowCount = Math.max(0, this.activeFlowCount - 1);
+ if (shutdownIfUnused && this.activeFlowCount === 0) {
+ await this.shutdown();
+ }
+ }
+
+ getActiveFlowCount(): number {
+ return this.activeFlowCount;
+ }
+
+ async shutdown(): Promise<void> {
+ if (this.initPromise) {
+ await this.initPromise.catch(() => {});
+ this.initPromise = null;
+ }
+
+ if (this.connections.size === 0) {
+ return;
+ }
+
+ await Promise.all(
+ Array.from(this.connections.values()).map((conn) =>
+ MCPService.disconnect(conn).catch((error) =>
+ console.warn(`[MCPStore] Error disconnecting ${conn.serverName}:`, error)
+ )
+ )
+ );
+
+ this.connections.clear();
+ this.toolsIndex.clear();
+ this.serverConfigs.clear();
+ this.configSignature = null;
+ this.updateState({ isInitializing: false, error: null, toolCount: 0, connectedServers: [] });
+ }
+
+ /**
+ * Immediately reconnect to a server by creating a fresh transport and session.
+ * Used when a session-expired error (HTTP 404) is detected during tool execution.
+ * Per MCP spec 2025-11-25: client MUST discard session ID and re-initialize.
+ *
+ * Unlike autoReconnect (which uses exponential backoff for connectivity issues),
+ * this performs a single immediate reconnection attempt since the server is known
+ * to be reachable (it responded with 404).
+ */
+ private async reconnectServer(serverName: string): Promise<void> {
+ const serverConfig = this.serverConfigs.get(serverName);
+ if (!serverConfig) {
+ throw new Error(`[MCPStore] No config found for ${serverName}, cannot reconnect`);
+ }
+
+ // Disconnect stale connection (clears old transport + session ID)
+ const oldConnection = this.connections.get(serverName);
+ if (oldConnection) {
+ await MCPService.disconnect(oldConnection).catch(console.warn);
+ this.connections.delete(serverName);
+ }
+
+ console.log(`[MCPStore][${serverName}] Session expired, reconnecting with fresh session...`);
+
+ const listChangedHandlers = this.createListChangedHandlers(serverName);
+ const connection = await MCPService.connect(
+ serverName,
+ serverConfig,
+ DEFAULT_MCP_CONFIG.clientInfo,
+ DEFAULT_MCP_CONFIG.capabilities,
+ (phase) => {
+ if (phase === MCPConnectionPhase.DISCONNECTED) {
+ console.log(`[MCPStore][${serverName}] Connection lost, starting auto-reconnect`);
+ this.autoReconnect(serverName);
+ }
+ },
+ listChangedHandlers
+ );
+
+ // Replace connection and rebuild tool index for this server
+ this.connections.set(serverName, connection);
+ for (const tool of connection.tools) {
+ this.toolsIndex.set(tool.name, serverName);
+ }
+
+ console.log(`[MCPStore][${serverName}] Session recovered successfully`);
+ }
+
+ /**
+ * Auto-reconnect to a server with exponential backoff.
+ * Continues indefinitely until successful.
+ *
+ * Race-condition safety: when the phase callback fires a DISCONNECTED event
+ * while we are still inside this function (e.g., the server drops right after
+ * a successful connect()), a naive inner `autoReconnect()` call would be
+ * swallowed by the `reconnectingServers` guard, leaving the server
+ * permanently disconnected once the outer call exits. We solve this by
+ * deferring the new reconnection via the `needsReconnect` flag: the flag is
+ * set inside the phase callback and honoured in the `finally` block after
+ * the guard entry has been removed.
+ */
+ private async autoReconnect(serverName: string): Promise<void> {
+ // Guard against concurrent reconnections
+ if (this.reconnectingServers.has(serverName)) {
+ console.log(`[MCPStore][${serverName}] Reconnection already in progress, skipping`);
+
+ return;
+ }
+
+ const serverConfig = this.serverConfigs.get(serverName);
+ if (!serverConfig) {
+ console.error(`[MCPStore] No config found for ${serverName}, cannot reconnect`);
+
+ return;
+ }
+
+ this.reconnectingServers.add(serverName);
+ let backoff = MCP_RECONNECT_INITIAL_DELAY;
+ // Flag set by the phase callback when a DISCONNECTED event fires while
+ // reconnectingServers still holds this server (see JSDoc above).
+ let needsReconnect = false;
+
+ try {
+ while (true) {
+ await new Promise((resolve) => setTimeout(resolve, backoff));
+
+ console.log(`[MCPStore][${serverName}] Auto-reconnecting...`);
+
+ try {
+ // Per-attempt timeout: reject if the server doesn't respond in time,
+ // then fall through to backoff logic as with any other failure.
+ const timeoutPromise = new Promise<never>((_, reject) =>
+ setTimeout(
+ () =>
+ reject(
+ new Error(
+ `Reconnect attempt timed out after ${MCP_RECONNECT_ATTEMPT_TIMEOUT_MS}ms`
+ )
+ ),
+ MCP_RECONNECT_ATTEMPT_TIMEOUT_MS
+ )
+ );
+
+ needsReconnect = false;
+ const listChangedHandlers = this.createListChangedHandlers(serverName);
+ const connectPromise = MCPService.connect(
+ serverName,
+ serverConfig,
+ DEFAULT_MCP_CONFIG.clientInfo,
+ DEFAULT_MCP_CONFIG.capabilities,
+ (phase) => {
+ if (phase === MCPConnectionPhase.DISCONNECTED) {
+ if (this.reconnectingServers.has(serverName)) {
+ // Reconnect loop is active; defer to after it exits.
+ needsReconnect = true;
+ } else {
+ console.log(
+ `[MCPStore][${serverName}] Connection lost, restarting auto-reconnect`
+ );
+ this.autoReconnect(serverName);
+ }
+ }
+ },
+ listChangedHandlers
+ );
+
+ const connection = await Promise.race([connectPromise, timeoutPromise]);
+
+ // Replace old connection with new one
+ this.connections.set(serverName, connection);
+
+ // Rebuild tool index for this server
+ for (const tool of connection.tools) {
+ this.toolsIndex.set(tool.name, serverName);
+ }
+
+ console.log(`[MCPStore][${serverName}] Reconnected successfully`);
+ break;
+ } catch (error) {
+ console.warn(`[MCPStore][${serverName}] Reconnection failed:`, error);
+ backoff = Math.min(backoff * MCP_RECONNECT_BACKOFF_MULTIPLIER, MCP_RECONNECT_MAX_DELAY);
+ }
+ }
+ } finally {
+ this.reconnectingServers.delete(serverName);
+ // If the phase callback signalled a disconnect while this function held
+ // the guard, kick off a fresh reconnect now that the guard is released.
+ if (needsReconnect) {
+ console.log(
+ `[MCPStore][${serverName}] Deferred disconnect detected, restarting auto-reconnect`
+ );
+ this.autoReconnect(serverName);
+ }
+ }
+ }
+
+ getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
+ const tools: OpenAIToolDefinition[] = [];
+
+ for (const connection of this.connections.values()) {
+ for (const tool of connection.tools) {
+ const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
+ type: JsonSchemaType.OBJECT,
+ properties: {},
+ required: []
+ };
+
+ tools.push({
+ type: ToolCallType.FUNCTION as const,
+ function: {
+ name: tool.name,
+ description: tool.description,
+ parameters: this.normalizeSchemaProperties(rawSchema)
+ }
+ });
+ }
+ }
+
+ return tools;
+ }
+
+ private normalizeSchemaProperties(schema: Record<string, unknown>): Record<string, unknown> {
+ if (!schema || typeof schema !== 'object') {
+ return schema;
+ }
+
+ const normalized = { ...schema };
+ if (normalized.properties && typeof normalized.properties === 'object') {
+ const props = normalized.properties as Record<string, Record<string, unknown>>;
+ const normalizedProps: Record<string, Record<string, unknown>> = {};
+ for (const [key, prop] of Object.entries(props)) {
+ if (!prop || typeof prop !== 'object') {
+ normalizedProps[key] = prop;
+ continue;
+ }
+ const normalizedProp = { ...prop };
+ if (!normalizedProp.type && normalizedProp.default !== undefined) {
+ const defaultVal = normalizedProp.default;
+ if (typeof defaultVal === 'string') normalizedProp.type = 'string';
+ else if (typeof defaultVal === 'number')
+ normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number';
+ else if (typeof defaultVal === 'boolean') normalizedProp.type = 'boolean';
+ else if (Array.isArray(defaultVal)) normalizedProp.type = 'array';
+ else if (typeof defaultVal === 'object' && defaultVal !== null)
+ normalizedProp.type = 'object';
+ }
+ if (normalizedProp.properties)
+ Object.assign(
+ normalizedProp,
+ this.normalizeSchemaProperties(normalizedProp as Record<string, unknown>)
+ );
+ if (normalizedProp.items && typeof normalizedProp.items === 'object')
+ normalizedProp.items = this.normalizeSchemaProperties(
+ normalizedProp.items as Record<string, unknown>
+ );
+ normalizedProps[key] = normalizedProp;
+ }
+ normalized.properties = normalizedProps;
+ }
+
+ return normalized;
+ }
+
+ getToolNames(): string[] {
+ return Array.from(this.toolsIndex.keys());
+ }
+
+ hasTool(toolName: string): boolean {
+ return this.toolsIndex.has(toolName);
+ }
+
+ getToolServer(toolName: string): string | undefined {
+ return this.toolsIndex.get(toolName);
+ }
+
+ hasPromptsSupport(): boolean {
+ for (const connection of this.connections.values()) {
+ if (connection.serverCapabilities?.prompts) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if any enabled server with successful health check supports prompts.
+ * Uses health check state since servers may not have active connections until
+ * the user actually sends a message or uses prompts.
+ * @param perChatOverrides - Per-chat server overrides to filter by enabled servers.
+ * If provided (even empty array), only checks enabled servers.
+ * If undefined, checks all servers with successful health checks.
+ */
+ hasPromptsCapability(perChatOverrides?: McpServerOverride[]): boolean {
+ // If perChatOverrides is provided (even empty array), filter by enabled servers
+ if (perChatOverrides !== undefined) {
+ const enabledServerIds = new Set(
+ perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId)
+ );
+
+ // No enabled servers = no capability
+ if (enabledServerIds.size === 0) {
+ return false;
+ }
+
+ // Check health check states for enabled servers with prompts capability
+ for (const [serverId, state] of Object.entries(this._healthChecks)) {
+ if (!enabledServerIds.has(serverId)) continue;
+ if (
+ state.status === HealthCheckStatus.SUCCESS &&
+ state.capabilities?.server?.prompts !== undefined
+ ) {
+ return true;
+ }
+ }
+
+ // Also check active connections as fallback
+ for (const [serverName, connection] of this.connections) {
+ if (!enabledServerIds.has(serverName)) continue;
+ if (connection.serverCapabilities?.prompts) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // No overrides provided - check all servers (global mode)
+ for (const state of Object.values(this._healthChecks)) {
+ if (
+ state.status === HealthCheckStatus.SUCCESS &&
+ state.capabilities?.server?.prompts !== undefined
+ ) {
+ return true;
+ }
+ }
+
+ for (const connection of this.connections.values()) {
+ if (connection.serverCapabilities?.prompts) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ async getAllPrompts(): Promise<MCPPromptInfo[]> {
+ const results: MCPPromptInfo[] = [];
+
+ for (const [serverName, connection] of this.connections) {
+ if (!connection.serverCapabilities?.prompts) continue;
+
+ const prompts = await MCPService.listPrompts(connection);
+
+ for (const prompt of prompts) {
+ results.push({
+ name: prompt.name,
+ description: prompt.description,
+ title: prompt.title,
+ serverName,
+ arguments: prompt.arguments?.map((arg) => ({
+ name: arg.name,
+ description: arg.description,
+ required: arg.required
+ }))
+ });
+ }
+ }
+
+ return results;
+ }
+
+ async getPrompt(
+ serverName: string,
+ promptName: string,
+ args?: Record<string, string>
+ ): Promise<GetPromptResult> {
+ const connection = this.connections.get(serverName);
+ if (!connection) throw new Error(`Server "${serverName}" not found for prompt "${promptName}"`);
+
+ return MCPService.getPrompt(connection, promptName, args);
+ }
+
+ async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise<ToolExecutionResult> {
+ const toolName = toolCall.function.name;
+
+ const serverName = this.toolsIndex.get(toolName);
+ if (!serverName) throw new Error(`Unknown tool: ${toolName}`);
+
+ const connection = this.connections.get(serverName);
+ if (!connection) throw new Error(`Server "${serverName}" is not connected`);
+
+ const args = this.parseToolArguments(toolCall.function.arguments);
+
+ try {
+ return await MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
+ } catch (error) {
+ // Session expired (server restarted) - reconnect and retry once
+ if (MCPService.isSessionExpiredError(error)) {
+ await this.reconnectServer(serverName);
+
+ const newConnection = this.connections.get(serverName);
+ if (!newConnection) throw new Error(`Failed to reconnect to "${serverName}"`);
+
+ return MCPService.callTool(newConnection, { name: toolName, arguments: args }, signal);
+ }
+
+ throw error;
+ }
+ }
+
+ async executeToolByName(
+ toolName: string,
+ args: Record<string, unknown>,
+ signal?: AbortSignal
+ ): Promise<ToolExecutionResult> {
+ const serverName = this.toolsIndex.get(toolName);
+ if (!serverName) throw new Error(`Unknown tool: ${toolName}`);
+ const connection = this.connections.get(serverName);
+ if (!connection) throw new Error(`Server "${serverName}" is not connected`);
+
+ try {
+ return await MCPService.callTool(connection, { name: toolName, arguments: args }, signal);
+ } catch (error) {
+ if (MCPService.isSessionExpiredError(error)) {
+ await this.reconnectServer(serverName);
+
+ const newConnection = this.connections.get(serverName);
+ if (!newConnection) throw new Error(`Failed to reconnect to "${serverName}"`);
+
+ return MCPService.callTool(newConnection, { name: toolName, arguments: args }, signal);
+ }
+
+ throw error;
+ }
+ }
+
+ private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
+ if (typeof args === 'string') {
+ const trimmed = args.trim();
+ if (trimmed === '') {
+ return {};
+ }
+
+ try {
+ const parsed = JSON.parse(trimmed);
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
+ throw new Error(
+ `Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`
+ );
+
+ return parsed as Record<string, unknown>;
+ } catch (error) {
+ throw new Error(`Failed to parse tool arguments as JSON: ${(error as Error).message}`);
+ }
+ }
+
+ if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
+ return args;
+ }
+
+ throw new Error(`Invalid tool arguments type: ${typeof args}`);
+ }
+
+ async getPromptCompletions(
+ serverName: string,
+ promptName: string,
+ argumentName: string,
+ argumentValue: string
+ ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
+ const connection = this.connections.get(serverName);
+ if (!connection) {
+ console.warn(`[MCPStore] Server "${serverName}" is not connected`);
+ return null;
+ }
+ if (!connection.serverCapabilities?.completions) {
+ return null;
+ }
+
+ return MCPService.complete(
+ connection,
+ { type: MCPRefType.PROMPT, name: promptName },
+ { name: argumentName, value: argumentValue }
+ );
+ }
+
+ /**
+ * Get completions for a resource template argument.
+ * Uses the MCP Completion API with ref/resource.
+ */
+ async getResourceCompletions(
+ serverName: string,
+ uriTemplate: string,
+ argumentName: string,
+ argumentValue: string
+ ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
+ const connection = this.connections.get(serverName);
+
+ if (!connection) {
+ console.warn(`[MCPStore] Server "${serverName}" is not connected`);
+ return null;
+ }
+
+ if (!connection.serverCapabilities?.completions) {
+ return null;
+ }
+
+ return MCPService.complete(
+ connection,
+ { type: MCPRefType.RESOURCE, uri: uriTemplate },
+ { name: argumentName, value: argumentValue }
+ );
+ }
+
+ /**
+ * Read a resource by an arbitrary URI (e.g., one expanded from a template).
+ * Unlike readResource(), this does not require the URI to be in the resources list.
+ */
+ async readResourceByUri(serverName: string, uri: string): Promise<MCPResourceContent[] | null> {
+ const connection = this.connections.get(serverName);
+
+ if (!connection) {
+ console.error(`[MCPStore] No connection found for server: ${serverName}`);
+
+ return null;
+ }
+
+ try {
+ const result = await MCPService.readResource(connection, uri);
+
+ return result.contents;
+ } catch (error) {
+ console.error(`[MCPStore] Failed to read resource ${uri}:`, error);
+
+ return null;
+ }
+ }
+
+ private parseHeaders(headersJson?: string): Record<string, string> | undefined {
+ if (!headersJson?.trim()) {
+ return undefined;
+ }
+
+ try {
+ const parsed = JSON.parse(headersJson);
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
+ return parsed as Record<string, string>;
+ } catch {
+ console.warn('[MCPStore] Failed to parse custom headers JSON:', headersJson);
+ }
+
+ return undefined;
+ }
+
+ async runHealthChecksForServers(
+ servers: {
+ id: string;
+ enabled: boolean;
+ url: string;
+ requestTimeoutSeconds: number;
+ headers?: string;
+ }[],
+ skipIfChecked = true,
+ promoteToActive = false
+ ): Promise<void> {
+ const serversToCheck = skipIfChecked
+ ? servers.filter((s) => !this.hasHealthCheck(s.id) && s.url.trim())
+ : servers.filter((s) => s.url.trim());
+
+ if (serversToCheck.length === 0) {
+ return;
+ }
+
+ const BATCH_SIZE = 5;
+ for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) {
+ const batch = serversToCheck.slice(i, i + BATCH_SIZE);
+ await Promise.allSettled(batch.map((server) => this.runHealthCheck(server, promoteToActive)));
+ }
+ }
+
+ /**
+ * Check if a server already has an active connection that can be reused.
+ * Returns the existing connection if available.
+ */
+ getExistingConnection(serverId: string): MCPConnection | undefined {
+ return this.connections.get(serverId);
+ }
+
+ /**
+ * Run a health check for a server.
+ * If the server already has an active connection, reuses it instead of creating a new one.
+ * If promoteToActive is true and server is enabled, the connection will be kept
+ * and promoted to an active connection instead of being disconnected.
+ */
+ async runHealthCheck(server: HealthCheckParams, promoteToActive = false): Promise<void> {
+ // Check if we already have an active connection for this server
+ const existingConnection = this.connections.get(server.id);
+ if (existingConnection) {
+ // Reuse existing connection - just refresh tools list
+ try {
+ const tools = await MCPService.listTools(existingConnection);
+ const capabilities = this.#buildCapabilitiesInfo(
+ existingConnection.serverCapabilities,
+ existingConnection.clientCapabilities
+ );
+ this.updateHealthCheck(server.id, {
+ status: HealthCheckStatus.SUCCESS,
+ tools: tools.map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ title: tool.title
+ })),
+ serverInfo: existingConnection.serverInfo,
+ capabilities,
+ transportType: existingConnection.transportType,
+ protocolVersion: existingConnection.protocolVersion,
+ instructions: existingConnection.instructions,
+ connectionTimeMs: existingConnection.connectionTimeMs,
+ logs: []
+ });
+ return;
+ } catch (error) {
+ console.warn(
+ `[MCPStore] Failed to reuse connection for ${server.id}, creating new one:`,
+ error
+ );
+ // Connection may be stale, remove it and create new one
+ this.connections.delete(server.id);
+ }
+ }
+
+ const trimmedUrl = server.url.trim();
+ const logs: MCPConnectionLog[] = [];
+ let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE;
+
+ if (!trimmedUrl) {
+ this.updateHealthCheck(server.id, {
+ status: HealthCheckStatus.ERROR,
+ message: 'Please enter a server URL first.',
+ logs: []
+ });
+ return;
+ }
+
+ this.updateHealthCheck(server.id, {
+ status: HealthCheckStatus.CONNECTING,
+ phase: MCPConnectionPhase.TRANSPORT_CREATING,
+ logs: []
+ });
+
+ const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
+ const headers = this.parseHeaders(server.headers);
+
+ try {
+ const serverConfig: MCPServerConfig = {
+ url: trimmedUrl,
+ transport: detectMcpTransportFromUrl(trimmedUrl),
+ handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
+ requestTimeoutMs: timeoutMs,
+ headers,
+ useProxy: server.useProxy
+ };
+
+ // Store config for reconnection
+ this.serverConfigs.set(server.id, serverConfig);
+
+ const connection = await MCPService.connect(
+ server.id,
+ serverConfig,
+ DEFAULT_MCP_CONFIG.clientInfo,
+ DEFAULT_MCP_CONFIG.capabilities,
+ (phase, log) => {
+ currentPhase = phase;
+ logs.push(log);
+ this.updateHealthCheck(server.id, {
+ status: HealthCheckStatus.CONNECTING,
+ phase,
+ logs: [...logs]
+ });
+
+ // Handle WebSocket disconnection
+ if (phase === MCPConnectionPhase.DISCONNECTED && promoteToActive) {
+ console.log(
+ `[MCPStore][${server.id}] Connection lost during health check, starting auto-reconnect`
+ );
+ this.autoReconnect(server.id);
+ }
+ }
+ );
+
+ const tools = connection.tools.map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ title: tool.title
+ }));
+
+ const capabilities = this.#buildCapabilitiesInfo(
+ connection.serverCapabilities,
+ connection.clientCapabilities
+ );
+
+ this.updateHealthCheck(server.id, {
+ status: HealthCheckStatus.SUCCESS,
+ tools,
+ serverInfo: connection.serverInfo,
+ capabilities,
+ transportType: connection.transportType,
+ protocolVersion: connection.protocolVersion,
+ instructions: connection.instructions,
+ connectionTimeMs: connection.connectionTimeMs,
+ logs
+ });
+
+ // Promote to active connection or disconnect
+ if (promoteToActive && server.enabled) {
+ this.promoteHealthCheckToConnection(server.id, connection);
+ } else {
+ await MCPService.disconnect(connection);
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error occurred';
+
+ logs.push({
+ timestamp: new Date(),
+ phase: MCPConnectionPhase.ERROR,
+ message: `Connection failed: ${message}`,
+ level: MCPLogLevel.ERROR
+ });
+
+ this.updateHealthCheck(server.id, {
+ status: HealthCheckStatus.ERROR,
+ message,
+ phase: currentPhase,
+ logs
+ });
+ }
+ }
+
+ /**
+ * Promote a health check connection to an active connection.
+ * This avoids the need to reconnect when the server is needed for agentic flows.
+ */
+ private promoteHealthCheckToConnection(serverId: string, connection: MCPConnection): void {
+ // Register tools from the connection
+ for (const tool of connection.tools) {
+ if (this.toolsIndex.has(tool.name)) {
+ console.warn(
+ `[MCPStore] Tool name conflict during promotion: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverId}". Using tool from "${serverId}".`
+ );
+ }
+ this.toolsIndex.set(tool.name, serverId);
+ }
+
+ // Add to active connections
+ this.connections.set(serverId, connection);
+
+ // Update state
+ this.updateState({
+ toolCount: this.toolsIndex.size,
+ connectedServers: Array.from(this.connections.keys())
+ });
+ }
+
+ getServersStatus(): ServerStatus[] {
+ const statuses: ServerStatus[] = [];
+
+ for (const [name, connection] of this.connections) {
+ statuses.push({
+ name,
+ isConnected: true,
+ toolCount: connection.tools.length,
+ error: undefined
+ });
+ }
+
+ return statuses;
+ }
+
+ /**
+ * Get aggregated server instructions from all connected servers.
+ * Returns an array of { serverName, serverTitle, instructions } objects.
+ */
+ getServerInstructions(): Array<{
+ serverName: string;
+ serverTitle?: string;
+ instructions: string;
+ }> {
+ const results: Array<{ serverName: string; serverTitle?: string; instructions: string }> = [];
+
+ for (const [serverName, connection] of this.connections) {
+ if (connection.instructions) {
+ results.push({
+ serverName,
+ serverTitle: connection.serverInfo?.title || connection.serverInfo?.name,
+ instructions: connection.instructions
+ });
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Get server instructions from health check results (for display before active connection).
+ * Useful for showing instructions in settings UI.
+ */
+ getHealthCheckInstructions(): Array<{
+ serverId: string;
+ serverTitle?: string;
+ instructions: string;
+ }> {
+ const results: Array<{ serverId: string; serverTitle?: string; instructions: string }> = [];
+
+ for (const [serverId, state] of Object.entries(this._healthChecks)) {
+ if (state.status === HealthCheckStatus.SUCCESS && state.instructions) {
+ results.push({
+ serverId,
+ serverTitle: state.serverInfo?.title || state.serverInfo?.name,
+ instructions: state.instructions
+ });
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Check if any connected server has instructions.
+ */
+ hasServerInstructions(): boolean {
+ for (const connection of this.connections.values()) {
+ if (connection.instructions) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ *
+ *
+ * Resources Operations
+ *
+ *
+ */
+
+ /**
+ * Check if any enabled server with successful health check supports resources.
+ * Uses health check state since servers may not have active connections until
+ * the user actually sends a message or uses prompts.
+ * @param perChatOverrides - Per-chat server overrides to filter by enabled servers.
+ * If provided (even empty array), only checks enabled servers.
+ * If undefined, checks all servers with successful health checks.
+ */
+ hasResourcesCapability(perChatOverrides?: McpServerOverride[]): boolean {
+ // If perChatOverrides is provided (even empty array), filter by enabled servers
+ if (perChatOverrides !== undefined) {
+ const enabledServerIds = new Set(
+ perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId)
+ );
+ // No enabled servers = no capability
+ if (enabledServerIds.size === 0) {
+ return false;
+ }
+
+ // Check health check states for enabled servers with resources capability
+ for (const [serverId, state] of Object.entries(this._healthChecks)) {
+ if (!enabledServerIds.has(serverId)) continue;
+ if (
+ state.status === HealthCheckStatus.SUCCESS &&
+ state.capabilities?.server?.resources !== undefined
+ ) {
+ return true;
+ }
+ }
+
+ // Also check active connections as fallback
+ for (const [serverName, connection] of this.connections) {
+ if (!enabledServerIds.has(serverName)) continue;
+ if (MCPService.supportsResources(connection)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // No overrides provided - check all servers (global mode)
+ for (const state of Object.values(this._healthChecks)) {
+ if (
+ state.status === HealthCheckStatus.SUCCESS &&
+ state.capabilities?.server?.resources !== undefined
+ ) {
+ return true;
+ }
+ }
+
+ for (const connection of this.connections.values()) {
+ if (MCPService.supportsResources(connection)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get list of servers that support resources.
+ * Checks active connections first, then health check state as fallback.
+ */
+ getServersWithResources(): string[] {
+ const servers: string[] = [];
+
+ // Check active connections
+ for (const [name, connection] of this.connections) {
+ if (MCPService.supportsResources(connection) && !servers.includes(name)) {
+ servers.push(name);
+ }
+ }
+
+ // Also check health check states for servers not yet connected
+ for (const [serverId, state] of Object.entries(this._healthChecks)) {
+ if (
+ !servers.includes(serverId) &&
+ state.status === HealthCheckStatus.SUCCESS &&
+ state.capabilities?.server?.resources !== undefined
+ ) {
+ servers.push(serverId);
+ }
+ }
+
+ return servers;
+ }
+
+ /**
+ * Fetch resources from all connected servers that support them.
+ * Updates mcpResourceStore with the results.
+ * @param forceRefresh - If true, bypass cache and fetch fresh data
+ */
+ async fetchAllResources(forceRefresh: boolean = false): Promise<void> {
+ const serversWithResources = this.getServersWithResources();
+ if (serversWithResources.length === 0) {
+ return;
+ }
+
+ // Check if we have cached resources and they're recent (unless force refresh)
+ if (!forceRefresh) {
+ const allServersCached = serversWithResources.every((serverName) => {
+ const serverRes = mcpResourceStore.getServerResources(serverName);
+ if (!serverRes || !serverRes.lastFetched) {
+ return false;
+ }
+
+ // Cache is valid for 5 minutes
+ const age = Date.now() - serverRes.lastFetched.getTime();
+
+ return age < DEFAULT_CACHE_TTL_MS;
+ });
+
+ if (allServersCached) {
+ console.log('[MCPStore] Using cached resources');
+
+ return;
+ }
+ }
+
+ mcpResourceStore.setLoading(true);
+
+ try {
+ await Promise.all(
+ serversWithResources.map((serverName) => this.fetchServerResources(serverName))
+ );
+ } finally {
+ mcpResourceStore.setLoading(false);
+ }
+ }
+
+ /**
+ * Fetch resources from a specific server.
+ * Updates mcpResourceStore with the results.
+ */
+ async fetchServerResources(serverName: string): Promise<void> {
+ const connection = this.connections.get(serverName);
+ if (!connection) {
+ console.warn(`[MCPStore] No connection found for server: ${serverName}`);
+ return;
+ }
+
+ if (!MCPService.supportsResources(connection)) {
+ return;
+ }
+
+ mcpResourceStore.setServerLoading(serverName, true);
+
+ try {
+ const [resources, templates] = await Promise.all([
+ MCPService.listAllResources(connection),
+ MCPService.listAllResourceTemplates(connection)
+ ]);
+
+ mcpResourceStore.setServerResources(serverName, resources, templates);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ mcpResourceStore.setServerError(serverName, message);
+ console.error(`[MCPStore][${serverName}] Failed to fetch resources:`, error);
+ }
+ }
+
+ /**
+ * Read resource content from a server.
+ * Caches the result in mcpResourceStore.
+ */
+ async readResource(uri: string): Promise<MCPResourceContent[] | null> {
+ // Check cache first
+ const cached = mcpResourceStore.getCachedContent(uri);
+ if (cached) {
+ return cached.content;
+ }
+
+ // Find which server has this resource
+ const serverName = mcpResourceStore.findServerForUri(uri);
+ if (!serverName) {
+ console.error(`[MCPStore] No server found for resource URI: ${uri}`);
+
+ return null;
+ }
+
+ const connection = this.connections.get(serverName);
+ if (!connection) {
+ console.error(`[MCPStore] No connection found for server: ${serverName}`);
+
+ return null;
+ }
+
+ try {
+ const result = await MCPService.readResource(connection, uri);
+ const resourceInfo = mcpResourceStore.findResourceByUri(uri);
+
+ if (resourceInfo) {
+ mcpResourceStore.cacheResourceContent(resourceInfo, result.contents);
+ }
+
+ return result.contents;
+ } catch (error) {
+ console.error(`[MCPStore] Failed to read resource ${uri}:`, error);
+
+ return null;
+ }
+ }
+
+ /**
+ * Subscribe to resource updates.
+ */
+ async subscribeToResource(uri: string): Promise<boolean> {
+ const serverName = mcpResourceStore.findServerForUri(uri);
+ if (!serverName) {
+ console.error(`[MCPStore] No server found for resource URI: ${uri}`);
+
+ return false;
+ }
+
+ const connection = this.connections.get(serverName);
+ if (!connection) {
+ console.error(`[MCPStore] No connection found for server: ${serverName}`);
+
+ return false;
+ }
+
+ if (!MCPService.supportsResourceSubscriptions(connection)) {
+ return false;
+ }
+
+ try {
+ await MCPService.subscribeResource(connection, uri);
+ mcpResourceStore.addSubscription(uri, serverName);
+
+ return true;
+ } catch (error) {
+ console.error(`[MCPStore] Failed to subscribe to resource ${uri}:`, error);
+
+ return false;
+ }
+ }
+
+ /**
+ * Unsubscribe from resource updates.
+ */
+ async unsubscribeFromResource(uri: string): Promise<boolean> {
+ const serverName = mcpResourceStore.findServerForUri(uri);
+ if (!serverName) {
+ console.error(`[MCPStore] No server found for resource URI: ${uri}`);
+
+ return false;
+ }
+
+ const connection = this.connections.get(serverName);
+ if (!connection) {
+ console.error(`[MCPStore] No connection found for server: ${serverName}`);
+
+ return false;
+ }
+
+ try {
+ await MCPService.unsubscribeResource(connection, uri);
+ mcpResourceStore.removeSubscription(uri);
+
+ return true;
+ } catch (error) {
+ console.error(`[MCPStore] Failed to unsubscribe from resource ${uri}:`, error);
+
+ return false;
+ }
+ }
+
+ /**
+ * Add a resource as attachment to chat context.
+ * Automatically fetches content if not cached.
+ */
+ async attachResource(uri: string): Promise<MCPResourceAttachment | null> {
+ const resourceInfo = mcpResourceStore.findResourceByUri(uri);
+ if (!resourceInfo) {
+ console.error(`[MCPStore] Resource not found: ${uri}`);
+
+ return null;
+ }
+
+ // Check if already attached
+ if (mcpResourceStore.isAttached(uri)) {
+ return null;
+ }
+
+ // Add attachment (initially loading)
+ const attachment = mcpResourceStore.addAttachment(resourceInfo);
+
+ // Fetch content
+ try {
+ const content = await this.readResource(uri);
+
+ if (content) {
+ mcpResourceStore.updateAttachmentContent(attachment.id, content);
+ } else {
+ mcpResourceStore.updateAttachmentError(attachment.id, 'Failed to read resource');
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ mcpResourceStore.updateAttachmentError(attachment.id, message);
+ }
+
+ return mcpResourceStore.getAttachment(attachment.id) ?? null;
+ }
+
+ /**
+ * Remove a resource attachment from chat context.
+ */
+ removeResourceAttachment(attachmentId: string): void {
+ mcpResourceStore.removeAttachment(attachmentId);
+ }
+
+ /**
+ * Clear all resource attachments.
+ */
+ clearResourceAttachments(): void {
+ mcpResourceStore.clearAttachments();
+ }
+
+ /**
+ * Get formatted resource context for chat.
+ */
+ getResourceContextForChat(): string {
+ return mcpResourceStore.formatAttachmentsForContext();
+ }
+
+ /**
+ * Convert current resource attachments to DatabaseMessageExtra[] and clear them.
+ * Called during message send to persist resources with the user message.
+ */
+ consumeResourceAttachmentsAsExtras(): DatabaseMessageExtraMcpResource[] {
+ const extras = mcpResourceStore.toMessageExtras();
+ if (extras.length > 0) {
+ mcpResourceStore.clearAttachments();
+ }
+ return extras;
+ }
+}
+
+export const mcpStore = new MCPStore();
+
+export const mcpIsInitializing = () => mcpStore.isInitializing;
+export const mcpIsInitialized = () => mcpStore.isInitialized;
+export const mcpError = () => mcpStore.error;
+export const mcpIsEnabled = () => mcpStore.isEnabled;
+export const mcpAvailableTools = () => mcpStore.availableTools;
+export const mcpConnectedServerCount = () => mcpStore.connectedServerCount;
+export const mcpConnectedServerNames = () => mcpStore.connectedServerNames;
+export const mcpToolCount = () => mcpStore.toolCount;
+export const mcpServerInstructions = () => mcpStore.getServerInstructions();
+export const mcpHasServerInstructions = () => mcpStore.hasServerInstructions();
+
+// Resources exports
+export const mcpHasResourcesCapability = () => mcpStore.hasResourcesCapability();
+export const mcpServersWithResources = () => mcpStore.getServersWithResources();
+export const mcpResourceContext = () => mcpStore.getResourceContextForChat();
--- /dev/null
+import type { MessageRole } from '$lib/enums';
+import { ToolCallType } from '$lib/enums';
+import type {
+ ApiChatCompletionRequest,
+ ApiChatMessageContentPart,
+ ApiChatMessageData
+} from './api';
+import type { ChatMessageTimings, ChatMessagePromptProgress } from './chat';
+import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from './database';
+
+/**
+ * Agentic orchestration configuration.
+ */
+export interface AgenticConfig {
+ enabled: boolean;
+ maxTurns: number;
+ maxToolPreviewLines: number;
+}
+
+/**
+ * Tool call payload for agentic messages.
+ */
+export type AgenticToolCallPayload = {
+ id: string;
+ type: ToolCallType.FUNCTION;
+ function: {
+ name: string;
+ arguments: string;
+ };
+};
+
+/**
+ * Agentic message types for different roles.
+ */
+export type AgenticMessage =
+ | {
+ role: MessageRole.SYSTEM | MessageRole.USER;
+ content: string | ApiChatMessageContentPart[];
+ }
+ | {
+ role: MessageRole.ASSISTANT;
+ content?: string | ApiChatMessageContentPart[];
+ tool_calls?: AgenticToolCallPayload[];
+ }
+ | {
+ role: MessageRole.TOOL;
+ tool_call_id: string;
+ content: string | ApiChatMessageContentPart[];
+ };
+
+export type AgenticAssistantMessage = Extract<AgenticMessage, { role: MessageRole.ASSISTANT }>;
+export type AgenticToolCallList = NonNullable<AgenticAssistantMessage['tool_calls']>;
+
+export type AgenticChatCompletionRequest = Omit<ApiChatCompletionRequest, 'messages'> & {
+ messages: AgenticMessage[];
+ stream: true;
+ tools?: ApiChatCompletionRequest['tools'];
+};
+
+/**
+ * Per-conversation agentic session state.
+ * Enables parallel agentic flows across multiple chats.
+ */
+export interface AgenticSession {
+ isRunning: boolean;
+ currentTurn: number;
+ totalToolCalls: number;
+ lastError: Error | null;
+ streamingToolCall: { name: string; arguments: string } | null;
+}
+
+/**
+ * Callbacks for agentic flow execution
+ */
+export interface AgenticFlowCallbacks {
+ onChunk?: (chunk: string) => void;
+ onReasoningChunk?: (chunk: string) => void;
+ onToolCallChunk?: (serializedToolCalls: string) => void;
+ onAttachments?: (extras: DatabaseMessageExtra[]) => void;
+ onModel?: (model: string) => void;
+ onComplete?: (
+ content: string,
+ reasoningContent?: string,
+ timings?: ChatMessageTimings,
+ toolCalls?: string
+ ) => void;
+ onError?: (error: Error) => void;
+ onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
+ onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void;
+}
+
+/**
+ * Options for agentic flow execution
+ */
+export interface AgenticFlowOptions {
+ stream?: boolean;
+ model?: string;
+ temperature?: number;
+ max_tokens?: number;
+ [key: string]: unknown;
+}
+
+/**
+ * Parameters for starting an agentic flow
+ */
+export interface AgenticFlowParams {
+ conversationId: string;
+ messages: (ApiChatMessageData | (DatabaseMessage & { extra?: DatabaseMessageExtra[] }))[];
+ options?: AgenticFlowOptions;
+ callbacks: AgenticFlowCallbacks;
+ signal?: AbortSignal;
+ perChatOverrides?: McpServerOverride[];
+}
+
+/**
+ * Result of an agentic flow execution
+ */
+export interface AgenticFlowResult {
+ handled: boolean;
+ error?: Error;
+}
file: File;
preview?: string;
textContent?: string;
+ mcpPrompt?: {
+ serverName: string;
+ promptName: string;
+ arguments?: Record<string, string>;
+ };
isLoading?: boolean;
loadError?: string;
}
size?: number;
preview?: string;
isImage: boolean;
+ isMcpPrompt?: boolean;
+ isMcpResource?: boolean;
isLoading?: boolean;
loadError?: string;
uploadedFile?: ChatUploadedFile;
predicted_n?: number;
prompt_ms?: number;
prompt_n?: number;
+ agentic?: ChatMessageAgenticTimings;
+}
+
+export interface ChatMessageAgenticTimings {
+ turns: number;
+ toolCallsCount: number;
+ toolsMs: number;
+ toolCalls?: ChatMessageToolCallTiming[];
+ perTurn?: ChatMessageAgenticTurnStats[];
+ llm: {
+ predicted_n: number;
+ predicted_ms: number;
+ prompt_n: number;
+ prompt_ms: number;
+ };
+}
+
+export interface ChatMessageAgenticTurnStats {
+ turn: number;
+ llm: {
+ predicted_n: number;
+ predicted_ms: number;
+ prompt_n: number;
+ prompt_ms: number;
+ };
+ toolCalls: ChatMessageToolCallTiming[];
+ toolsMs: number;
+}
+
+export interface ChatMessageToolCallTiming {
+ name: string;
+ duration_ms: number;
+ success: boolean;
}
/**
toolCallContent?: string
) => void;
onError?: (error: Error) => void;
+ onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void;
}
/**
import type { AttachmentType } from '$lib/enums';
+/**
+ * Common utility types used across the application
+ */
+
/**
* Common utility types used across the application
*/
}
/**
- * Parsed result from clipboard content.
+ * Format for MCP prompt attachments when copied to clipboard
+ */
+export interface ClipboardMcpPromptAttachment {
+ type: typeof AttachmentType.MCP_PROMPT;
+ name: string;
+ serverName: string;
+ promptName: string;
+ content: string;
+ arguments?: Record<string, string>;
+}
+
+/**
+ * Union type for all clipboard attachment types
+ */
+export type ClipboardAttachment = ClipboardTextAttachment | ClipboardMcpPromptAttachment;
+
+/**
+ * Parsed result from clipboard content
*/
export interface ParsedClipboardContent {
message: string;
textAttachments: ClipboardTextAttachment[];
+ mcpPromptAttachments: ClipboardMcpPromptAttachment[];
}
export type MimeTypeUnion = MimeTypeAudio | MimeTypeImage | MimeTypeApplication | MimeTypeText;
import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat';
import { AttachmentType } from '$lib/enums';
+export interface McpServerOverride {
+ serverId: string;
+ enabled: boolean;
+}
+
export interface DatabaseConversation {
currNode: string | null;
id: string;
lastModified: number;
name: string;
+ mcpServerOverrides?: McpServerOverride[];
}
export interface DatabaseMessageExtraAudioFile {
content: string;
}
+export interface DatabaseMessageExtraMcpPrompt {
+ type: AttachmentType.MCP_PROMPT;
+ name: string;
+ serverName: string;
+ promptName: string;
+ content: string;
+ arguments?: Record<string, string>;
+}
+
+export interface DatabaseMessageExtraMcpResource {
+ type: AttachmentType.MCP_RESOURCE;
+ name: string;
+ uri: string;
+ serverName: string;
+ content: string;
+ mimeType?: string;
+}
+
export type DatabaseMessageExtra =
| DatabaseMessageExtraImageFile
| DatabaseMessageExtraTextFile
| DatabaseMessageExtraAudioFile
| DatabaseMessageExtraPdfFile
+ | DatabaseMessageExtraMcpPrompt
+ | DatabaseMessageExtraMcpResource
| DatabaseMessageExtraLegacyContext;
export interface DatabaseMessage {
ChatMessageSiblingInfo,
ChatMessagePromptProgress,
ChatMessageTimings,
+ ChatMessageAgenticTimings,
+ ChatMessageAgenticTurnStats,
+ ChatMessageToolCallTiming,
ChatStreamCallbacks,
ErrorDialogState,
LiveProcessingStats,
// Database types
export type {
+ McpServerOverride,
DatabaseConversation,
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraImageFile,
DatabaseMessageExtraLegacyContext,
+ DatabaseMessageExtraMcpPrompt,
+ DatabaseMessageExtraMcpResource,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtra,
KeyValuePair,
BinaryDetectionOptions,
ClipboardTextAttachment,
+ ClipboardMcpPromptAttachment,
+ ClipboardAttachment,
ParsedClipboardContent
} from './common';
+
+// MCP types
+export type {
+ ClientCapabilities,
+ ServerCapabilities,
+ Implementation,
+ MCPConnectionLog,
+ MCPServerInfo,
+ MCPCapabilitiesInfo,
+ MCPToolInfo,
+ MCPPromptInfo,
+ MCPConnectionDetails,
+ MCPPhaseCallback,
+ MCPConnection,
+ HealthCheckState,
+ HealthCheckParams,
+ MCPServerConfig,
+ MCPClientConfig,
+ MCPServerSettingsEntry,
+ MCPToolCall,
+ OpenAIToolDefinition,
+ ServerStatus,
+ ToolCallParams,
+ ToolExecutionResult,
+ Tool,
+ Prompt,
+ GetPromptResult,
+ PromptMessage,
+ MCPProgressState,
+ MCPResourceAnnotations,
+ MCPResourceIcon,
+ MCPResource,
+ MCPResourceTemplate,
+ MCPTextResourceContent,
+ MCPBlobResourceContent,
+ MCPResourceContent,
+ MCPReadResourceResult,
+ MCPResourceInfo,
+ MCPResourceTemplateInfo,
+ MCPCachedResource,
+ MCPResourceAttachment,
+ MCPResourceSubscription,
+ MCPServerResources
+} from './mcp';
+
+// Agentic types
+export type {
+ AgenticConfig,
+ AgenticToolCallPayload,
+ AgenticMessage,
+ AgenticAssistantMessage,
+ AgenticToolCallList,
+ AgenticChatCompletionRequest,
+ AgenticSession,
+ AgenticFlowCallbacks,
+ AgenticFlowOptions,
+ AgenticFlowParams,
+ AgenticFlowResult
+} from './agentic';
--- /dev/null
+import type { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums/mcp';
+import type {
+ Client,
+ ClientCapabilities as SDKClientCapabilities,
+ ServerCapabilities as SDKServerCapabilities,
+ Implementation as SDKImplementation,
+ Tool,
+ CallToolResult,
+ Prompt,
+ GetPromptResult,
+ PromptMessage,
+ Transport
+} from '@modelcontextprotocol/sdk';
+import type { MimeTypeUnion } from './common';
+import type { ColorMode } from '$lib/enums';
+
+export type { Tool, CallToolResult, Prompt, GetPromptResult, PromptMessage };
+export type ClientCapabilities = SDKClientCapabilities;
+export type ServerCapabilities = SDKServerCapabilities;
+export type Implementation = SDKImplementation;
+
+/**
+ * Log entry for connection events
+ */
+export interface MCPConnectionLog {
+ timestamp: Date;
+ phase: MCPConnectionPhase;
+ message: string;
+ details?: unknown;
+ level: MCPLogLevel;
+}
+
+/**
+ * Server information returned after initialization
+ */
+export interface MCPServerInfo {
+ name: string;
+ version: string;
+ title?: string;
+ description?: string;
+ websiteUrl?: string;
+ icons?: MCPResourceIcon[];
+}
+
+/**
+ * Detailed capabilities information
+ */
+export interface MCPCapabilitiesInfo {
+ server: {
+ tools?: { listChanged?: boolean };
+ prompts?: { listChanged?: boolean };
+ resources?: { subscribe?: boolean; listChanged?: boolean };
+ logging?: boolean;
+ completions?: boolean;
+ tasks?: boolean;
+ };
+ client: {
+ roots?: { listChanged?: boolean };
+ sampling?: boolean;
+ elicitation?: { form?: boolean; url?: boolean };
+ tasks?: boolean;
+ };
+}
+
+/**
+ * Tool information for display
+ */
+export interface MCPToolInfo {
+ name: string;
+ description?: string;
+ title?: string;
+}
+
+/**
+ * Prompt information for display
+ */
+export interface MCPPromptInfo {
+ name: string;
+ description?: string;
+ title?: string;
+ serverName: string;
+ arguments?: Array<{
+ name: string;
+ description?: string;
+ required?: boolean;
+ }>;
+}
+
+/**
+ * Full connection details for visualization
+ */
+export interface MCPConnectionDetails {
+ phase: MCPConnectionPhase;
+ transportType?: MCPTransportType;
+ protocolVersion?: string;
+ serverInfo?: MCPServerInfo;
+ capabilities?: MCPCapabilitiesInfo;
+ instructions?: string;
+ tools: MCPToolInfo[];
+ connectionTimeMs?: number;
+ error?: string;
+ logs: MCPConnectionLog[];
+}
+
+/**
+ * Callback for connection phase changes
+ */
+export type MCPPhaseCallback = (
+ phase: MCPConnectionPhase,
+ log: MCPConnectionLog,
+ details?: {
+ transportType?: MCPTransportType;
+ serverInfo?: MCPServerInfo;
+ serverCapabilities?: ServerCapabilities;
+ clientCapabilities?: ClientCapabilities;
+ protocolVersion?: string;
+ instructions?: string;
+ }
+) => void;
+
+/**
+ * Represents an active MCP server connection.
+ * Returned by MCPService.connect() and used for subsequent operations.
+ */
+export interface MCPConnection {
+ client: Client;
+ transport: Transport;
+ tools: Tool[];
+ serverName: string;
+ transportType: MCPTransportType;
+ serverInfo?: MCPServerInfo;
+ serverCapabilities?: ServerCapabilities;
+ clientCapabilities?: ClientCapabilities;
+ protocolVersion?: string;
+ instructions?: string;
+ connectionTimeMs: number;
+}
+
+/**
+ * Extended health check state with detailed connection info
+ */
+export type HealthCheckState =
+ | { status: HealthCheckStatus.IDLE }
+ | {
+ status: HealthCheckStatus.CONNECTING;
+ phase: MCPConnectionPhase;
+ logs: MCPConnectionLog[];
+ }
+ | {
+ status: HealthCheckStatus.ERROR;
+ message: string;
+ phase?: MCPConnectionPhase;
+ logs: MCPConnectionLog[];
+ }
+ | {
+ status: HealthCheckStatus.SUCCESS;
+ tools: MCPToolInfo[];
+ serverInfo?: MCPServerInfo;
+ capabilities?: MCPCapabilitiesInfo;
+ transportType?: MCPTransportType;
+ protocolVersion?: string;
+ instructions?: string;
+ connectionTimeMs?: number;
+ logs: MCPConnectionLog[];
+ };
+
+/**
+ * Health check parameters
+ */
+export interface HealthCheckParams {
+ id: string;
+ enabled: boolean;
+ url: string;
+ requestTimeoutSeconds: number;
+ headers?: string;
+ useProxy?: boolean;
+}
+
+export type MCPServerConfig = {
+ transport?: MCPTransportType;
+ url: string;
+ protocols?: string | string[];
+ headers?: Record<string, string>;
+ credentials?: RequestCredentials;
+ handshakeTimeoutMs?: number;
+ requestTimeoutMs?: number;
+ capabilities?: ClientCapabilities;
+ useProxy?: boolean;
+};
+
+export type MCPClientConfig = {
+ servers: Record<string, MCPServerConfig>;
+ protocolVersion?: string;
+ capabilities?: ClientCapabilities;
+ clientInfo?: Implementation;
+ requestTimeoutMs?: number;
+};
+
+export type MCPToolCallArguments = Record<string, unknown>;
+
+export type MCPToolCall = {
+ id: string;
+ function: {
+ name: string;
+ arguments: string | MCPToolCallArguments;
+ };
+};
+
+export type MCPServerSettingsEntry = {
+ id: string;
+ enabled: boolean;
+ url: string;
+ requestTimeoutSeconds: number;
+ headers?: string;
+ name?: string;
+ iconUrl?: string;
+ useProxy?: boolean;
+};
+
+export interface MCPHostManagerConfig {
+ servers: MCPClientConfig['servers'];
+ clientInfo?: Implementation;
+ capabilities?: ClientCapabilities;
+}
+
+export interface OpenAIToolDefinition {
+ type: 'function';
+ function: {
+ name: string;
+ description?: string;
+ parameters: Record<string, unknown>;
+ };
+}
+
+export interface ServerStatus {
+ name: string;
+ isConnected: boolean;
+ toolCount: number;
+ error?: string;
+}
+
+export interface MCPServerConnectionConfig {
+ name: string;
+ server: MCPServerConfig;
+ clientInfo?: Implementation;
+ capabilities?: ClientCapabilities;
+}
+
+export interface ToolCallParams {
+ name: string;
+ arguments: Record<string, unknown>;
+}
+
+export interface ToolExecutionResult {
+ content: string;
+ isError: boolean;
+}
+
+/**
+ * Progress tracking state for a specific operation
+ */
+export interface MCPProgressState {
+ progressToken: string | number;
+ serverName: string;
+ progress: number;
+ total?: number;
+ message?: string;
+ startTime: Date;
+ lastUpdate: Date;
+}
+
+/**
+ * Resource annotations for audience and priority hints
+ */
+export interface MCPResourceAnnotations {
+ audience?: ('user' | 'assistant')[];
+ priority?: number;
+ lastModified?: string;
+}
+
+/**
+ * Icon definition for resources
+ */
+export interface MCPResourceIcon {
+ src: string;
+ mimeType?: MimeTypeUnion;
+ sizes?: string[];
+ theme?: ColorMode.LIGHT | ColorMode.DARK;
+}
+
+/**
+ * A known resource that the server is capable of reading
+ */
+export interface MCPResource {
+ uri: string;
+ name: string;
+ title?: string;
+ description?: string;
+ mimeType?: MimeTypeUnion;
+ annotations?: MCPResourceAnnotations;
+ icons?: MCPResourceIcon[];
+ _meta?: Record<string, unknown>;
+}
+
+/**
+ * A template for dynamically generating resource URIs
+ */
+export interface MCPResourceTemplate {
+ uriTemplate: string;
+ name: string;
+ title?: string;
+ description?: string;
+ mimeType?: MimeTypeUnion;
+ annotations?: MCPResourceAnnotations;
+ icons?: MCPResourceIcon[];
+ _meta?: Record<string, unknown>;
+}
+
+/**
+ * Text content from a resource
+ */
+export interface MCPTextResourceContent {
+ uri: string;
+ mimeType?: MimeTypeUnion;
+ text: string;
+}
+
+/**
+ * Binary (blob) content from a resource
+ */
+export interface MCPBlobResourceContent {
+ uri: string;
+ mimeType?: MimeTypeUnion;
+ /** Base64-encoded binary data */
+ blob: string;
+}
+
+/**
+ * Union type for resource content
+ */
+export type MCPResourceContent = MCPTextResourceContent | MCPBlobResourceContent;
+
+/**
+ * Result from reading a resource
+ */
+export interface MCPReadResourceResult {
+ contents: MCPResourceContent[];
+ _meta?: Record<string, unknown>;
+}
+
+/**
+ * Resource information for display in UI
+ */
+export interface MCPResourceInfo {
+ uri: string;
+ name: string;
+ title?: string;
+ description?: string;
+ mimeType?: MimeTypeUnion;
+ serverName: string;
+ annotations?: MCPResourceAnnotations;
+ icons?: MCPResourceIcon[];
+}
+
+/**
+ * Resource template information for display in UI
+ */
+export interface MCPResourceTemplateInfo {
+ uriTemplate: string;
+ name: string;
+ title?: string;
+ description?: string;
+ mimeType?: MimeTypeUnion;
+ serverName: string;
+ annotations?: MCPResourceAnnotations;
+ icons?: MCPResourceIcon[];
+}
+
+/**
+ * Cached resource content with metadata
+ */
+export interface MCPCachedResource {
+ resource: MCPResourceInfo;
+ content: MCPResourceContent[];
+ fetchedAt: Date;
+ /** Whether this resource has an active subscription */
+ subscribed?: boolean;
+}
+
+/**
+ * Resource attachment for chat context
+ */
+export interface MCPResourceAttachment {
+ id: string;
+ resource: MCPResourceInfo;
+ content?: MCPResourceContent[];
+ loading?: boolean;
+ error?: string;
+}
+
+/**
+ * State for resource subscriptions
+ */
+export interface MCPResourceSubscription {
+ uri: string;
+ serverName: string;
+ subscribedAt: Date;
+ lastUpdate?: Date;
+}
+
+/**
+ * Aggregated resources state per server
+ */
+export interface MCPServerResources {
+ serverName: string;
+ resources: MCPResource[];
+ templates: MCPResourceTemplate[];
+ lastFetched?: Date;
+ loading: boolean;
+ error?: string;
+}
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants';
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
+import type { OpenAIToolDefinition } from './mcp';
import type { DatabaseMessageExtra } from './database';
import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
+import type { Icon } from '@lucide/svelte';
export type SettingsConfigValue = string | number | boolean;
type: SettingsFieldType;
isExperimental?: boolean;
help?: string;
- options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
+ options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
}
export interface SettingsChatServiceOptions {
systemMessage?: string;
// Disable reasoning parsing (use 'none' instead of 'auto')
disableReasoningParsing?: boolean;
+ tools?: OpenAIToolDefinition[];
// Generation parameters
temperature?: number;
max_tokens?: number;
--- /dev/null
+import { AgenticSectionType } from '$lib/enums';
+import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS, TRIM_NEWLINES_REGEX } from '$lib/constants';
+
+/**
+ * Represents a parsed section of agentic content
+ */
+export interface AgenticSection {
+ type: AgenticSectionType;
+ content: string;
+ toolName?: string;
+ toolArgs?: string;
+ toolResult?: string;
+}
+
+/**
+ * Represents a segment of content that may contain reasoning blocks
+ */
+type ReasoningSegment = {
+ type:
+ | AgenticSectionType.TEXT
+ | AgenticSectionType.REASONING
+ | AgenticSectionType.REASONING_PENDING;
+ content: string;
+};
+
+/**
+ * Parses agentic content into structured sections
+ *
+ * Main parsing function that processes content containing:
+ * - Tool calls (completed, pending, or streaming)
+ * - Reasoning blocks (completed or streaming)
+ * - Regular text content
+ *
+ * The parser handles chronological display of agentic flow output, maintaining
+ * the order of operations and properly identifying different states of tool calls
+ * and reasoning blocks during streaming.
+ *
+ * @param rawContent - The raw content string to parse
+ * @returns Array of structured agentic sections ready for display
+ *
+ * @example
+ * ```typescript
+ * const content = "Some text <<<AGENTIC_TOOL_CALL>>>tool_name...";
+ * const sections = parseAgenticContent(content);
+ * // Returns: [{ type: 'text', content: 'Some text' }, { type: 'tool_call_streaming', ... }]
+ * ```
+ */
+export function parseAgenticContent(rawContent: string): AgenticSection[] {
+ if (!rawContent) return [];
+
+ const segments = splitReasoningSegments(rawContent);
+ const sections: AgenticSection[] = [];
+
+ for (const segment of segments) {
+ if (segment.type === AgenticSectionType.TEXT) {
+ sections.push(...parseToolCallContent(segment.content));
+ continue;
+ }
+
+ if (segment.type === AgenticSectionType.REASONING) {
+ if (segment.content.trim()) {
+ sections.push({ type: AgenticSectionType.REASONING, content: segment.content });
+ }
+ continue;
+ }
+
+ sections.push({
+ type: AgenticSectionType.REASONING_PENDING,
+ content: segment.content
+ });
+ }
+
+ return sections;
+}
+
+/**
+ * Parses content containing tool call markers
+ *
+ * Identifies and extracts tool calls from content, handling:
+ * - Completed tool calls with name, arguments, and results
+ * - Pending tool calls (execution in progress)
+ * - Streaming tool calls (arguments being received)
+ * - Early-stage tool calls (just started)
+ *
+ * @param rawContent - The raw content string to parse
+ * @returns Array of agentic sections representing tool calls and text
+ */
+function parseToolCallContent(rawContent: string): AgenticSection[] {
+ if (!rawContent) return [];
+
+ const sections: AgenticSection[] = [];
+
+ const completedToolCallRegex = new RegExp(AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
+
+ let lastIndex = 0;
+ let match;
+
+ while ((match = completedToolCallRegex.exec(rawContent)) !== null) {
+ if (match.index > lastIndex) {
+ const textBefore = rawContent.slice(lastIndex, match.index).trim();
+ if (textBefore) {
+ sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
+ }
+ }
+
+ const toolName = match[1];
+ const toolArgs = match[2];
+ const toolResult = match[3].replace(TRIM_NEWLINES_REGEX, '');
+
+ sections.push({
+ type: AgenticSectionType.TOOL_CALL,
+ content: toolResult,
+ toolName,
+ toolArgs,
+ toolResult
+ });
+
+ lastIndex = match.index + match[0].length;
+ }
+
+ const remainingContent = rawContent.slice(lastIndex);
+
+ const pendingMatch = remainingContent.match(AGENTIC_REGEX.PENDING_TOOL_CALL);
+ const partialWithNameMatch = remainingContent.match(AGENTIC_REGEX.PARTIAL_WITH_NAME);
+ const earlyMatch = remainingContent.match(AGENTIC_REGEX.EARLY_MATCH);
+
+ if (pendingMatch) {
+ const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
+
+ if (pendingIndex > 0) {
+ const textBefore = remainingContent.slice(0, pendingIndex).trim();
+
+ if (textBefore) {
+ sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
+ }
+ }
+
+ const toolName = pendingMatch[1];
+ const toolArgs = pendingMatch[2];
+ const streamingResult = (pendingMatch[3] || '').replace(TRIM_NEWLINES_REGEX, '');
+
+ sections.push({
+ type: AgenticSectionType.TOOL_CALL_PENDING,
+ content: streamingResult,
+ toolName,
+ toolArgs,
+ toolResult: streamingResult || undefined
+ });
+ } else if (partialWithNameMatch) {
+ const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
+
+ if (pendingIndex > 0) {
+ const textBefore = remainingContent.slice(0, pendingIndex).trim();
+ if (textBefore) {
+ sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
+ }
+ }
+
+ const partialArgs = partialWithNameMatch[2] || '';
+
+ sections.push({
+ type: AgenticSectionType.TOOL_CALL_STREAMING,
+ content: '',
+ toolName: partialWithNameMatch[1],
+ toolArgs: partialArgs || undefined,
+ toolResult: undefined
+ });
+ } else if (earlyMatch) {
+ const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
+
+ if (pendingIndex > 0) {
+ const textBefore = remainingContent.slice(0, pendingIndex).trim();
+ if (textBefore) {
+ sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
+ }
+ }
+
+ const nameMatch = earlyMatch[1]?.match(AGENTIC_REGEX.TOOL_NAME_EXTRACT);
+
+ sections.push({
+ type: AgenticSectionType.TOOL_CALL_STREAMING,
+ content: '',
+ toolName: nameMatch?.[1],
+ toolArgs: undefined,
+ toolResult: undefined
+ });
+ } else if (lastIndex < rawContent.length) {
+ let remainingText = rawContent.slice(lastIndex).trim();
+
+ const partialMarkerMatch = remainingText.match(AGENTIC_REGEX.PARTIAL_MARKER);
+
+ if (partialMarkerMatch) {
+ remainingText = remainingText.slice(0, partialMarkerMatch.index).trim();
+ }
+
+ if (remainingText) {
+ sections.push({ type: AgenticSectionType.TEXT, content: remainingText });
+ }
+ }
+
+ if (sections.length === 0 && rawContent.trim()) {
+ sections.push({ type: AgenticSectionType.TEXT, content: rawContent });
+ }
+
+ return sections;
+}
+
+/**
+ * Strips partial marker from text content
+ *
+ * Removes incomplete agentic markers (e.g., "<<<", "<<<AGENTIC") that may appear
+ * at the end of streaming content.
+ *
+ * @param text - The text content to process
+ * @returns Text with partial markers removed
+ */
+function stripPartialMarker(text: string): string {
+ const partialMarkerMatch = text.match(AGENTIC_REGEX.PARTIAL_MARKER);
+
+ if (partialMarkerMatch) {
+ return text.slice(0, partialMarkerMatch.index).trim();
+ }
+
+ return text;
+}
+
+/**
+ * Splits raw content into segments based on reasoning blocks
+ *
+ * Identifies and extracts reasoning content wrapped in REASONING_TAGS.START/END markers,
+ * separating it from regular text content. Handles both complete and incomplete
+ * (streaming) reasoning blocks.
+ *
+ * @param rawContent - The raw content string to parse
+ * @returns Array of reasoning segments with their types and content
+ */
+function splitReasoningSegments(rawContent: string): ReasoningSegment[] {
+ if (!rawContent) return [];
+
+ const segments: ReasoningSegment[] = [];
+ let cursor = 0;
+
+ while (cursor < rawContent.length) {
+ const startIndex = rawContent.indexOf(REASONING_TAGS.START, cursor);
+
+ if (startIndex === -1) {
+ const remainingText = rawContent.slice(cursor);
+
+ if (remainingText) {
+ segments.push({ type: AgenticSectionType.TEXT, content: remainingText });
+ }
+
+ break;
+ }
+
+ if (startIndex > cursor) {
+ const textBefore = rawContent.slice(cursor, startIndex);
+
+ if (textBefore) {
+ segments.push({ type: AgenticSectionType.TEXT, content: textBefore });
+ }
+ }
+
+ const contentStart = startIndex + REASONING_TAGS.START.length;
+ const endIndex = rawContent.indexOf(REASONING_TAGS.END, contentStart);
+
+ if (endIndex === -1) {
+ const pendingContent = rawContent.slice(contentStart);
+
+ segments.push({
+ type: AgenticSectionType.REASONING_PENDING,
+ content: stripPartialMarker(pendingContent)
+ });
+
+ break;
+ }
+
+ const reasoningContent = rawContent.slice(contentStart, endIndex);
+ segments.push({ type: AgenticSectionType.REASONING, content: reasoningContent });
+ cursor = endIndex + REASONING_TAGS.END.length;
+ }
+
+ return segments;
+}
import { base } from '$app/paths';
import { getJsonHeaders, getAuthHeaders } from './api-headers';
-import { UrlPrefix } from '$lib/enums';
+import { UrlProtocol } from '$lib/enums';
/**
* API Fetch Utilities
const headers = { ...baseHeaders, ...customHeaders };
const url =
- path.startsWith(UrlPrefix.HTTP) || path.startsWith(UrlPrefix.HTTPS) ? path : `${base}${path}`;
+ path.startsWith(UrlProtocol.HTTP) || path.startsWith(UrlProtocol.HTTPS)
+ ? path
+ : `${base}${path}`;
const response = await fetch(url, {
...fetchOptions,
-import { FileTypeCategory } from '$lib/enums';
+import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
+import type {
+ AttachmentDisplayItemsOptions,
+ ChatUploadedFile,
+ DatabaseMessageExtra
+} from '$lib/types';
-export interface AttachmentDisplayItemsOptions {
- uploadedFiles?: ChatUploadedFile[];
- attachments?: DatabaseMessageExtra[];
+/**
+ * Check if an uploaded file is an MCP prompt
+ */
+function isMcpPromptUpload(file: ChatUploadedFile): boolean {
+ return file.type === SpecialFileType.MCP_PROMPT && !!file.mcpPrompt;
+}
+
+/**
+ * Check if an attachment is an MCP prompt
+ */
+function isMcpPromptAttachment(attachment: DatabaseMessageExtra): boolean {
+ return attachment.type === AttachmentType.MCP_PROMPT;
+}
+
+/**
+ * Check if an attachment is an MCP resource
+ */
+function isMcpResourceAttachment(attachment: DatabaseMessageExtra): boolean {
+ return attachment.type === AttachmentType.MCP_RESOURCE;
}
/**
size: file.size,
preview: file.preview,
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
+ isMcpPrompt: isMcpPromptUpload(file),
+ isLoading: file.isLoading,
+ loadError: file.loadError,
uploadedFile: file,
textContent: file.textContent
});
// Add stored attachments (ChatMessage)
for (const [index, attachment] of attachments.entries()) {
const isImage = isImageFile(attachment);
+ const isMcpPrompt = isMcpPromptAttachment(attachment);
+ const isMcpResource = isMcpResourceAttachment(attachment);
items.push({
id: `attachment-${index}`,
name: attachment.name,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
isImage,
+ isMcpPrompt,
+ isMcpResource,
attachment,
attachmentIndex: index,
textContent: 'content' in attachment ? attachment.content : undefined
DatabaseMessageExtra,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraLegacyContext,
+ DatabaseMessageExtraMcpPrompt,
+ DatabaseMessageExtraMcpResource,
ClipboardTextAttachment,
+ ClipboardMcpPromptAttachment,
+ ClipboardAttachment,
ParsedClipboardContent
} from '$lib/types';
extras?: DatabaseMessageExtra[],
asPlainText: boolean = false
): string {
- // Filter only text attachments (TEXT type and legacy CONTEXT type)
+ // Filter text-like attachments (TEXT, LEGACY_CONTEXT, MCP_PROMPT, and MCP_RESOURCE types)
const textAttachments =
extras?.filter(
- (extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext =>
- extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT
+ (
+ extra
+ ): extra is
+ | DatabaseMessageExtraTextFile
+ | DatabaseMessageExtraLegacyContext
+ | DatabaseMessageExtraMcpPrompt
+ | DatabaseMessageExtraMcpResource =>
+ extra.type === AttachmentType.TEXT ||
+ extra.type === AttachmentType.LEGACY_CONTEXT ||
+ extra.type === AttachmentType.MCP_PROMPT ||
+ extra.type === AttachmentType.MCP_RESOURCE
) ?? [];
if (textAttachments.length === 0) {
return parts.join('\n\n');
}
- const clipboardAttachments: ClipboardTextAttachment[] = textAttachments.map((att) => ({
- type: AttachmentType.TEXT,
- name: att.name,
- content: att.content
- }));
+ const clipboardAttachments: ClipboardAttachment[] = textAttachments.map((att) => {
+ if (att.type === AttachmentType.MCP_PROMPT) {
+ const mcpAtt = att as DatabaseMessageExtraMcpPrompt;
+ return {
+ type: AttachmentType.MCP_PROMPT,
+ name: mcpAtt.name,
+ serverName: mcpAtt.serverName,
+ promptName: mcpAtt.promptName,
+ content: mcpAtt.content,
+ arguments: mcpAtt.arguments
+ } as ClipboardMcpPromptAttachment;
+ }
+ return {
+ type: AttachmentType.TEXT,
+ name: att.name,
+ content: att.content
+ } as ClipboardTextAttachment;
+ });
return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`;
}
export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
const defaultResult: ParsedClipboardContent = {
message: clipboardText,
- textAttachments: []
+ textAttachments: [],
+ mcpPromptAttachments: []
};
if (!clipboardText.startsWith('"')) {
if (!remainingPart || !remainingPart.startsWith('[')) {
return {
message,
- textAttachments: []
+ textAttachments: [],
+ mcpPromptAttachments: []
};
}
const attachments = JSON.parse(remainingPart) as unknown[];
- const validAttachments: ClipboardTextAttachment[] = [];
+ const validTextAttachments: ClipboardTextAttachment[] = [];
+ const validMcpPromptAttachments: ClipboardMcpPromptAttachment[] = [];
for (const att of attachments) {
- if (isValidTextAttachment(att)) {
- validAttachments.push({
+ if (isValidMcpPromptAttachment(att)) {
+ validMcpPromptAttachments.push({
+ type: AttachmentType.MCP_PROMPT,
+ name: att.name,
+ serverName: att.serverName,
+ promptName: att.promptName,
+ content: att.content,
+ arguments: att.arguments
+ });
+ } else if (isValidTextAttachment(att)) {
+ validTextAttachments.push({
type: AttachmentType.TEXT,
name: att.name,
content: att.content
return {
message,
- textAttachments: validAttachments
+ textAttachments: validTextAttachments,
+ mcpPromptAttachments: validMcpPromptAttachments
};
} catch {
return defaultResult;
}
}
+/**
+ * Type guard to validate an MCP prompt attachment object
+ * @param obj The object to validate
+ * @returns true if the object is a valid MCP prompt attachment
+ */
+function isValidMcpPromptAttachment(obj: unknown): obj is {
+ type: string;
+ name: string;
+ serverName: string;
+ promptName: string;
+ content: string;
+ arguments?: Record<string, string>;
+} {
+ if (typeof obj !== 'object' || obj === null) {
+ return false;
+ }
+
+ const record = obj as Record<string, unknown>;
+
+ return (
+ (record.type === AttachmentType.MCP_PROMPT || record.type === 'MCP_PROMPT') &&
+ typeof record.name === 'string' &&
+ typeof record.serverName === 'string' &&
+ typeof record.promptName === 'string' &&
+ typeof record.content === 'string'
+ );
+}
+
/**
* Type guard to validate a text attachment object
* @param obj The object to validate
}
const parsed = parseClipboardContent(clipboardText);
- return parsed.textAttachments.length > 0;
+ return parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0;
}
import { convertPDFToImage, convertPDFToText } from './pdf-processing';
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
-import { FileTypeCategory, AttachmentType } from '$lib/enums';
+import { FileTypeCategory, AttachmentType, SpecialFileType } from '$lib/enums';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { getFileTypeCategory } from '$lib/utils';
const emptyFiles: string[] = [];
for (const file of files) {
+ if (file.type === SpecialFileType.MCP_PROMPT && file.mcpPrompt) {
+ extras.push({
+ type: AttachmentType.MCP_PROMPT,
+ name: file.name,
+ serverName: file.mcpPrompt.serverName,
+ promptName: file.mcpPrompt.promptName,
+ content: file.textContent ?? '',
+ arguments: file.mcpPrompt.arguments
+ });
+
+ continue;
+ }
+
if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
if (file.preview) {
let base64Url = file.preview;
--- /dev/null
+/**
+ * CORS Proxy utility for routing requests through llama-server's CORS proxy.
+ */
+
+import { base } from '$app/paths';
+import { CORS_PROXY_ENDPOINT, CORS_PROXY_URL_PARAM } from '$lib/constants';
+
+/**
+ * Build a proxied URL that routes through llama-server's CORS proxy.
+ * @param targetUrl - The original URL to proxy
+ * @returns URL pointing to the CORS proxy with target encoded
+ */
+export function buildProxiedUrl(targetUrl: string): URL {
+ const proxyPath = `${base}${CORS_PROXY_ENDPOINT}`;
+ const proxyUrl = new URL(proxyPath, window.location.origin);
+
+ proxyUrl.searchParams.set(CORS_PROXY_URL_PARAM, targetUrl);
+
+ return proxyUrl;
+}
+
+/**
+ * Get a proxied URL string for use in fetch requests.
+ * @param targetUrl - The original URL to proxy
+ * @returns Proxied URL as string
+ */
+export function getProxiedUrlString(targetUrl: string): string {
+ return buildProxiedUrl(targetUrl).href;
+}
--- /dev/null
+/**
+ * Favicon utility functions for extracting favicons from URLs.
+ */
+
+import { getProxiedUrlString } from './cors-proxy';
+import {
+ GOOGLE_FAVICON_BASE_URL,
+ DEFAULT_FAVICON_SIZE,
+ DOMAIN_SEPARATOR,
+ ROOT_DOMAIN_MIN_PARTS
+} from '$lib/constants';
+
+/**
+ * Gets a favicon URL for a given URL using Google's favicon service.
+ * Returns null if the URL is invalid.
+ *
+ * @param urlString - The URL to get the favicon for
+ * @returns The favicon URL or null if invalid
+ */
+export function getFaviconUrl(urlString: string): string | null {
+ try {
+ const url = new URL(urlString);
+ const hostnameParts = url.hostname.split(DOMAIN_SEPARATOR);
+ const rootDomain =
+ hostnameParts.length >= ROOT_DOMAIN_MIN_PARTS
+ ? hostnameParts.slice(-ROOT_DOMAIN_MIN_PARTS).join(DOMAIN_SEPARATOR)
+ : url.hostname;
+
+ const googleFaviconUrl = `${GOOGLE_FAVICON_BASE_URL}?domain=${rootDomain}&sz=${DEFAULT_FAVICON_SIZE}`;
+ return getProxiedUrlString(googleFaviconUrl);
+ } catch {
+ return null;
+ }
+}
--- /dev/null
+/**
+ * Header utilities for parsing and serializing HTTP headers.
+ * Generic utilities not specific to MCP.
+ */
+
+/**
+ * Parses a JSON string of headers into an array of key-value pairs.
+ * Returns empty array if the JSON is invalid or empty.
+ */
+export function parseHeadersToArray(headersJson: string): { key: string; value: string }[] {
+ if (!headersJson?.trim()) return [];
+
+ try {
+ const parsed = JSON.parse(headersJson);
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
+ return Object.entries(parsed).map(([key, value]) => ({
+ key,
+ value: String(value)
+ }));
+ }
+ } catch {
+ return [];
+ }
+
+ return [];
+}
+
+/**
+ * Serializes an array of header key-value pairs to a JSON string.
+ * Filters out pairs with empty keys and returns empty string if no valid pairs.
+ */
+export function serializeHeaders(pairs: { key: string; value: string }[]): string {
+ const validPairs = pairs.filter((p) => p.key.trim());
+
+ if (validPairs.length === 0) return '';
+
+ const obj: Record<string, string> = {};
+
+ for (const pair of validPairs) {
+ obj[pair.key.trim()] = pair.value;
+ }
+
+ return JSON.stringify(obj);
+}
getPreviousSibling
} from './branching';
+// Code
+export { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from './code';
+
// Config helpers
export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
+// CORS Proxy
+export { buildProxiedUrl, getProxiedUrlString } from './cors-proxy';
+
// Conversation utilities
export { createMessageCountMap, getMessageCount } from './conversation-utils';
// Debounce utilities
export { debounce } from './debounce';
+// Sanitization utilities
+export { sanitizeKeyValuePairKey, sanitizeKeyValuePairValue } from './sanitize';
+
// Image error fallback utilities
export { getImageErrorFallbackHtml } from './image-error-fallback';
+// MCP utilities
+export {
+ detectMcpTransportFromUrl,
+ parseMcpServerSettings,
+ getMcpLogLevelIcon,
+ getMcpLogLevelClass,
+ isImageMimeType,
+ parseResourcePath,
+ getDisplayName,
+ getResourceDisplayName,
+ isCodeResource,
+ isImageResource,
+ getResourceIcon,
+ getResourceTextContent,
+ getResourceBlobContent,
+ downloadResourceContent
+} from './mcp';
+
+// URI Template utilities
+export {
+ extractTemplateVariables,
+ expandTemplate,
+ isTemplateComplete,
+ normalizeResourceUri,
+ type UriTemplateVariable
+} from './uri-template';
+
// Data URL utilities
export { createBase64DataUrl } from './data-url';
+// Header utilities
+export { parseHeadersToArray, serializeHeaders } from './headers';
+
+// Favicon utilities
+export { getFaviconUrl } from './favicon';
+
+// Agentic content parsing utilities
+export { parseAgenticContent, type AgenticSection } from './agentic';
+
// Cache utilities
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
createTimeoutSignal,
withAbortSignal
} from './abort';
+
+// Cryptography utilities
+
+export { uuid } from './uuid';
--- /dev/null
+import type { MCPServerSettingsEntry, MCPResourceContent, MCPResourceInfo } from '$lib/types';
+import {
+ MCPTransportType,
+ MCPLogLevel,
+ UrlProtocol,
+ MimeTypePrefix,
+ MimeTypeIncludes,
+ UriPattern,
+ MimeTypeText
+} from '$lib/enums';
+import {
+ DEFAULT_MCP_CONFIG,
+ MCP_SERVER_ID_PREFIX,
+ IMAGE_FILE_EXTENSION_REGEX,
+ CODE_FILE_EXTENSION_REGEX,
+ TEXT_FILE_EXTENSION_REGEX,
+ PROTOCOL_PREFIX_REGEX,
+ FILE_EXTENSION_REGEX,
+ DISPLAY_NAME_SEPARATOR_REGEX,
+ PATH_SEPARATOR,
+ RESOURCE_TEXT_CONTENT_SEPARATOR,
+ DEFAULT_RESOURCE_FILENAME
+} from '$lib/constants';
+import {
+ Database,
+ File,
+ FileText,
+ Image,
+ Code,
+ Info,
+ AlertTriangle,
+ XCircle
+} from '@lucide/svelte';
+import type { Component } from 'svelte';
+import type { MimeTypeUnion } from '$lib/types/common';
+
+/**
+ * Detects the MCP transport type from a URL.
+ * WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'.
+ */
+export function detectMcpTransportFromUrl(url: string): MCPTransportType {
+ const normalized = url.trim().toLowerCase();
+
+ return normalized.startsWith(UrlProtocol.WEBSOCKET) ||
+ normalized.startsWith(UrlProtocol.WEBSOCKET_SECURE)
+ ? MCPTransportType.WEBSOCKET
+ : MCPTransportType.STREAMABLE_HTTP;
+}
+
+/**
+ * Parses MCP server settings from a JSON string or array.
+ * requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value.
+ * @param rawServers - The raw servers to parse
+ * @returns An empty array if the input is invalid.
+ */
+export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
+ if (!rawServers) return [];
+
+ let parsed: unknown;
+
+ if (typeof rawServers === 'string') {
+ const trimmed = rawServers.trim();
+ if (!trimmed) return [];
+
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch (error) {
+ console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error);
+
+ return [];
+ }
+ } else {
+ parsed = rawServers;
+ }
+
+ if (!Array.isArray(parsed)) return [];
+
+ return parsed.map((entry, index) => {
+ const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
+ const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
+ const id =
+ typeof (entry as { id?: unknown })?.id === 'string' && (entry as { id?: string }).id?.trim()
+ ? (entry as { id: string }).id.trim()
+ : `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
+
+ return {
+ id,
+ enabled: Boolean((entry as { enabled?: unknown })?.enabled),
+ url,
+ name: (entry as { name?: string })?.name,
+ requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
+ headers: headers || undefined,
+ useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
+ } satisfies MCPServerSettingsEntry;
+ });
+}
+
+/**
+ * Get the appropriate icon component for a log level
+ *
+ * @param level - MCP log level
+ * @returns Lucide icon component
+ */
+export function getMcpLogLevelIcon(level: MCPLogLevel): Component {
+ switch (level) {
+ case MCPLogLevel.ERROR:
+ return XCircle;
+ case MCPLogLevel.WARN:
+ return AlertTriangle;
+ default:
+ return Info;
+ }
+}
+
+/**
+ * Get the appropriate CSS class for a log level
+ *
+ * @param level - MCP log level
+ * @returns Tailwind CSS class string
+ */
+export function getMcpLogLevelClass(level: MCPLogLevel): string {
+ switch (level) {
+ case MCPLogLevel.ERROR:
+ return 'text-destructive';
+ case MCPLogLevel.WARN:
+ return 'text-yellow-600 dark:text-yellow-500';
+ default:
+ return 'text-muted-foreground';
+ }
+}
+
+/**
+ * Check if a MIME type represents an image.
+ *
+ * @param mimeType - The MIME type to check
+ * @returns True if the MIME type starts with 'image/'
+ */
+export function isImageMimeType(mimeType?: MimeTypeUnion): boolean {
+ return mimeType?.startsWith(MimeTypePrefix.IMAGE) ?? false;
+}
+
+/**
+ * Parse a resource URI into path segments, stripping the protocol prefix.
+ *
+ * @param uri - The resource URI to parse
+ * @returns Array of non-empty path segments
+ */
+export function parseResourcePath(uri: string): string[] {
+ try {
+ const withoutProtocol = uri.replace(PROTOCOL_PREFIX_REGEX, '');
+ return withoutProtocol.split(PATH_SEPARATOR).filter((p) => p.length > 0);
+ } catch {
+ return [uri];
+ }
+}
+
+/**
+ * Convert a path part into a human-readable display name.
+ * Strips file extensions and converts kebab-case/snake_case to Title Case.
+ *
+ * @param pathPart - The path segment to convert
+ * @returns Human-readable display name
+ */
+export function getDisplayName(pathPart: string): string {
+ const withoutExt = pathPart.replace(FILE_EXTENSION_REGEX, '');
+ return withoutExt
+ .split(DISPLAY_NAME_SEPARATOR_REGEX)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+}
+
+/**
+ * Get the display name from a resource, extracting the last path segment from the URI.
+ *
+ * @param resource - The MCP resource info
+ * @returns Display name string
+ */
+export function getResourceDisplayName(resource: MCPResourceInfo): string {
+ try {
+ const parts = parseResourcePath(resource.uri);
+ return parts[parts.length - 1] || resource.name || resource.uri;
+ } catch {
+ return resource.name || resource.uri;
+ }
+}
+
+/**
+ * Determine if a MIME type and/or URI represents code content.
+ *
+ * @param mimeType - Optional MIME type string
+ * @param uri - Optional URI string
+ * @returns True if the content is code
+ */
+export function isCodeResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
+ const mime = mimeType?.toLowerCase() || '';
+ const u = uri?.toLowerCase() || '';
+ return (
+ mime.includes(MimeTypeIncludes.JSON) ||
+ mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
+ mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
+ CODE_FILE_EXTENSION_REGEX.test(u)
+ );
+}
+
+/**
+ * Determine if a MIME type and/or URI represents image content.
+ *
+ * @param mimeType - Optional MIME type string
+ * @param uri - Optional URI string
+ * @returns True if the content is an image
+ */
+export function isImageResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
+ const mime = mimeType?.toLowerCase() || '';
+ const u = uri?.toLowerCase() || '';
+ return mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u);
+}
+
+/**
+ * Get the appropriate Lucide icon component for an MCP resource based on its MIME type and URI.
+ *
+ * @param mimeType - Optional MIME type of the resource
+ * @param uri - Optional URI of the resource
+ * @returns Lucide icon component
+ */
+export function getResourceIcon(mimeType?: MimeTypeUnion, uri?: string): Component {
+ const mime = mimeType?.toLowerCase() || '';
+ const u = uri?.toLowerCase() || '';
+
+ if (mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u)) {
+ return Image;
+ }
+
+ if (
+ mime.includes(MimeTypeIncludes.JSON) ||
+ mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
+ mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
+ CODE_FILE_EXTENSION_REGEX.test(u)
+ ) {
+ return Code;
+ }
+
+ if (mime.includes(MimeTypePrefix.TEXT) || TEXT_FILE_EXTENSION_REGEX.test(u)) {
+ return FileText;
+ }
+
+ if (u.includes(UriPattern.DATABASE_KEYWORD) || u.includes(UriPattern.DATABASE_SCHEME)) {
+ return Database;
+ }
+
+ return File;
+}
+
+/**
+ * Extract text content from MCP resource content array.
+ *
+ * @param content - Array of MCP resource content items
+ * @returns Joined text content string
+ */
+export function getResourceTextContent(content: MCPResourceContent[] | null | undefined): string {
+ if (!content) return '';
+ return content
+ .filter((c): c is { uri: string; mimeType?: MimeTypeUnion; text: string } => 'text' in c)
+ .map((c) => c.text)
+ .join(RESOURCE_TEXT_CONTENT_SEPARATOR);
+}
+
+/**
+ * Extract blob content from MCP resource content array.
+ *
+ * @param content - Array of MCP resource content items
+ * @returns Array of blob content items
+ */
+export function getResourceBlobContent(
+ content: MCPResourceContent[] | null | undefined
+): Array<{ uri: string; mimeType?: MimeTypeUnion; blob: string }> {
+ if (!content) return [];
+
+ return content.filter(
+ (c): c is { uri: string; mimeType?: MimeTypeUnion; blob: string } => 'blob' in c
+ );
+}
+
+/**
+ * Trigger a file download from text content.
+ *
+ * @param text - The text content to download
+ * @param mimeType - MIME type for the blob
+ * @param filename - Suggested filename
+ */
+export function downloadResourceContent(
+ text: string,
+ mimeType: MimeTypeUnion = MimeTypeText.PLAIN,
+ filename: string = DEFAULT_RESOURCE_FILENAME
+): void {
+ const blob = new Blob([text], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
--- /dev/null
+import {
+ KEY_VALUE_PAIR_KEY_MAX_LENGTH,
+ KEY_VALUE_PAIR_VALUE_MAX_LENGTH,
+ KEY_VALUE_PAIR_UNSAFE_KEY_RE,
+ KEY_VALUE_PAIR_UNSAFE_VALUE_RE
+} from '$lib/constants';
+
+/**
+ * Strip control characters unsafe in identifier/header-name contexts and cap length.
+ * Removes all C0 controls (including TAB) and DEL.
+ */
+export function sanitizeKeyValuePairKey(raw: string): string {
+ return raw.replace(KEY_VALUE_PAIR_UNSAFE_KEY_RE, '').slice(0, KEY_VALUE_PAIR_KEY_MAX_LENGTH);
+}
+
+/**
+ * Strip control characters that enable header injection; allow TAB; cap length.
+ * Removes null bytes, CR/LF and other C0/DEL controls while keeping TAB (\x09),
+ * which is a valid header-value continuation character per RFC 7230.
+ */
+export function sanitizeKeyValuePairValue(raw: string): string {
+ return raw.replace(KEY_VALUE_PAIR_UNSAFE_VALUE_RE, '').slice(0, KEY_VALUE_PAIR_VALUE_MAX_LENGTH);
+}
--- /dev/null
+import {
+ TEMPLATE_EXPRESSION_REGEX,
+ URI_SCHEME_SEPARATOR,
+ URI_TEMPLATE_OPERATORS,
+ URI_TEMPLATE_SEPARATORS,
+ VARIABLE_EXPLODE_MODIFIER_REGEX,
+ VARIABLE_PREFIX_MODIFIER_REGEX,
+ LEADING_SLASHES_REGEX
+} from '../constants';
+
+/**
+ * Normalize a resource URI for comparison.
+ *
+ * URI template expansion (especially with path operators like {/var})
+ * can produce URIs that differ from listed resource URIs in slash placement.
+ * For example, the template `svelte://{/slug*}.md` with slug="svelte/$effect"
+ * expands to `svelte:///svelte/$effect.md`, while the listed resource URI is
+ * `svelte://svelte/$effect.md`.
+ *
+ * This function strips extra leading slashes after the scheme to normalize
+ * both forms to the same string for comparison purposes.
+ *
+ * @param uri - The URI to normalize
+ * @returns Normalized URI string
+ */
+export function normalizeResourceUri(uri: string): string {
+ const schemeEnd = uri.indexOf(URI_SCHEME_SEPARATOR);
+ if (schemeEnd === -1) return uri;
+
+ const scheme = uri.substring(0, schemeEnd);
+ const rest = uri
+ .substring(schemeEnd + URI_SCHEME_SEPARATOR.length)
+ .replace(LEADING_SLASHES_REGEX, '');
+
+ return `${scheme}${URI_SCHEME_SEPARATOR}${rest}`;
+}
+
+/**
+ * A parsed variable from a URI template expression.
+ */
+export interface UriTemplateVariable {
+ /** Variable name */
+ name: string;
+ /** Operator prefix (+, #, /, etc.) or empty string */
+ operator: string;
+}
+
+/**
+ * Extract all variable names from a URI template string.
+ *
+ * @param template - URI template string (RFC 6570)
+ * @returns Array of unique variable descriptors
+ *
+ * @example
+ * ```ts
+ * extractTemplateVariables("file:///{path}")
+ * // => [{ name: "path", operator: "" }]
+ *
+ * extractTemplateVariables("db://{schema}/{table}")
+ * // => [{ name: "schema", operator: "" }, { name: "table", operator: "" }]
+ * ```
+ */
+export function extractTemplateVariables(template: string): UriTemplateVariable[] {
+ const variables: UriTemplateVariable[] = [];
+ const seen = new Set<string>();
+
+ let match;
+ TEMPLATE_EXPRESSION_REGEX.lastIndex = 0;
+
+ while ((match = TEMPLATE_EXPRESSION_REGEX.exec(template)) !== null) {
+ const operator = match[1] || '';
+ const varList = match[2];
+
+ // RFC 6570 allows comma-separated variable lists: {x,y,z}
+ for (const varSpec of varList.split(',')) {
+ // Strip explode modifier (*) and prefix modifier (:N)
+ const name = varSpec
+ .replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '')
+ .replace(VARIABLE_PREFIX_MODIFIER_REGEX, '')
+ .trim();
+
+ if (name && !seen.has(name)) {
+ seen.add(name);
+ variables.push({ name, operator });
+ }
+ }
+ }
+
+ return variables;
+}
+
+/**
+ * Expand a URI template with the given variable values.
+ * Implements a simplified RFC 6570 Level 2 expansion.
+ *
+ * @param template - URI template string
+ * @param values - Map of variable name to value
+ * @returns Expanded URI string
+ *
+ * @example
+ * ```ts
+ * expandTemplate("file:///{path}", { path: "src/main.rs" })
+ * // => "file:///src/main.rs"
+ * ```
+ */
+export function expandTemplate(template: string, values: Record<string, string>): string {
+ TEMPLATE_EXPRESSION_REGEX.lastIndex = 0;
+
+ return template.replace(
+ TEMPLATE_EXPRESSION_REGEX,
+ (_match, operator: string, varList: string) => {
+ const varNames = varList
+ .split(',')
+ .map((v: string) =>
+ v
+ .replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '')
+ .replace(VARIABLE_PREFIX_MODIFIER_REGEX, '')
+ .trim()
+ );
+
+ const expandedParts = varNames
+ .map((name: string) => values[name] ?? '')
+ .filter((v: string) => v !== '');
+
+ if (expandedParts.length === 0) return '';
+
+ switch (operator) {
+ case URI_TEMPLATE_OPERATORS.RESERVED:
+ // Reserved expansion: no encoding
+ return expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA);
+ case URI_TEMPLATE_OPERATORS.FRAGMENT:
+ // Fragment expansion
+ return (
+ URI_TEMPLATE_OPERATORS.FRAGMENT + expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA)
+ );
+ case URI_TEMPLATE_OPERATORS.PATH_SEGMENT:
+ // Path segments
+ return URI_TEMPLATE_SEPARATORS.SLASH + expandedParts.join(URI_TEMPLATE_SEPARATORS.SLASH);
+ case URI_TEMPLATE_OPERATORS.LABEL:
+ // Label expansion
+ return (
+ URI_TEMPLATE_SEPARATORS.PERIOD + expandedParts.join(URI_TEMPLATE_SEPARATORS.PERIOD)
+ );
+ case URI_TEMPLATE_OPERATORS.PATH_PARAM:
+ // Path-style parameters
+ return varNames
+ .filter((_: string, i: number) => expandedParts[i])
+ .map(
+ (name: string, i: number) =>
+ `${URI_TEMPLATE_SEPARATORS.SEMICOLON}${name}=${expandedParts[i]}`
+ )
+ .join('');
+ case URI_TEMPLATE_OPERATORS.FORM_QUERY:
+ // Form-style query
+ return (
+ URI_TEMPLATE_SEPARATORS.QUERY_PREFIX +
+ varNames
+ .filter((_: string, i: number) => expandedParts[i])
+ .map(
+ (name: string, i: number) =>
+ `${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}`
+ )
+ .join(URI_TEMPLATE_SEPARATORS.COMMA)
+ );
+ case URI_TEMPLATE_OPERATORS.FORM_CONTINUATION:
+ // Form-style query continuation
+ return (
+ URI_TEMPLATE_SEPARATORS.QUERY_CONTINUATION +
+ varNames
+ .filter((_: string, i: number) => expandedParts[i])
+ .map(
+ (name: string, i: number) =>
+ `${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}`
+ )
+ .join(URI_TEMPLATE_SEPARATORS.COMMA)
+ );
+ default:
+ // Simple string expansion (default operator)
+ return expandedParts
+ .map((v: string) => encodeURIComponent(v))
+ .join(URI_TEMPLATE_SEPARATORS.COMMA);
+ }
+ }
+ );
+}
+
+/**
+ * Check whether all required variables in a template have been provided.
+ *
+ * @param template - URI template string
+ * @param values - Map of variable name to value
+ * @returns true if all variables have non-empty values
+ */
+export function isTemplateComplete(template: string, values: Record<string, string>): boolean {
+ const variables = extractTemplateVariables(template);
+
+ return variables.every((v) => (values[v.name] ?? '').trim() !== '');
+}
--- /dev/null
+export function uuid(): string {
+ return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).substring(2);
+}
<script lang="ts">
import '../app.css';
import { base } from '$app/paths';
+ import { browser } from '$app/environment';
import { page } from '$app/state';
import { untrack } from 'svelte';
import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app';
import { Toaster } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { modelsStore } from '$lib/stores/models.svelte';
+ import { mcpStore } from '$lib/stores/mcp.svelte';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
}
});
+ // Background MCP server health checks on app load
+ // Fetch enabled servers from settings and run health checks in background
+ $effect(() => {
+ if (!browser) return;
+
+ const mcpServers = mcpStore.getServers();
+
+ // Only run health checks if we have enabled servers with URLs
+ const enabledServers = mcpServers.filter((s) => s.enabled && s.url.trim());
+
+ if (enabledServers.length > 0) {
+ untrack(() => {
+ // Run health checks in background (don't await)
+ mcpStore.runHealthChecksForServers(enabledServers, false).catch((error) => {
+ console.warn('[layout] MCP health checks failed:', error);
+ });
+ });
+ }
+ });
+
// Monitor API key changes and redirect to error page if removed or changed when required
$effect(() => {
const apiKey = config().apiKey;
{#if !(alwaysShowSidebarOnDesktop && isDesktop)}
<Sidebar.Trigger
- class="transition-left absolute left-0 z-[900] h-8 w-8 duration-200 ease-linear {sidebarOpen
+ class="transition-left absolute left-0 z-[900] duration-200 ease-linear {sidebarOpen
? 'md:left-[var(--sidebar-width)]'
: ''}"
style="translate: 1rem 1rem;"
<title>llama.cpp - AI Chat Interface</title>
</svelte:head>
-<ChatScreen showCenteredEmpty={true} />
+<ChatScreen showCenteredEmpty />
<DialogModelNotAvailable
bind:open={showModelNotAvailable}
--- /dev/null
+import { describe, it, expect } from 'vitest';
+import { AGENTIC_REGEX } from '$lib/constants/agentic';
+
+// Mirror the logic in ChatService.stripReasoningContent so we can test it in isolation.
+// The real function is private static, so we replicate the strip pipeline here.
+function stripContextMarkers(content: string): string {
+ return content
+ .replace(AGENTIC_REGEX.REASONING_BLOCK, '')
+ .replace(AGENTIC_REGEX.REASONING_OPEN, '')
+ .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
+ .replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
+}
+
+// A realistic complete tool call block as stored in message.content after a turn.
+const COMPLETE_BLOCK =
+ '\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
+ '<<<TOOL_NAME:bash_tool>>>\n' +
+ '<<<TOOL_ARGS_START>>>\n' +
+ '{"command":"ls /tmp","description":"list tmp"}\n' +
+ '<<<TOOL_ARGS_END>>>\n' +
+ 'file1.txt\nfile2.txt\n' +
+ '<<<AGENTIC_TOOL_CALL_END>>>\n';
+
+// Partial block: streaming was cut before END arrived.
+const OPEN_BLOCK =
+ '\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
+ '<<<TOOL_NAME:bash_tool>>>\n' +
+ '<<<TOOL_ARGS_START>>>\n' +
+ '{"command":"ls /tmp","description":"list tmp"}\n' +
+ '<<<TOOL_ARGS_END>>>\n' +
+ 'partial output...';
+
+describe('agentic marker stripping for context', () => {
+ it('strips a complete tool call block, leaving surrounding text', () => {
+ const input = 'Before.' + COMPLETE_BLOCK + 'After.';
+ const result = stripContextMarkers(input);
+ // markers gone; residual newlines between fragments are fine
+ expect(result).not.toContain('<<<');
+ expect(result).toContain('Before.');
+ expect(result).toContain('After.');
+ });
+
+ it('strips multiple complete tool call blocks', () => {
+ const input = 'A' + COMPLETE_BLOCK + 'B' + COMPLETE_BLOCK + 'C';
+ const result = stripContextMarkers(input);
+ expect(result).not.toContain('<<<');
+ expect(result).toContain('A');
+ expect(result).toContain('B');
+ expect(result).toContain('C');
+ });
+
+ it('strips an open/partial tool call block (no END marker)', () => {
+ const input = 'Lead text.' + OPEN_BLOCK;
+ const result = stripContextMarkers(input);
+ expect(result).toBe('Lead text.');
+ expect(result).not.toContain('<<<');
+ });
+
+ it('does not alter content with no markers', () => {
+ const input = 'Just a normal assistant response.';
+ expect(stripContextMarkers(input)).toBe(input);
+ });
+
+ it('strips reasoning block independently', () => {
+ const input = '<<<reasoning_content_start>>>think hard<<<reasoning_content_end>>>Answer.';
+ expect(stripContextMarkers(input)).toBe('Answer.');
+ });
+
+ it('strips both reasoning and agentic blocks together', () => {
+ const input =
+ '<<<reasoning_content_start>>>plan<<<reasoning_content_end>>>' +
+ 'Some text.' +
+ COMPLETE_BLOCK;
+ expect(stripContextMarkers(input)).not.toContain('<<<');
+ expect(stripContextMarkers(input)).toContain('Some text.');
+ });
+
+ it('empty string survives', () => {
+ expect(stripContextMarkers('')).toBe('');
+ });
+});
--- /dev/null
+import { describe, it, expect } from 'vitest';
+import {
+ extractTemplateVariables,
+ expandTemplate,
+ isTemplateComplete,
+ normalizeResourceUri
+} from '../../src/lib/utils/uri-template';
+import { URI_TEMPLATE_OPERATORS } from '../../src/lib/constants/uri-template';
+
+describe('extractTemplateVariables', () => {
+ it('extracts simple variables', () => {
+ const vars = extractTemplateVariables('file:///{path}');
+ expect(vars).toEqual([{ name: 'path', operator: '' }]);
+ });
+
+ it('extracts multiple variables', () => {
+ const vars = extractTemplateVariables('db://{schema}/{table}');
+ expect(vars).toEqual([
+ { name: 'schema', operator: '' },
+ { name: 'table', operator: '' }
+ ]);
+ });
+
+ it('extracts variables with operators', () => {
+ const vars = extractTemplateVariables('http://example.com{+path}');
+ expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.RESERVED }]);
+ });
+
+ it('extracts comma-separated variable lists', () => {
+ const vars = extractTemplateVariables('{x,y,z}');
+ expect(vars).toEqual([
+ { name: 'x', operator: '' },
+ { name: 'y', operator: '' },
+ { name: 'z', operator: '' }
+ ]);
+ });
+
+ it('deduplicates variable names', () => {
+ const vars = extractTemplateVariables('{name}/{name}');
+ expect(vars).toEqual([{ name: 'name', operator: '' }]);
+ });
+
+ it('handles fragment expansion', () => {
+ const vars = extractTemplateVariables('http://example.com/page{#section}');
+ expect(vars).toEqual([{ name: 'section', operator: URI_TEMPLATE_OPERATORS.FRAGMENT }]);
+ });
+
+ it('handles path segment expansion', () => {
+ const vars = extractTemplateVariables('http://example.com{/path}');
+ expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.PATH_SEGMENT }]);
+ });
+
+ it('returns empty array for template without variables', () => {
+ const vars = extractTemplateVariables('http://example.com/static');
+ expect(vars).toEqual([]);
+ });
+
+ it('strips explode modifier', () => {
+ const vars = extractTemplateVariables('{list*}');
+ expect(vars).toEqual([{ name: 'list', operator: '' }]);
+ });
+
+ it('strips prefix modifier', () => {
+ const vars = extractTemplateVariables('{value:5}');
+ expect(vars).toEqual([{ name: 'value', operator: '' }]);
+ });
+});
+
+describe('expandTemplate', () => {
+ it('expands simple variable', () => {
+ const result = expandTemplate('file:///{path}', { path: 'src/main.rs' });
+ expect(result).toBe('file:///src%2Fmain.rs');
+ });
+
+ it('expands reserved variable (no encoding)', () => {
+ const result = expandTemplate('file:///{+path}', { path: 'src/main.rs' });
+ expect(result).toBe('file:///src/main.rs');
+ });
+
+ it('expands multiple variables', () => {
+ const result = expandTemplate('db://{schema}/{table}', {
+ schema: 'public',
+ table: 'users'
+ });
+ expect(result).toBe('db://public/users');
+ });
+
+ it('leaves empty for missing variables', () => {
+ const result = expandTemplate('{missing}', {});
+ expect(result).toBe('');
+ });
+
+ it('expands fragment', () => {
+ const result = expandTemplate('http://example.com/page{#section}', {
+ section: 'intro'
+ });
+ expect(result).toBe('http://example.com/page#intro');
+ });
+
+ it('expands path segments', () => {
+ const result = expandTemplate('http://example.com{/path}', { path: 'docs' });
+ expect(result).toBe('http://example.com/docs');
+ });
+
+ it('expands query parameters', () => {
+ const result = expandTemplate('http://example.com{?q}', { q: 'search term' });
+ expect(result).toBe('http://example.com?q=search%20term');
+ });
+
+ it('keeps static parts unchanged', () => {
+ const result = expandTemplate('http://example.com/static', {});
+ expect(result).toBe('http://example.com/static');
+ });
+});
+
+describe('isTemplateComplete', () => {
+ it('returns true when all variables are filled', () => {
+ expect(isTemplateComplete('file:///{path}', { path: 'test.txt' })).toBe(true);
+ });
+
+ it('returns false when a variable is missing', () => {
+ expect(isTemplateComplete('db://{schema}/{table}', { schema: 'public' })).toBe(false);
+ });
+
+ it('returns false when a variable is empty', () => {
+ expect(isTemplateComplete('file:///{path}', { path: '' })).toBe(false);
+ });
+
+ it('returns false when a variable is whitespace only', () => {
+ expect(isTemplateComplete('file:///{path}', { path: ' ' })).toBe(false);
+ });
+
+ it('returns true for template without variables', () => {
+ expect(isTemplateComplete('http://example.com/static', {})).toBe(true);
+ });
+
+ it('returns true when all multiple variables are filled', () => {
+ expect(isTemplateComplete('db://{schema}/{table}', { schema: 'public', table: 'users' })).toBe(
+ true
+ );
+ });
+});
+
+describe('normalizeResourceUri', () => {
+ it('passes through a normal URI unchanged', () => {
+ expect(normalizeResourceUri('svelte://svelte/$effect.md')).toBe('svelte://svelte/$effect.md');
+ });
+
+ it('normalizes triple-slash URIs from path-style template expansion', () => {
+ expect(normalizeResourceUri('svelte:///svelte/$effect.md')).toBe('svelte://svelte/$effect.md');
+ });
+
+ it('normalizes quadruple-slash URIs', () => {
+ expect(normalizeResourceUri('svelte:////svelte/$effect.md')).toBe('svelte://svelte/$effect.md');
+ });
+
+ it('handles file:// URIs', () => {
+ expect(normalizeResourceUri('file:///home/user/doc.txt')).toBe('file://home/user/doc.txt');
+ });
+
+ it('handles http URIs unchanged', () => {
+ expect(normalizeResourceUri('http://example.com/path')).toBe('http://example.com/path');
+ });
+
+ it('returns non-URI strings unchanged', () => {
+ expect(normalizeResourceUri('not-a-uri')).toBe('not-a-uri');
+ });
+});