]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: Agentic Loop + MCP Client with support for Tools, Resources and Prompts (...
authorAleksander Grygier <redacted>
Fri, 6 Mar 2026 09:00:39 +0000 (10:00 +0100)
committerGitHub <redacted>
Fri, 6 Mar 2026 09:00:39 +0000 (10:00 +0100)
147 files changed:
common/arg.cpp
common/common.h
tools/server/public/index.html.gz
tools/server/server-cors-proxy.h [new file with mode: 0644]
tools/server/server-models.cpp
tools/server/server.cpp
tools/server/webui/docs/architecture/high-level-architecture-simplified.md
tools/server/webui/docs/architecture/high-level-architecture.md
tools/server/webui/docs/flows/chat-flow.md
tools/server/webui/docs/flows/conversations-flow.md
tools/server/webui/docs/flows/database-flow.md
tools/server/webui/docs/flows/mcp-flow.md [new file with mode: 0644]
tools/server/webui/package-lock.json
tools/server/webui/package.json
tools/server/webui/src/lib/components/app/actions/ActionIconRemove.svelte
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpPrompt.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpResource.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpResources.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsSheet.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormHelperText.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerItemHeader.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerList.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerListItem.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickerPopover.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentForm.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentInput.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormResourcePicker/ChatFormResourcePicker.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageMcpPrompt.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageMcpPromptContent.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte [deleted file]
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
tools/server/webui/src/lib/components/app/chat/index.ts
tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte
tools/server/webui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
tools/server/webui/src/lib/components/app/content/index.ts
tools/server/webui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/DialogMcpResources.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/DialogMcpServersSettings.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/dialogs/index.ts
tools/server/webui/src/lib/components/app/forms/InputWithSuggestions.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/forms/KeyValuePairs.svelte
tools/server/webui/src/lib/components/app/forms/index.ts
tools/server/webui/src/lib/components/app/index.ts
tools/server/webui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpCapabilitiesBadges.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpConnectionLogs.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpLogo.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowser.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserEmptyState.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserHeader.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserServerItem.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/mcp-resource-browser.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpResourcePreview.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpResourceTemplateForm.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardActions.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardDeleteDialog.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardHeader.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardToolsList.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServerCardSkeleton.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServerInfo.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServersSelector.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/McpServersSettings.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/mcp/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte
tools/server/webui/src/lib/components/app/misc/TruncatedText.svelte
tools/server/webui/src/lib/components/app/models/ModelId.svelte
tools/server/webui/src/lib/components/app/models/ModelsSelectorSheet.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/models/index.ts
tools/server/webui/src/lib/components/ui/button/button.svelte
tools/server/webui/src/lib/components/ui/sheet/sheet-content.svelte
tools/server/webui/src/lib/components/ui/sidebar/sidebar-trigger.svelte
tools/server/webui/src/lib/constants/agentic.ts
tools/server/webui/src/lib/constants/api-endpoints.ts
tools/server/webui/src/lib/constants/attachment-labels.ts
tools/server/webui/src/lib/constants/cache.ts
tools/server/webui/src/lib/constants/chat-form.ts
tools/server/webui/src/lib/constants/css-classes.ts
tools/server/webui/src/lib/constants/favicon.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/index.ts
tools/server/webui/src/lib/constants/key-value-pairs.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/mcp-form.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/mcp-resource.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/mcp.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/settings-config.ts
tools/server/webui/src/lib/constants/settings-keys.ts
tools/server/webui/src/lib/constants/settings-sections.ts
tools/server/webui/src/lib/constants/uri-template.ts [new file with mode: 0644]
tools/server/webui/src/lib/enums/agentic.ts [new file with mode: 0644]
tools/server/webui/src/lib/enums/attachment.ts
tools/server/webui/src/lib/enums/files.ts
tools/server/webui/src/lib/enums/index.ts
tools/server/webui/src/lib/enums/mcp.ts [new file with mode: 0644]
tools/server/webui/src/lib/enums/ui.ts
tools/server/webui/src/lib/markdown/resolve-attachment-images.ts
tools/server/webui/src/lib/services/chat.service.ts
tools/server/webui/src/lib/services/database.service.ts
tools/server/webui/src/lib/services/index.ts
tools/server/webui/src/lib/services/mcp.service.ts [new file with mode: 0644]
tools/server/webui/src/lib/services/parameter-sync.service.spec.ts
tools/server/webui/src/lib/stores/agentic.svelte.ts [new file with mode: 0644]
tools/server/webui/src/lib/stores/chat.svelte.ts
tools/server/webui/src/lib/stores/conversations.svelte.ts
tools/server/webui/src/lib/stores/mcp-resources.svelte.ts [new file with mode: 0644]
tools/server/webui/src/lib/stores/mcp.svelte.ts [new file with mode: 0644]
tools/server/webui/src/lib/types/agentic.d.ts [new file with mode: 0644]
tools/server/webui/src/lib/types/chat.d.ts
tools/server/webui/src/lib/types/common.d.ts
tools/server/webui/src/lib/types/database.d.ts
tools/server/webui/src/lib/types/index.ts
tools/server/webui/src/lib/types/mcp.d.ts [new file with mode: 0644]
tools/server/webui/src/lib/types/settings.d.ts
tools/server/webui/src/lib/utils/agentic.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/api-fetch.ts
tools/server/webui/src/lib/utils/attachment-display.ts
tools/server/webui/src/lib/utils/clipboard.ts
tools/server/webui/src/lib/utils/convert-files-to-extra.ts
tools/server/webui/src/lib/utils/cors-proxy.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/favicon.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/headers.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/index.ts
tools/server/webui/src/lib/utils/mcp.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/sanitize.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/uri-template.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/uuid.ts [new file with mode: 0644]
tools/server/webui/src/routes/+layout.svelte
tools/server/webui/src/routes/+page.svelte
tools/server/webui/tests/unit/agentic-strip.test.ts [new file with mode: 0644]
tools/server/webui/tests/unit/uri-template.test.ts [new file with mode: 0644]

index 0260d79fef02f04408b63db3b9c3f43f0394301f..cd73d9642043f498035d690c2704722bdd75bca2 100644 (file)
@@ -2827,6 +2827,14 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
             params.webui_config_json = read_file(value);
         }
     ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_WEBUI_CONFIG_FILE"));
+    add_opt(common_arg(
+        {"--webui-mcp-proxy"},
+        {"--no-webui-mcp-proxy"},
+        string_format("experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: %s)", params.webui_mcp_proxy ? "enabled" : "disabled"),
+        [](common_params & params, bool value) {
+            params.webui_mcp_proxy = value;
+        }
+    ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_WEBUI_MCP_PROXY"));
     add_opt(common_arg(
         {"--webui"},
         {"--no-webui"},
index ae32d5053c5192b88872940ec0aedcfc884cedb9..3c09cdf04055c791d2b219aa80400a2a734183a0 100644 (file)
@@ -545,6 +545,7 @@ struct common_params {
 
     // webui configs
     bool webui = true;
+    bool webui_mcp_proxy = false;
     std::string webui_config_json;
 
     // "advanced" endpoints are disabled by default for better security
index 77362ce66de8c9ccb1e661b4dbd3f710ad3a942a..ed3fc127b77e15c90041ca89457b3989a07021c7 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
diff --git a/tools/server/server-cors-proxy.h b/tools/server/server-cors-proxy.h
new file mode 100644 (file)
index 0000000..bca50b5
--- /dev/null
@@ -0,0 +1,56 @@
+#pragma once
+
+#include "common.h"
+#include "http.h"
+
+#include <string>
+#include <unordered_set>
+#include <list>
+#include <map>
+
+#include "server-http.h"
+
+static server_http_res_ptr proxy_request(const server_http_req & req, std::string method) {
+    std::string target_url = req.get_param("url");
+    common_http_url parsed_url = common_http_parse_url(target_url);
+
+    if (parsed_url.host.empty()) {
+        throw std::runtime_error("invalid target URL: missing host");
+    }
+
+    if (parsed_url.path.empty()) {
+        parsed_url.path = "/";
+    }
+
+    if (!parsed_url.password.empty()) {
+        throw std::runtime_error("authentication in target URL is not supported");
+    }
+
+    if (parsed_url.scheme != "http" && parsed_url.scheme != "https") {
+        throw std::runtime_error("unsupported URL scheme in target URL: " + parsed_url.scheme);
+    }
+
+    SRV_INF("proxying %s request to %s://%s%s\n", method.c_str(), parsed_url.scheme.c_str(), parsed_url.host.c_str(), parsed_url.path.c_str());
+
+    auto proxy = std::make_unique<server_http_proxy>(
+            method,
+            parsed_url.host,
+            parsed_url.scheme == "http" ? 80 : 443,
+            parsed_url.path,
+            req.headers,
+            req.body,
+            req.should_stop,
+            600, // timeout_read (default to 10 minutes)
+            600  // timeout_write (default to 10 minutes)
+            );
+
+    return proxy;
+}
+
+static server_http_context::handler_t proxy_handler_post = [](const server_http_req & req) -> server_http_res_ptr {
+    return proxy_request(req, "POST");
+};
+
+static server_http_context::handler_t proxy_handler_get = [](const server_http_req & req) -> server_http_res_ptr {
+    return proxy_request(req, "GET");
+};
index bc601237b7d2234f7d20892e9221a153a3f5bc20..5f87ba9a21270ffad594d363b5ed9abd1bd260b9 100644 (file)
@@ -1089,11 +1089,20 @@ server_http_proxy::server_http_proxy(
         int32_t timeout_write
         ) {
     // shared between reader and writer threads
-    auto cli  = std::make_shared<httplib::Client>(host, port);
+    auto cli  = std::make_shared<httplib::ClientImpl>(host, port);
     auto pipe = std::make_shared<pipe_t<msg_t>>();
 
+    if (port == 443) {
+#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
+        cli.reset(new httplib::SSLClient(host, port));
+#else
+        throw std::runtime_error("HTTPS requested but CPPHTTPLIB_OPENSSL_SUPPORT is not defined");
+#endif
+    }
+
     // setup Client
-    cli->set_connection_timeout(0, 200000); // 200 milliseconds
+    cli->set_follow_location(true);
+    cli->set_connection_timeout(5, 0); // 5 seconds
     cli->set_write_timeout(timeout_read, 0); // reversed for cli (client) vs srv (server)
     cli->set_read_timeout(timeout_write, 0);
     this->status = 500; // to be overwritten upon response
@@ -1142,7 +1151,15 @@ server_http_proxy::server_http_proxy(
         req.method = method;
         req.path = path;
         for (const auto & [key, value] : headers) {
-            req.set_header(key, value);
+            if (key == "Accept-Encoding") {
+                // disable Accept-Encoding to avoid compressed responses
+                continue;
+            }
+            if (key == "Host" || key == "host") {
+                req.set_header(key, host);
+            } else {
+                req.set_header(key, value);
+            }
         }
         req.body = body;
         req.response_handler = response_handler;
index fab0bb587f3d8211abdf6dc6cb2cc50cfc2d0b3c..0bd6fda17d2258519215d781f9fcded6e3a6039a 100644 (file)
@@ -1,6 +1,7 @@
 #include "server-context.h"
 #include "server-http.h"
 #include "server-models.h"
+#include "server-cors-proxy.h"
 
 #include "arg.h"
 #include "common.h"
@@ -201,6 +202,15 @@ int main(int argc, char ** argv) {
     // Save & load slots
     ctx_http.get ("/slots",               ex_wrapper(routes.get_slots));
     ctx_http.post("/slots/:id_slot",      ex_wrapper(routes.post_slots));
+    // CORS proxy (EXPERIMENTAL, only used by the Web UI for MCP)
+    if (params.webui_mcp_proxy) {
+        SRV_WRN("%s", "-----------------\n");
+        SRV_WRN("%s", "CORS proxy is enabled, do not expose server to untrusted environments\n");
+        SRV_WRN("%s", "This feature is EXPERIMENTAL and may be removed or changed in future versions\n");
+        SRV_WRN("%s", "-----------------\n");
+        ctx_http.get ("/cors-proxy",      ex_wrapper(proxy_handler_get));
+        ctx_http.post("/cors-proxy",      ex_wrapper(proxy_handler_post));
+    }
 
     //
     // Start the server
index a6cb1e9c3940910a54b553e5903760c5f2da4f54..500f477c9a4cf1164545d1bb1a42703fe0166bc5 100644 (file)
@@ -12,9 +12,13 @@ flowchart TB
         C_Form["ChatForm"]
         C_Messages["ChatMessages"]
         C_Message["ChatMessage"]
+        C_ChatMessageAgenticContent["ChatMessageAgenticContent"]
         C_MessageEditForm["ChatMessageEditForm"]
         C_ModelsSelector["ModelsSelector"]
         C_Settings["ChatSettings"]
+        C_McpSettings["McpServersSettings"]
+        C_McpResourceBrowser["McpResourceBrowser"]
+        C_McpServersSelector["McpServersSelector"]
     end
 
     subgraph Hooks["🪝 Hooks"]
@@ -24,10 +28,13 @@ flowchart TB
 
     subgraph Stores["🗄️ Stores"]
         S1["chatStore<br/><i>Chat interactions & streaming</i>"]
-        S2["conversationsStore<br/><i>Conversation data & messages</i>"]
+        SA["agenticStore<br/><i>Multi-turn agentic loop orchestration</i>"]
+        S2["conversationsStore<br/><i>Conversation data, messages & MCP overrides</i>"]
         S3["modelsStore<br/><i>Model selection & loading</i>"]
         S4["serverStore<br/><i>Server props & role detection</i>"]
-        S5["settingsStore<br/><i>User configuration</i>"]
+        S5["settingsStore<br/><i>User configuration incl. MCP</i>"]
+        S6["mcpStore<br/><i>MCP servers, tools, prompts</i>"]
+        S7["mcpResourceStore<br/><i>MCP resources & attachments</i>"]
     end
 
     subgraph Services["⚙️ Services"]
@@ -36,11 +43,12 @@ flowchart TB
         SV3["PropsService"]
         SV4["DatabaseService"]
         SV5["ParameterSyncService"]
+        SV6["MCPService<br/><i>protocol operations</i>"]
     end
 
     subgraph Storage["💾 Storage"]
         ST1["IndexedDB<br/><i>conversations, messages</i>"]
-        ST2["LocalStorage<br/><i>config, userOverrides</i>"]
+        ST2["LocalStorage<br/><i>config, userOverrides, mcpServers</i>"]
     end
 
     subgraph APIs["🌐 llama-server API"]
@@ -50,15 +58,27 @@ flowchart TB
         API4["/v1/models"]
     end
 
+    subgraph ExternalMCP["🔌 External MCP Servers"]
+        EXT1["MCP Server 1<br/><i>WebSocket/HTTP/SSE</i>"]
+        EXT2["MCP Server N"]
+    end
+
     %% Routes → Components
     R1 & R2 --> C_Screen
     RL --> C_Sidebar
 
+    %% Layout runs MCP health checks
+    RL --> S6
+
     %% Component hierarchy
     C_Screen --> C_Form & C_Messages & C_Settings
     C_Messages --> C_Message
+    C_Message --> C_ChatMessageAgenticContent
     C_Message --> C_MessageEditForm
     C_Form & C_MessageEditForm --> C_ModelsSelector
+    C_Form --> C_McpServersSelector
+    C_Settings --> C_McpSettings
+    C_McpSettings --> C_McpResourceBrowser
 
     %% Components → Hooks → Stores
     C_Form & C_Messages --> H1 & H2
@@ -70,6 +90,15 @@ flowchart TB
     C_Sidebar --> S2
     C_ModelsSelector --> S3 & S4
     C_Settings --> S5
+    C_McpSettings --> S6
+    C_McpResourceBrowser --> S6 & S7
+    C_McpServersSelector --> S6
+    C_Form --> S6
+
+    %% chatStore → agenticStore → mcpStore (agentic loop)
+    S1 --> SA
+    SA --> SV1
+    SA --> S6
 
     %% Stores → Services
     S1 --> SV1 & SV4
@@ -77,6 +106,8 @@ flowchart TB
     S3 --> SV2 & SV3
     S4 --> SV3
     S5 --> SV5
+    S6 --> SV6
+    S7 --> SV6
 
     %% Services → Storage
     SV4 --> ST1
@@ -87,6 +118,9 @@ flowchart TB
     SV2 --> API3 & API4
     SV3 --> API2
 
+    %% MCP → External Servers
+    SV6 --> EXT1 & EXT2
+
     %% Styling
     classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
     classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
@@ -95,12 +129,17 @@ flowchart TB
     classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
     classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
     classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
+    classDef mcpStyle fill:#e0f2f1,stroke:#00695c,stroke-width:2px
+    classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px
+    classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
 
     class R1,R2,RL routeStyle
-    class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle
+    class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_ChatMessageAgenticContent,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle
+    class C_McpSettings,C_McpResourceBrowser,C_McpServersSelector componentStyle
     class H1,H2 hookStyle
-    class S1,S2,S3,S4,S5 storeStyle
-    class SV1,SV2,SV3,SV4,SV5 serviceStyle
+    class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
+    class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
     class ST1,ST2 storageStyle
     class API1,API2,API3,API4 apiStyle
+    class EXT1,EXT2 externalStyle
 ```
index c5ec4d6909595f471cd2243745a38e40cf575ec2..42ddb3f4f5ba57be9373a9141fe50154bcb92de6 100644 (file)
@@ -22,6 +22,13 @@ end
             C_ModelsSelector["ModelsSelector"]
             C_Settings["ChatSettings"]
         end
+        subgraph MCPComponents["MCP UI"]
+            C_McpSettings["McpServersSettings"]
+            C_McpServerCard["McpServerCard"]
+            C_McpResourceBrowser["McpResourceBrowser"]
+            C_McpResourcePreview["McpResourcePreview"]
+            C_McpServersSelector["McpServersSelector"]
+        end
     end
 
     subgraph Hooks["🪝 Hooks"]
@@ -43,14 +50,20 @@ end
             S1Edit["<b>Editing:</b><br/>editAssistantMessage()<br/>editUserMessagePreserveResponses()<br/>editMessageWithBranching()<br/>clearEditMode()<br/>isEditModeActive()<br/>getAddFilesHandler()<br/>setEditModeActive()"]
             S1Utils["<b>Utilities:</b><br/>getApiOptions()<br/>parseTimingData()<br/>getOrCreateAbortController()<br/>getConversationModel()"]
         end
+        subgraph SA["agenticStore"]
+            SAState["<b>State:</b><br/>sessions (Map)<br/>isAnyRunning"]
+            SASession["<b>Session Management:</b><br/>getSession()<br/>updateSession()<br/>clearSession()<br/>getActiveSessions()<br/>isRunning()<br/>currentTurn()<br/>totalToolCalls()<br/>lastError()<br/>streamingToolCall()"]
+            SAConfig["<b>Configuration:</b><br/>getConfig()<br/>maxTurns, maxToolPreviewLines"]
+            SAFlow["<b>Agentic Loop:</b><br/>runAgenticFlow()<br/>executeAgenticLoop()<br/>normalizeToolCalls()<br/>emitToolCallResult()<br/>extractBase64Attachments()"]
+        end
         subgraph S2["conversationsStore"]
-            S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>usedModalities<br/>isInitialized<br/>titleUpdateConfirmationCallback"]
-            S2Modal["<b>Modalities:</b><br/>getModalitiesUpToMessage()<br/>calculateModalitiesFromMessages()"]
+            S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>isInitialized<br/>pendingMcpServerOverrides<br/>titleUpdateConfirmationCallback"]
             S2Lifecycle["<b>Lifecycle:</b><br/>initialize()<br/>loadConversations()<br/>clearActiveConversation()"]
-            S2ConvCRUD["<b>Conversation CRUD:</b><br/>createConversation()<br/>loadConversation()<br/>deleteConversation()<br/>updateConversationName()<br/>updateConversationTitleWithConfirmation()"]
+            S2ConvCRUD["<b>Conversation CRUD:</b><br/>createConversation()<br/>loadConversation()<br/>deleteConversation()<br/>deleteAll()<br/>updateConversationName()<br/>updateConversationTitleWithConfirmation()"]
             S2MsgMgmt["<b>Message Management:</b><br/>refreshActiveMessages()<br/>addMessageToActive()<br/>updateMessageAtIndex()<br/>findMessageIndex()<br/>sliceActiveMessages()<br/>removeMessageAtIndex()<br/>getConversationMessages()"]
             S2Nav["<b>Navigation:</b><br/>navigateToSibling()<br/>updateCurrentNode()<br/>updateConversationTimestamp()"]
-            S2Export["<b>Import/Export:</b><br/>downloadConversation()<br/>exportAllConversations()<br/>importConversations()<br/>triggerDownload()"]
+            S2McpOverrides["<b>MCP Per-Chat Overrides:</b><br/>getMcpServerOverride()<br/>getAllMcpServerOverrides()<br/>setMcpServerOverride()<br/>toggleMcpServerForChat()<br/>removeMcpServerOverride()<br/>isMcpServerEnabledForChat()<br/>clearPendingMcpServerOverrides()"]
+            S2Export["<b>Import/Export:</b><br/>downloadConversation()<br/>exportAllConversations()<br/>importConversations()<br/>importConversationsData()<br/>triggerDownload()"]
             S2Utils["<b>Utilities:</b><br/>setTitleUpdateConfirmationCallback()"]
         end
         subgraph S3["modelsStore"]
@@ -77,6 +90,21 @@ end
             S5Sync["<b>Server Sync:</b><br/>syncWithServerDefaults()<br/>forceSyncWithServerDefaults()"]
             S5Utils["<b>Utilities:</b><br/>getConfig()<br/>getAllConfig()<br/>getParameterInfo()<br/>getParameterDiff()<br/>getServerDefaults()<br/>clearAllUserOverrides()"]
         end
+        subgraph S6["mcpStore"]
+            S6State["<b>State:</b><br/>isInitializing, error<br/>toolCount, connectedServers<br/>healthChecks (Map)<br/>connections (Map)<br/>toolsIndex (Map)"]
+            S6Lifecycle["<b>Lifecycle:</b><br/>ensureInitialized()<br/>initialize()<br/>shutdown()<br/>acquireConnection()<br/>releaseConnection()"]
+            S6Health["<b>Health Checks:</b><br/>runHealthCheck()<br/>runHealthChecksForServers()<br/>updateHealthCheck()<br/>getHealthCheckState()<br/>clearHealthCheck()"]
+            S6Servers["<b>Server Management:</b><br/>getServers()<br/>addServer()<br/>updateServer()<br/>removeServer()<br/>getServerById()<br/>getServerDisplayName()"]
+            S6Tools["<b>Tool Operations:</b><br/>getToolDefinitionsForLLM()<br/>getToolNames()<br/>hasTool()<br/>getToolServer()<br/>executeTool()<br/>executeToolByName()"]
+            S6Prompts["<b>Prompt Operations:</b><br/>getAllPrompts()<br/>getPrompt()<br/>hasPromptsCapability()<br/>getPromptCompletions()"]
+        end
+        subgraph S7["mcpResourceStore"]
+            S7State["<b>State:</b><br/>serverResources (Map)<br/>cachedResources (Map)<br/>subscriptions (Map)<br/>attachments[]<br/>isLoading"]
+            S7Resources["<b>Resource Discovery:</b><br/>setServerResources()<br/>getServerResources()<br/>getAllResourceInfos()<br/>getAllTemplateInfos()<br/>clearServerResources()"]
+            S7Cache["<b>Caching:</b><br/>cacheResourceContent()<br/>getCachedContent()<br/>invalidateCache()<br/>clearCache()"]
+            S7Subs["<b>Subscriptions:</b><br/>addSubscription()<br/>removeSubscription()<br/>isSubscribed()<br/>handleResourceUpdate()"]
+            S7Attach["<b>Attachments:</b><br/>addAttachment()<br/>updateAttachmentContent()<br/>removeAttachment()<br/>clearAttachments()<br/>toMessageExtras()"]
+        end
 
         subgraph ReactiveExports["⚡ Reactive Exports"]
             direction LR
@@ -95,12 +123,19 @@ end
                 RE9c["setEditModeActive()"]
                 RE9d["clearEditMode()"]
             end
+            subgraph AgenticExports["agenticStore"]
+                REA1["agenticIsRunning()"]
+                REA2["agenticCurrentTurn()"]
+                REA3["agenticTotalToolCalls()"]
+                REA4["agenticLastError()"]
+                REA5["agenticStreamingToolCall()"]
+                REA6["agenticIsAnyRunning()"]
+            end
             subgraph ConvExports["conversationsStore"]
                 RE10["conversations()"]
                 RE11["activeConversation()"]
                 RE12["activeMessages()"]
                 RE13["isConversationsInitialized()"]
-                RE14["usedModalities()"]
             end
             subgraph ModelsExports["modelsStore"]
                 RE15["modelOptions()"]
@@ -131,6 +166,13 @@ end
                 RE36["theme()"]
                 RE37["isInitialized()"]
             end
+            subgraph MCPExports["mcpStore / mcpResourceStore"]
+                RE38["mcpResources()"]
+                RE39["mcpResourceAttachments()"]
+                RE40["mcpHasResourceAttachments()"]
+                RE41["mcpTotalResourceCount()"]
+                RE42["mcpResourcesLoading()"]
+            end
         end
     end
 
@@ -138,9 +180,9 @@ end
         direction TB
         subgraph SV1["ChatService"]
             SV1Msg["<b>Messaging:</b><br/>sendMessage()"]
-            SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>parseSSEChunk()"]
-            SV1Convert["<b>Conversion:</b><br/>convertMessageToChatData()<br/>convertExtraToApiFormat()"]
-            SV1Utils["<b>Utilities:</b><br/>extractReasoningContent()<br/>getServerProps()<br/>getModels()"]
+            SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>handleNonStreamResponse()"]
+            SV1Convert["<b>Conversion:</b><br/>convertDbMessageToApiChatMessageData()<br/>mergeToolCallDeltas()"]
+            SV1Utils["<b>Utilities:</b><br/>stripReasoningContent()<br/>extractModelName()<br/>parseErrorResponse()"]
         end
         subgraph SV2["ModelsService"]
             SV2List["<b>Listing:</b><br/>list()<br/>listRouter()"]
@@ -152,7 +194,7 @@ end
         end
         subgraph SV4["DatabaseService"]
             SV4Conv["<b>Conversations:</b><br/>createConversation()<br/>getConversation()<br/>getAllConversations()<br/>updateConversation()<br/>deleteConversation()"]
-            SV4Msg["<b>Messages:</b><br/>createMessageBranch()<br/>createRootMessage()<br/>getConversationMessages()<br/>updateMessage()<br/>deleteMessage()<br/>deleteMessageCascading()"]
+            SV4Msg["<b>Messages:</b><br/>createMessageBranch()<br/>createRootMessage()<br/>createSystemMessage()<br/>getConversationMessages()<br/>updateMessage()<br/>deleteMessage()<br/>deleteMessageCascading()"]
             SV4Node["<b>Navigation:</b><br/>updateCurrentNode()"]
             SV4Import["<b>Import:</b><br/>importConversations()"]
         end
@@ -162,6 +204,19 @@ end
             SV5Info["<b>Info:</b><br/>getParameterInfo()<br/>canSyncParameter()<br/>getSyncableParameterKeys()<br/>validateServerParameter()"]
             SV5Diff["<b>Diff:</b><br/>createParameterDiff()"]
         end
+        subgraph SV6["MCPService"]
+            SV6Transport["<b>Transport:</b><br/>createTransport()<br/>WebSocket / StreamableHTTP / SSE"]
+            SV6Conn["<b>Connection:</b><br/>connect()<br/>disconnect()"]
+            SV6Tools["<b>Tools:</b><br/>listTools()<br/>callTool()"]
+            SV6Prompts["<b>Prompts:</b><br/>listPrompts()<br/>getPrompt()"]
+            SV6Resources["<b>Resources:</b><br/>listResources()<br/>listResourceTemplates()<br/>readResource()<br/>subscribeResource()<br/>unsubscribeResource()"]
+            SV6Complete["<b>Completions:</b><br/>complete()"]
+        end
+    end
+
+    subgraph ExternalMCP["🔌 External MCP Servers"]
+        EXT1["MCP Server 1<br/>(WebSocket/StreamableHTTP/SSE)"]
+        EXT2["MCP Server N"]
     end
 
     subgraph Storage["💾 Storage"]
@@ -171,6 +226,7 @@ end
         ST5["LocalStorage"]
         ST6["config"]
         ST7["userOverrides"]
+        ST8["mcpServers"]
     end
 
     subgraph APIs["🌐 llama-server API"]
@@ -185,6 +241,9 @@ end
     R2 --> C_Screen
     RL --> C_Sidebar
 
+    %% Layout runs MCP health checks on startup
+    RL --> S6
+
     %% Component hierarchy
     C_Screen --> C_Form & C_Messages & C_Settings
     C_Messages --> C_Message
@@ -194,8 +253,15 @@ end
     C_MessageEditForm --> C_Attach
     C_Form --> C_ModelsSelector
     C_Form --> C_Attach
+    C_Form --> C_McpServersSelector
     C_Message --> C_Attach
 
+    %% MCP Components hierarchy
+    C_Settings --> C_McpSettings
+    C_McpSettings --> C_McpServerCard
+    C_McpServerCard --> C_McpResourceBrowser
+    C_McpResourceBrowser --> C_McpResourcePreview
+
     %% Components use Hooks
     C_Form --> H1
     C_Message --> H1 & H2
@@ -210,17 +276,29 @@ end
     C_Screen --> S1 & S2
     C_Messages --> S2
     C_Message --> S1 & S2 & S3
-    C_Form --> S1 & S3
+    C_Form --> S1 & S3 & S6
     C_Sidebar --> S2
     C_ModelsSelector --> S3 & S4
     C_Settings --> S5
+    C_McpSettings --> S6
+    C_McpServerCard --> S6
+    C_McpResourceBrowser --> S6 & S7
+    C_McpServersSelector --> S6
 
     %% Stores export Reactive State
     S1 -. exports .-> ChatExports
+    SA -. exports .-> AgenticExports
     S2 -. exports .-> ConvExports
     S3 -. exports .-> ModelsExports
     S4 -. exports .-> ServerExports
     S5 -. exports .-> SettingsExports
+    S6 -. exports .-> MCPExports
+    S7 -. exports .-> MCPExports
+
+    %% chatStore → agenticStore (agentic loop orchestration)
+    S1 --> SA
+    SA --> SV1
+    SA --> S6
 
     %% Stores use Services
     S1 --> SV1 & SV4
@@ -228,28 +306,35 @@ end
     S3 --> SV2 & SV3
     S4 --> SV3
     S5 --> SV5
+    S6 --> SV6
+    S7 --> SV6
 
     %% Services to Storage
     SV4 --> ST1
     ST1 --> ST2 & ST3
     SV5 --> ST5
-    ST5 --> ST6 & ST7
+    ST5 --> ST6 & ST7 & ST8
 
     %% Services to APIs
     SV1 --> API1
     SV2 --> API3 & API4
     SV3 --> API2
 
+    %% MCP → External Servers
+    SV6 --> EXT1 & EXT2
+
     %% Styling
     classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
     classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
     classDef componentGroupStyle fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
+    classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px
     classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
     classDef stateStyle fill:#ffe0b2,stroke:#e65100,stroke-width:1px
     classDef methodStyle fill:#ffecb3,stroke:#e65100,stroke-width:1px
     classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px
     classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
     classDef serviceMStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
+    classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
     classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
     classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
 
@@ -257,23 +342,32 @@ end
     class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageUser,C_MessageEditForm componentStyle
     class C_ModelsSelector,C_Settings componentStyle
     class C_Attach componentStyle
-    class H1,H2,H3 methodStyle
-    class LayoutComponents,ChatUIComponents componentGroupStyle
-    class Hooks storeStyle
-    class S1,S2,S3,S4,S5 storeStyle
-    class S1State,S2State,S3State,S4State,S5State stateStyle
+    class C_McpSettings,C_McpServerCard,C_McpResourceBrowser,C_McpResourcePreview,C_McpServersSelector componentStyle
+    class H1,H2,H3 hookStyle
+    class LayoutComponents,ChatUIComponents,MCPComponents componentGroupStyle
+    class Hooks hookStyle
+    classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px
+    classDef agenticMethodStyle fill:#c5cae9,stroke:#283593,stroke-width:1px
+
+    class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
+    class S1State,S2State,S3State,S4State,S5State,SAState,S6State,S7State stateStyle
     class S1Msg,S1Regen,S1Edit,S1Stream,S1LoadState,S1ProcState,S1Error,S1Utils methodStyle
-    class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2Modal,S2Export,S2Utils methodStyle
+    class SASession,SAConfig,SAFlow methodStyle
+    class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2McpOverrides,S2Export,S2Utils methodStyle
     class S3Getters,S3Modal,S3Status,S3Fetch,S3Select,S3LoadUnload,S3Utils methodStyle
     class S4Getters,S4Data,S4Utils methodStyle
     class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle
-    class ChatExports,ConvExports,ModelsExports,ServerExports,SettingsExports reactiveStyle
-    class SV1,SV2,SV3,SV4,SV5 serviceStyle
+    class S6Lifecycle,S6Health,S6Servers,S6Tools,S6Prompts methodStyle
+    class S7Resources,S7Cache,S7Subs,S7Attach methodStyle
+    class ChatExports,AgenticExports,ConvExports,ModelsExports,ServerExports,SettingsExports,MCPExports reactiveStyle
+    class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
+    class SV6Transport,SV6Conn,SV6Tools,SV6Prompts,SV6Resources,SV6Complete serviceMStyle
+    class EXT1,EXT2 externalStyle
     class SV1Msg,SV1Stream,SV1Convert,SV1Utils serviceMStyle
     class SV2List,SV2LoadUnload,SV2Status serviceMStyle
     class SV3Fetch serviceMStyle
     class SV4Conv,SV4Msg,SV4Node,SV4Import serviceMStyle
     class SV5Extract,SV5Merge,SV5Info,SV5Diff serviceMStyle
-    class ST1,ST2,ST3,ST5,ST6,ST7 storageStyle
+    class ST1,ST2,ST3,ST5,ST6,ST7,ST8 storageStyle
     class API1,API2,API3,API4 apiStyle
 ```
index 05e1df385a795bb29cfff5322e24b0070c3f85cb..296693c6a541ed01c616db493f03e7dc5b238efd 100644 (file)
@@ -2,8 +2,10 @@
 sequenceDiagram
     participant UI as 🧩 ChatForm / ChatMessage
     participant chatStore as 🗄️ chatStore
+    participant agenticStore as 🗄️ agenticStore
     participant convStore as 🗄️ conversationsStore
     participant settingsStore as 🗄️ settingsStore
+    participant mcpStore as 🗄️ mcpStore
     participant ChatSvc as ⚙️ ChatService
     participant DbSvc as ⚙️ DatabaseService
     participant API as 🌐 /v1/chat/completions
@@ -25,6 +27,9 @@ sequenceDiagram
         Note over convStore: → see conversations-flow.mmd
     end
 
+    chatStore->>mcpStore: consumeResourceAttachmentsAsExtras()
+    Note right of mcpStore: Converts pending MCP resource<br/>attachments into message extras
+
     chatStore->>chatStore: addMessage("user", content, extras)
     chatStore->>DbSvc: createMessageBranch(userMsg, parentId)
     chatStore->>convStore: addMessageToActive(userMsg)
@@ -38,7 +43,7 @@ sequenceDiagram
     deactivate chatStore
 
     %% ═══════════════════════════════════════════════════════════════════════════
-    Note over UI,API: 🌊 STREAMING
+    Note over UI,API: 🌊 STREAMING (with agentic flow detection)
     %% ═══════════════════════════════════════════════════════════════════════════
 
     activate chatStore
@@ -52,10 +57,17 @@ sequenceDiagram
     chatStore->>chatStore: getApiOptions()
     Note right of chatStore: Merge from settingsStore.config:<br/>temperature, max_tokens, top_p, etc.
 
-    chatStore->>ChatSvc: sendMessage(messages, options, signal)
+    alt agenticConfig.enabled && mcpStore has connected servers
+        chatStore->>agenticStore: runAgenticFlow(convId, messages, assistantMsg, options, signal)
+        Note over agenticStore: Multi-turn agentic loop:<br/>1. Call ChatService.sendMessage()<br/>2. If response has tool_calls → execute via mcpStore<br/>3. Append tool results as messages<br/>4. Loop until no more tool_calls or maxTurns<br/>→ see agentic flow details below
+        agenticStore-->>chatStore: final response with timings
+    else standard (non-agentic) flow
+        chatStore->>ChatSvc: sendMessage(messages, options, signal)
+    end
+
     activate ChatSvc
 
-    ChatSvc->>ChatSvc: convertMessageToChatData(messages)
+    ChatSvc->>ChatSvc: convertDbMessageToApiChatMessageData(messages)
     Note right of ChatSvc: DatabaseMessage[] → ApiChatMessageData[]<br/>Process attachments (images, PDFs, audio)
 
     ChatSvc->>API: POST /v1/chat/completions
@@ -63,7 +75,7 @@ sequenceDiagram
 
     loop SSE chunks
         API-->>ChatSvc: data: {"choices":[{"delta":{...}}]}
-        ChatSvc->>ChatSvc: parseSSEChunk(line)
+        ChatSvc->>ChatSvc: handleStreamResponse(response)
 
         alt content chunk
             ChatSvc-->>chatStore: onChunk(content)
@@ -154,12 +166,15 @@ sequenceDiagram
     Note over UI,API: ✏️ EDIT USER MESSAGE
     %% ═══════════════════════════════════════════════════════════════════════════
 
-    UI->>chatStore: editUserMessagePreserveResponses(msgId, newContent)
+    UI->>chatStore: editMessageWithBranching(msgId, newContent, extras)
     activate chatStore
     chatStore->>chatStore: Get parent of target message
     chatStore->>DbSvc: createMessageBranch(editedMsg, parentId)
     chatStore->>convStore: refreshActiveMessages()
     Note right of chatStore: Creates new branch, original preserved
+    chatStore->>chatStore: createAssistantMessage(editedMsg.id)
+    chatStore->>chatStore: streamChatCompletion(...)
+    Note right of chatStore: Automatically regenerates response
     deactivate chatStore
 
     %% ═══════════════════════════════════════════════════════════════════════════
@@ -171,4 +186,43 @@ sequenceDiagram
     Note right of chatStore: errorDialogState = {type: 'timeout'|'server', message}
     chatStore->>convStore: removeMessageAtIndex(failedMsgIdx)
     chatStore->>DbSvc: deleteMessage(failedMsgId)
+
+    %% ═══════════════════════════════════════════════════════════════════════════
+    Note over UI,API: 🤖 AGENTIC LOOP (when agenticConfig.enabled)
+    %% ═══════════════════════════════════════════════════════════════════════════
+
+    Note over agenticStore: agenticStore.runAgenticFlow(convId, messages, assistantMsg, options, signal)
+    activate agenticStore
+    agenticStore->>agenticStore: getSession(convId) or create new
+    agenticStore->>agenticStore: updateSession(turn: 0, running: true)
+
+    loop executeAgenticLoop (until no tool_calls or maxTurns)
+        agenticStore->>agenticStore: turn++
+        agenticStore->>ChatSvc: sendMessage(messages, options, signal)
+        ChatSvc->>API: POST /v1/chat/completions
+        API-->>ChatSvc: response with potential tool_calls
+        ChatSvc-->>agenticStore: onComplete(content, reasoning, timings, toolCalls)
+
+        alt response has tool_calls
+            agenticStore->>agenticStore: normalizeToolCalls(toolCalls)
+            loop for each tool_call
+                agenticStore->>agenticStore: updateSession(streamingToolCall)
+                agenticStore->>mcpStore: executeTool(mcpCall, signal)
+                mcpStore-->>agenticStore: tool result
+                agenticStore->>agenticStore: extractBase64Attachments(result)
+                agenticStore->>agenticStore: emitToolCallResult(convId, ...)
+                agenticStore->>convStore: addMessageToActive(toolResultMsg)
+                agenticStore->>DbSvc: createMessageBranch(toolResultMsg)
+            end
+            agenticStore->>agenticStore: Create new assistantMsg for next turn
+            Note right of agenticStore: Continue loop with updated messages
+        else no tool_calls (final response)
+            agenticStore->>agenticStore: buildFinalTimings(allTurns)
+            Note right of agenticStore: Break loop, return final response
+        end
+    end
+
+    agenticStore->>agenticStore: updateSession(running: false)
+    agenticStore-->>chatStore: final content, timings, model
+    deactivate agenticStore
 ```
index 185ed16e0cdd2712e8b714520c379732ae1ad2f6..bd2309bc03eb46547f30ac10f489402a2d97723b 100644 (file)
@@ -6,7 +6,7 @@ sequenceDiagram
     participant DbSvc as ⚙️ DatabaseService
     participant IDB as 💾 IndexedDB
 
-    Note over convStore: State:<br/>conversations: DatabaseConversation[]<br/>activeConversation: DatabaseConversation | null<br/>activeMessages: DatabaseMessage[]<br/>isInitialized: boolean<br/>usedModalities: $derived({vision, audio})
+    Note over convStore: State:<br/>conversations: DatabaseConversation[]<br/>activeConversation: DatabaseConversation | null<br/>activeMessages: DatabaseMessage[]<br/>isInitialized: boolean<br/>pendingMcpServerOverrides: Map&lt;string, McpServerOverride&gt;
 
     %% ═══════════════════════════════════════════════════════════════════════════
     Note over UI,IDB: 🚀 INITIALIZATION
@@ -37,6 +37,13 @@ sequenceDiagram
     convStore->>convStore: conversations.unshift(conversation)
     convStore->>convStore: activeConversation = $state(conversation)
     convStore->>convStore: activeMessages = $state([])
+
+    alt pendingMcpServerOverrides has entries
+        loop each pending override
+            convStore->>DbSvc: Store MCP server override for new conversation
+        end
+        convStore->>convStore: clearPendingMcpServerOverrides()
+    end
     deactivate convStore
 
     %% ═══════════════════════════════════════════════════════════════════════════
@@ -58,8 +65,7 @@ sequenceDiagram
     Note right of convStore: Filter to show only current branch path
     convStore->>convStore: activeMessages = $state(filtered)
 
-    convStore->>chatStore: syncLoadingStateForChat(convId)
-    Note right of chatStore: Sync isLoading/currentResponse if streaming
+    Note right of convStore: Route (+page.svelte) then calls:<br/>chatStore.syncLoadingStateForChat(convId)
     deactivate convStore
 
     %% ═══════════════════════════════════════════════════════════════════════════
@@ -121,16 +127,36 @@ sequenceDiagram
     end
     deactivate convStore
 
+    UI->>convStore: deleteAll()
+    activate convStore
+    convStore->>DbSvc: Delete all conversations and messages
+    convStore->>convStore: conversations = []
+    convStore->>convStore: clearActiveConversation()
+    deactivate convStore
+
     %% ═══════════════════════════════════════════════════════════════════════════
-    Note over UI,IDB: 📊 MODALITY TRACKING
+    Note over UI,IDB: � 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
@@ -148,8 +174,10 @@ sequenceDiagram
     UI->>convStore: importConversations(file)
     activate convStore
     convStore->>convStore: Parse JSON file
+    convStore->>convStore: importConversationsData(parsed)
     convStore->>DbSvc: importConversations(parsed)
-    DbSvc->>IDB: Bulk INSERT conversations + messages
+    Note right of DbSvc: Skips duplicate conversations<br/>(checks existing by ID)
+    DbSvc->>IDB: INSERT conversations + messages (skip existing)
     convStore->>convStore: loadConversations()
     deactivate convStore
 ```
index 50f8284e3c31b471081915cc46feb9beb61a0472..38cd6941cf78ca3e0d2e3619fcf6d8a9d289bfb4 100644 (file)
@@ -66,6 +66,14 @@ sequenceDiagram
     DbSvc-->>Store: rootMessageId
     deactivate DbSvc
 
+    Store->>DbSvc: createSystemMessage(convId, content, parentId)
+    activate DbSvc
+    DbSvc->>DbSvc: Create message {role: "system", parent: parentId}
+    DbSvc->>Dexie: db.messages.add(systemMsg)
+    Dexie->>IDB: INSERT
+    DbSvc-->>Store: DatabaseMessage
+    deactivate DbSvc
+
     Store->>DbSvc: createMessageBranch(message, parentId)
     activate DbSvc
     DbSvc->>DbSvc: Generate UUID for new message
@@ -116,6 +124,13 @@ sequenceDiagram
     end
     DbSvc->>Dexie: db.messages.delete(msgId)
     Dexie->>IDB: DELETE target message
+
+    alt target message has a parent
+        DbSvc->>Dexie: db.messages.get(parentId)
+        DbSvc->>DbSvc: parent.children.filter(id !== msgId)
+        DbSvc->>Dexie: db.messages.update(parentId, {children})
+        Note right of DbSvc: Remove deleted message from parent's children[]
+    end
     deactivate DbSvc
 
     %% ═══════════════════════════════════════════════════════════════════════════
@@ -125,12 +140,16 @@ sequenceDiagram
     Store->>DbSvc: importConversations(data)
     activate DbSvc
     loop each conversation in data
-        DbSvc->>DbSvc: Generate new UUIDs (avoid conflicts)
-        DbSvc->>Dexie: db.conversations.add(conversation)
-        Dexie->>IDB: INSERT conversation
-        loop each message
-            DbSvc->>Dexie: db.messages.add(message)
-            Dexie->>IDB: INSERT message
+        DbSvc->>Dexie: db.conversations.get(conv.id)
+        alt conversation already exists
+            Note right of DbSvc: Skip duplicate (keep existing)
+        else conversation is new
+            DbSvc->>Dexie: db.conversations.add(conversation)
+            Dexie->>IDB: INSERT conversation
+            loop each message
+                DbSvc->>Dexie: db.messages.add(message)
+                Dexie->>IDB: INSERT message
+            end
         end
     end
     deactivate DbSvc
diff --git a/tools/server/webui/docs/flows/mcp-flow.md b/tools/server/webui/docs/flows/mcp-flow.md
new file mode 100644 (file)
index 0000000..c8aa666
--- /dev/null
@@ -0,0 +1,226 @@
+```mermaid
+sequenceDiagram
+    participant UI as 🧩 McpServersSettings / ChatForm
+    participant chatStore as 🗄️ chatStore
+    participant mcpStore as 🗄️ mcpStore
+    participant mcpResStore as 🗄️ mcpResourceStore
+    participant convStore as 🗄️ conversationsStore
+    participant MCPSvc as ⚙️ MCPService
+    participant LS as 💾 LocalStorage
+    participant ExtMCP as 🔌 External MCP Server
+
+    Note over mcpStore: State:<br/>isInitializing, error<br/>toolCount, connectedServers<br/>healthChecks (Map)<br/>connections (Map)<br/>toolsIndex (Map)<br/>serverConfigs (Map)
+
+    Note over mcpResStore: State:<br/>serverResources (Map)<br/>cachedResources (Map)<br/>subscriptions (Map)<br/>attachments[]
+
+    %% ═══════════════════════════════════════════════════════════════════════════
+    Note over UI,ExtMCP: 🚀 INITIALIZATION (App Startup)
+    %% ═══════════════════════════════════════════════════════════════════════════
+
+    UI->>mcpStore: ensureInitialized()
+    activate mcpStore
+
+    mcpStore->>LS: get(MCP_SERVERS_LOCALSTORAGE_KEY)
+    LS-->>mcpStore: MCPServerSettingsEntry[]
+
+    mcpStore->>mcpStore: parseServerSettings(servers)
+    Note right of mcpStore: Filter enabled servers<br/>Build MCPServerConfig objects<br/>Per-chat overrides checked via convStore
+
+    loop For each enabled server
+        mcpStore->>mcpStore: runHealthCheck(serverId)
+        mcpStore->>mcpStore: updateHealthCheck(id, CONNECTING)
+
+        mcpStore->>MCPSvc: connect(serverName, config, clientInfo, capabilities, onPhase)
+        activate MCPSvc
+
+        MCPSvc->>MCPSvc: createTransport(config)
+        Note right of MCPSvc: WebSocket / StreamableHTTP / SSE<br/>with optional CORS proxy
+
+        MCPSvc->>ExtMCP: Transport handshake
+        ExtMCP-->>MCPSvc: Connection established
+
+        MCPSvc->>ExtMCP: Initialize request
+        Note right of ExtMCP: Exchange capabilities<br/>Server info, protocol version
+
+        ExtMCP-->>MCPSvc: InitializeResult (serverInfo, capabilities)
+
+        MCPSvc->>ExtMCP: listTools()
+        ExtMCP-->>MCPSvc: Tool[]
+
+        MCPSvc-->>mcpStore: MCPConnection
+        deactivate MCPSvc
+
+        mcpStore->>mcpStore: connections.set(serverName, connection)
+        mcpStore->>mcpStore: indexTools(connection.tools, serverName)
+        Note right of mcpStore: toolsIndex.set(toolName, serverName)<br/>Handle name conflicts with prefixes
+
+        mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS)
+        mcpStore->>mcpStore: _connectedServers.push(serverName)
+
+        alt Server supports resources
+            mcpStore->>MCPSvc: listAllResources(connection)
+            MCPSvc->>ExtMCP: listResources()
+            ExtMCP-->>MCPSvc: MCPResource[]
+            MCPSvc-->>mcpStore: resources
+
+            mcpStore->>MCPSvc: listAllResourceTemplates(connection)
+            MCPSvc->>ExtMCP: listResourceTemplates()
+            ExtMCP-->>MCPSvc: MCPResourceTemplate[]
+            MCPSvc-->>mcpStore: templates
+
+            mcpStore->>mcpResStore: setServerResources(serverName, resources, templates)
+        end
+    end
+
+    mcpStore->>mcpStore: _isInitializing = false
+    deactivate mcpStore
+
+    %% ═══════════════════════════════════════════════════════════════════════════
+    Note over UI,ExtMCP: 🔧 TOOL EXECUTION (Chat with Tools)
+    %% ═══════════════════════════════════════════════════════════════════════════
+
+    UI->>mcpStore: executeTool(mcpCall: MCPToolCall, signal?)
+    activate mcpStore
+
+    mcpStore->>mcpStore: toolsIndex.get(mcpCall.function.name)
+    Note right of mcpStore: Resolve serverName from toolsIndex<br/>MCPToolCall = {id, type, function: {name, arguments}}
+
+    mcpStore->>mcpStore: acquireConnection()
+    Note right of mcpStore: activeFlowCount++<br/>Prevent shutdown during execution
+
+    mcpStore->>mcpStore: connection = connections.get(serverName)
+
+    mcpStore->>MCPSvc: callTool(connection, {name, arguments}, signal)
+    activate MCPSvc
+
+    MCPSvc->>MCPSvc: throwIfAborted(signal)
+    MCPSvc->>ExtMCP: callTool(name, arguments)
+
+    alt Tool execution success
+        ExtMCP-->>MCPSvc: ToolCallResult (content, isError)
+        MCPSvc->>MCPSvc: formatToolResult(result)
+        Note right of MCPSvc: Handle text, image (base64),<br/>embedded resource content
+        MCPSvc-->>mcpStore: ToolExecutionResult
+    else Tool execution error
+        ExtMCP-->>MCPSvc: Error
+        MCPSvc-->>mcpStore: throw Error
+    else Aborted
+        MCPSvc-->>mcpStore: throw AbortError
+    end
+
+    deactivate MCPSvc
+
+    mcpStore->>mcpStore: releaseConnection()
+    Note right of mcpStore: activeFlowCount--
+
+    mcpStore-->>UI: ToolExecutionResult
+    deactivate mcpStore
+
+    %% ═══════════════════════════════════════════════════════════════════════════
+    Note over UI,ExtMCP: � 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
+```
index 8d13e5a535f10385d2fdb068c00eaac271734d8b..361144915f0d37d174bb0152988a20dd8846fdb9 100644 (file)
@@ -8,6 +8,7 @@
                        "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",
@@ -19,7 +20,8 @@
                                "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",
index 0b74e301b1d8f4888befd7be749a7e081d03af50..f5cdc9e47f0b3b1187dee63b28a3b5b0b45fc8c8 100644 (file)
@@ -79,6 +79,7 @@
                "vitest-browser-svelte": "^0.1.0"
        },
        "dependencies": {
+               "@modelcontextprotocol/sdk": "^1.25.1",
                "highlight.js": "^11.11.1",
                "mode-watcher": "^1.1.0",
                "pdfjs-dist": "^5.4.54",
@@ -90,6 +91,7 @@
                "remark-html": "^16.0.1",
                "remark-rehype": "^11.1.2",
                "svelte-sonner": "^1.0.5",
-               "unist-util-visit": "^5.0.0"
+               "unist-util-visit": "^5.0.0",
+               "zod": "^4.2.1"
        }
 }
index 1ae3d2177477f9a9d9bc37d96b999f1a0b03364b..11f1c17d988257c47ccafbe1d2e84a8551437333 100644 (file)
@@ -6,21 +6,22 @@
                id: string;
                onRemove?: (id: string) => void;
                class?: string;
+               iconSize?: number;
        }
 
-       let { id, onRemove, class: className = '' }: Props = $props();
+       let { id, onRemove, class: className = '', iconSize = 3 }: Props = $props();
 </script>
 
 <Button
        type="button"
        variant="ghost"
-       size="sm"
-       class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}"
+       size="icon-sm"
+       class="bg-white/20 p-0 hover:bg-white/30 {className}"
        onclick={(e: MouseEvent) => {
                e.stopPropagation();
                onRemove?.(id);
        }}
        aria-label="Remove file"
 >
-       <X class="h-3 w-3" />
+       <X class="h-{iconSize} w-{iconSize}" />
 </Button>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpPrompt.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpPrompt.svelte
new file mode 100644 (file)
index 0000000..5fba2b3
--- /dev/null
@@ -0,0 +1,40 @@
+<script lang="ts">
+       import { ChatMessageMcpPromptContent, ActionIconRemove } from '$lib/components/app';
+       import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
+       import { McpPromptVariant } from '$lib/enums';
+
+       interface Props {
+               class?: string;
+               prompt: DatabaseMessageExtraMcpPrompt;
+               readonly?: boolean;
+               isLoading?: boolean;
+               loadError?: string;
+               onRemove?: () => void;
+       }
+
+       let {
+               class: className = '',
+               prompt,
+               readonly = false,
+               isLoading = false,
+               loadError,
+               onRemove
+       }: Props = $props();
+</script>
+
+<div class="group relative {className}">
+       <ChatMessageMcpPromptContent
+               {prompt}
+               variant={McpPromptVariant.ATTACHMENT}
+               {isLoading}
+               {loadError}
+       />
+
+       {#if !readonly && onRemove}
+               <div
+                       class="absolute top-10 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
+               >
+                       <ActionIconRemove id={prompt.name} onRemove={() => onRemove?.()} />
+               </div>
+       {/if}
+</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpResource.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpResource.svelte
new file mode 100644 (file)
index 0000000..258fcac
--- /dev/null
@@ -0,0 +1,86 @@
+<script lang="ts">
+       import { Loader2, AlertCircle } from '@lucide/svelte';
+       import { cn } from '$lib/components/ui/utils';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import type { MCPResourceAttachment } from '$lib/types';
+       import * as Tooltip from '$lib/components/ui/tooltip';
+       import { ActionIconRemove } from '$lib/components/app';
+       import { getResourceIcon, getResourceDisplayName } from '$lib/utils';
+
+       interface Props {
+               attachment: MCPResourceAttachment;
+               onRemove?: (attachmentId: string) => void;
+               onClick?: () => void;
+               class?: string;
+       }
+
+       let { attachment, onRemove, onClick, class: className }: Props = $props();
+
+       function getStatusClass(attachment: MCPResourceAttachment): string {
+               if (attachment.error) return 'border-red-500/50 bg-red-500/10';
+               if (attachment.loading) return 'border-border/50 bg-muted/30';
+               return 'border-border/50 bg-muted/30';
+       }
+
+       const ResourceIcon = $derived(
+               getResourceIcon(attachment.resource.mimeType, attachment.resource.uri)
+       );
+       const serverName = $derived(mcpStore.getServerDisplayName(attachment.resource.serverName));
+       const favicon = $derived(mcpStore.getServerFavicon(attachment.resource.serverName));
+</script>
+
+<Tooltip.Root>
+       <Tooltip.Trigger>
+               <button
+                       type="button"
+                       class={cn(
+                               'flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.75 text-sm transition-colors',
+                               getStatusClass(attachment),
+                               onClick && 'cursor-pointer hover:bg-muted/50',
+                               className
+                       )}
+                       onclick={onClick}
+                       disabled={!onClick}
+               >
+                       {#if attachment.loading}
+                               <Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
+                       {:else if attachment.error}
+                               <AlertCircle class="h-3 w-3 text-red-500" />
+                       {:else}
+                               <ResourceIcon class="h-3 w-3 text-muted-foreground" />
+                       {/if}
+
+                       <span class="max-w-[150px] truncate text-xs">
+                               {getResourceDisplayName(attachment.resource)}
+                       </span>
+
+                       {#if onRemove}
+                               <ActionIconRemove
+                                       class="-my-2 -mr-1.5 bg-transparent"
+                                       iconSize={2}
+                                       id={attachment.id}
+                                       {onRemove}
+                               />
+                       {/if}
+               </button>
+       </Tooltip.Trigger>
+
+       <Tooltip.Content>
+               <div class="flex items-center gap-1 text-xs">
+                       {#if favicon}
+                               <img
+                                       src={favicon}
+                                       alt=""
+                                       class="h-3 w-3 shrink-0 rounded-sm"
+                                       onerror={(e) => {
+                                               (e.currentTarget as HTMLImageElement).style.display = 'none';
+                                       }}
+                               />
+                       {/if}
+
+                       <span class="truncate">
+                               {serverName}
+                       </span>
+               </div>
+       </Tooltip.Content>
+</Tooltip.Root>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpResources.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpResources.svelte
new file mode 100644 (file)
index 0000000..341bf32
--- /dev/null
@@ -0,0 +1,41 @@
+<script lang="ts">
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import {
+               mcpResourceAttachments,
+               mcpHasResourceAttachments
+       } from '$lib/stores/mcp-resources.svelte';
+       import { ChatAttachmentMcpResource, HorizontalScrollCarousel } from '$lib/components/app';
+
+       interface Props {
+               class?: string;
+               onResourceClick?: (uri: string) => void;
+       }
+
+       let { class: className, onResourceClick }: Props = $props();
+
+       const attachments = $derived(mcpResourceAttachments());
+       const hasAttachments = $derived(mcpHasResourceAttachments());
+
+       function handleRemove(attachmentId: string) {
+               mcpStore.removeResourceAttachment(attachmentId);
+       }
+
+       function handleResourceClick(uri: string) {
+               onResourceClick?.(uri);
+       }
+</script>
+
+{#if hasAttachments}
+       <div class={className}>
+               <HorizontalScrollCarousel gapSize="2">
+                       {#each attachments as attachment, i (attachment.id)}
+                               <ChatAttachmentMcpResource
+                                       class={i === 0 ? 'ml-3' : ''}
+                                       {attachment}
+                                       onRemove={handleRemove}
+                                       onClick={() => handleResourceClick(attachment.resource.uri)}
+                               />
+                       {/each}
+               </HorizontalScrollCarousel>
+       </div>
+{/if}
index 6248d84fb0d3706ff8957157a2945b65f4315566..a3d37b42a3b7fb5d300c1b2cba19bb5cd4f9e47c 100644 (file)
@@ -1,12 +1,21 @@
 <script lang="ts">
        import {
+               ChatAttachmentMcpPrompt,
+               ChatAttachmentMcpResource,
                ChatAttachmentThumbnailImage,
                ChatAttachmentThumbnailFile,
                HorizontalScrollCarousel,
                DialogChatAttachmentPreview,
-               DialogChatAttachmentsViewAll
+               DialogChatAttachmentsViewAll,
+               DialogMcpResourcePreview
        } from '$lib/components/app';
        import { Button } from '$lib/components/ui/button';
+       import { AttachmentType } from '$lib/enums';
+       import type {
+               DatabaseMessageExtraMcpPrompt,
+               DatabaseMessageExtraMcpResource,
+               MCPResourceAttachment
+       } from '$lib/types';
        import { getAttachmentDisplayItems } from '$lib/utils';
 
        interface Props {
@@ -49,6 +58,8 @@
        let isScrollable = $state(false);
        let previewDialogOpen = $state(false);
        let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
+       let mcpResourcePreviewOpen = $state(false);
+       let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
        let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
        let viewAllDialogOpen = $state(false);
 
                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}
index 37888d92e537199c7cbbea1112f354e3985b116e..fea1e82903d3ce37933c31c9ddc33c0a7ea269c9 100644 (file)
@@ -1,22 +1,39 @@
 <script lang="ts">
        import {
                ChatAttachmentsList,
+               ChatAttachmentMcpResources,
                ChatFormActions,
                ChatFormFileInputInvisible,
+               ChatFormPromptPicker,
+               ChatFormResourcePicker,
                ChatFormTextarea
        } from '$lib/components/app';
+       import { DialogMcpResources } from '$lib/components/app/dialogs';
        import {
                CLIPBOARD_CONTENT_QUOTE_PREFIX,
                INPUT_CLASSES,
-               SETTING_CONFIG_DEFAULT
+               SETTING_CONFIG_DEFAULT,
+               INITIAL_FILE_SIZE,
+               PROMPT_CONTENT_SEPARATOR,
+               PROMPT_TRIGGER_PREFIX,
+               RESOURCE_TRIGGER_PREFIX
        } from '$lib/constants';
-       import { KeyboardKey, MimeTypeText } from '$lib/enums';
+       import {
+               ContentPartType,
+               FileExtensionText,
+               KeyboardKey,
+               MimeTypeText,
+               SpecialFileType
+       } from '$lib/enums';
        import { config } from '$lib/stores/settings.svelte';
        import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
        import { isRouterMode } from '$lib/stores/server.svelte';
        import { chatStore } from '$lib/stores/chat.svelte';
-       import { activeMessages } from '$lib/stores/conversations.svelte';
-       import { isIMEComposing, parseClipboardContent } from '$lib/utils';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
+       import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
+       import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
+       import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
        import {
                AudioRecorder,
                convertToWav,
@@ -36,6 +53,7 @@
                disabled?: boolean;
                isLoading?: boolean;
                placeholder?: string;
+               showMcpPromptButton?: boolean;
 
                // Event Handlers
                onAttachmentRemove?: (index: number) => void;
@@ -44,6 +62,7 @@
                onSubmit?: () => void;
                onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
                onUploadedFileRemove?: (fileId: string) => void;
+               onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
                onValueChange?: (value: string) => void;
        }
 
@@ -53,6 +72,7 @@
                disabled = false,
                isLoading = false,
                placeholder = 'Type a message...',
+               showMcpPromptButton = false,
                uploadedFiles = $bindable([]),
                value = $bindable(''),
                onAttachmentRemove,
@@ -61,6 +81,7 @@
                onSubmit,
                onSystemPromptClick,
                onUploadedFileRemove,
+               onUploadedFilesChange,
                onValueChange
        }: Props = $props();
 
        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;
+               }
+       }}
+/>
index 86dca844829b852eef22f2a99a937b52ac99b46a..81b55513d32d2e915a94b34e8f1588f9e40f87e9 100644 (file)
@@ -1,18 +1,30 @@
 <script lang="ts">
        import { page } from '$app/state';
-       import { Plus, MessageSquare } from '@lucide/svelte';
+       import { Plus, MessageSquare, Settings, Zap, FolderOpen } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
        import * as Tooltip from '$lib/components/ui/tooltip';
+       import { Switch } from '$lib/components/ui/switch';
        import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
+       import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
+       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+
+       import { HealthCheckStatus } from '$lib/enums';
+       import type { MCPServerSettingsEntry } from '$lib/types';
 
        interface Props {
                class?: string;
                disabled?: boolean;
                hasAudioModality?: boolean;
                hasVisionModality?: boolean;
+               hasMcpPromptsSupport?: boolean;
+               hasMcpResourcesSupport?: boolean;
                onFileUpload?: () => void;
                onSystemPromptClick?: () => void;
+               onMcpPromptClick?: () => void;
+               onMcpSettingsClick?: () => void;
+               onMcpResourcesClick?: () => void;
        }
 
        let {
                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>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsSheet.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsSheet.svelte
new file mode 100644 (file)
index 0000000..bf643dd
--- /dev/null
@@ -0,0 +1,170 @@
+<script lang="ts">
+       import { Plus, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import * as Sheet from '$lib/components/ui/sheet';
+       import { FILE_TYPE_ICONS } from '$lib/constants';
+       import { McpLogo } from '$lib/components/app';
+
+       interface Props {
+               class?: string;
+               disabled?: boolean;
+               hasAudioModality?: boolean;
+               hasVisionModality?: boolean;
+               hasMcpPromptsSupport?: boolean;
+               hasMcpResourcesSupport?: boolean;
+               onFileUpload?: () => void;
+               onSystemPromptClick?: () => void;
+               onMcpPromptClick?: () => void;
+               onMcpSettingsClick?: () => void;
+               onMcpResourcesClick?: () => void;
+       }
+
+       let {
+               class: className = '',
+               disabled = false,
+               hasAudioModality = false,
+               hasVisionModality = false,
+               hasMcpPromptsSupport = false,
+               hasMcpResourcesSupport = false,
+               onFileUpload,
+               onSystemPromptClick,
+               onMcpPromptClick,
+               onMcpSettingsClick,
+               onMcpResourcesClick
+       }: Props = $props();
+
+       let sheetOpen = $state(false);
+
+       function handleMcpPromptClick() {
+               sheetOpen = false;
+               onMcpPromptClick?.();
+       }
+
+       function handleMcpSettingsClick() {
+               onMcpSettingsClick?.();
+       }
+
+       function handleMcpResourcesClick() {
+               sheetOpen = false;
+               onMcpResourcesClick?.();
+       }
+
+       function handleSheetFileUpload() {
+               sheetOpen = false;
+               onFileUpload?.();
+       }
+
+       function handleSheetSystemPromptClick() {
+               sheetOpen = false;
+               onSystemPromptClick?.();
+       }
+
+       const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
+
+       const sheetItemClass =
+               'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
+</script>
+
+<div class="flex items-center gap-1 {className}">
+       <Sheet.Root bind:open={sheetOpen}>
+               <Button
+                       class="file-upload-button h-8 w-8 rounded-full p-0"
+                       {disabled}
+                       variant="secondary"
+                       type="button"
+                       onclick={() => (sheetOpen = true)}
+               >
+                       <span class="sr-only">{fileUploadTooltipText}</span>
+
+                       <Plus class="h-4 w-4" />
+               </Button>
+
+               <Sheet.Content side="bottom" class="max-h-[85vh] gap-0">
+                       <Sheet.Header>
+                               <Sheet.Title>Add to chat</Sheet.Title>
+
+                               <Sheet.Description class="sr-only">
+                                       Add files, system prompt or configure MCP servers
+                               </Sheet.Description>
+                       </Sheet.Header>
+
+                       <div class="flex flex-col gap-1 overflow-y-auto px-1.5 pb-2">
+                               <!-- Images -->
+                               <button
+                                       type="button"
+                                       class={sheetItemClass}
+                                       disabled={!hasVisionModality}
+                                       onclick={handleSheetFileUpload}
+                               >
+                                       <FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
+
+                                       <span>Images</span>
+
+                                       {#if !hasVisionModality}
+                                               <span class="ml-auto text-xs text-muted-foreground">Requires vision model</span>
+                                       {/if}
+                               </button>
+
+                               <!-- Audio -->
+                               <button
+                                       type="button"
+                                       class={sheetItemClass}
+                                       disabled={!hasAudioModality}
+                                       onclick={handleSheetFileUpload}
+                               >
+                                       <FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
+
+                                       <span>Audio Files</span>
+
+                                       {#if !hasAudioModality}
+                                               <span class="ml-auto text-xs text-muted-foreground">Requires audio model</span>
+                                       {/if}
+                               </button>
+
+                               <button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
+                                       <FILE_TYPE_ICONS.text class="h-4 w-4 shrink-0" />
+
+                                       <span>Text Files</span>
+                               </button>
+
+                               <button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
+                                       <FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
+
+                                       <span>PDF Files</span>
+
+                                       {#if !hasVisionModality}
+                                               <span class="ml-auto text-xs text-muted-foreground">Text-only</span>
+                                       {/if}
+                               </button>
+
+                               <button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
+                                       <MessageSquare class="h-4 w-4 shrink-0" />
+
+                                       <span>System Message</span>
+                               </button>
+
+                               <button type="button" class={sheetItemClass} onclick={handleMcpSettingsClick}>
+                                       <McpLogo class="h-4 w-4 shrink-0" />
+
+                                       <span>MCP Servers</span>
+                               </button>
+
+                               {#if hasMcpPromptsSupport}
+                                       <button type="button" class={sheetItemClass} onclick={handleMcpPromptClick}>
+                                               <Zap class="h-4 w-4 shrink-0" />
+
+                                               <span>MCP Prompt</span>
+                                       </button>
+                               {/if}
+
+                               {#if hasMcpResourcesSupport}
+                                       <button type="button" class={sheetItemClass} onclick={handleMcpResourcesClick}>
+                                               <FolderOpen class="h-4 w-4 shrink-0" />
+
+                                               <span>MCP Resources</span>
+                                       </button>
+                               {/if}
+                       </div>
+               </Sheet.Content>
+       </Sheet.Root>
+</div>
index c94fe267d53b2b6b6ff64527c29dc865247d75db..850177693393e7ce9b44f6b387e671dc070eb45f 100644 (file)
@@ -3,17 +3,24 @@
        import { Button } from '$lib/components/ui/button';
        import {
                ChatFormActionAttachmentsDropdown,
+               ChatFormActionAttachmentsSheet,
                ChatFormActionRecord,
                ChatFormActionSubmit,
-               ModelsSelector
+               McpServersSelector,
+               ModelsSelector,
+               ModelsSelectorSheet
        } from '$lib/components/app';
+       import { DialogChatSettings } from '$lib/components/app/dialogs';
+       import { SETTINGS_SECTION_TITLES } from '$lib/constants';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
        import { FileTypeCategory } from '$lib/enums';
        import { getFileTypeCategory } from '$lib/utils';
        import { config } from '$lib/stores/settings.svelte';
        import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
        import { isRouterMode, serverError } from '$lib/stores/server.svelte';
        import { chatStore } from '$lib/stores/chat.svelte';
-       import { activeMessages } from '$lib/stores/conversations.svelte';
+       import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
+       import { IsMobile } from '$lib/hooks/is-mobile.svelte';
 
        interface Props {
                canSend?: boolean;
@@ -27,6 +34,8 @@
                onMicClick?: () => void;
                onStop?: () => void;
                onSystemPromptClick?: () => void;
+               onMcpPromptClick?: () => void;
+               onMcpResourcesClick?: () => void;
        }
 
        let {
@@ -40,7 +49,9 @@
                onFileUpload,
                onMicClick,
                onStop,
-               onSystemPromptClick
+               onSystemPromptClick,
+               onMcpPromptClick,
+               onMcpResourcesClick
        }: Props = $props();
 
        let currentConfig = $derived(config());
                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}
+/>
index f8246f249c3eb8e0d39eda9288f319a864f662cc..a8f1f76c7cf5924f89b8bc5d4f55645d60951135 100644 (file)
@@ -8,7 +8,7 @@
 </script>
 
 {#if show}
-       <div class="mt-4 flex items-center justify-center {className}">
+       <div class="mt-6 items-center justify-center {className} hidden md:flex">
                <p class="text-xs text-muted-foreground">
                        Press <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Enter</kbd> to send,
                        <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Shift + Enter</kbd> for new line
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerItemHeader.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerItemHeader.svelte
new file mode 100644 (file)
index 0000000..11ca520
--- /dev/null
@@ -0,0 +1,55 @@
+<script lang="ts">
+       import type { Snippet } from 'svelte';
+       import type { MCPServerSettingsEntry } from '$lib/types';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+
+       interface Props {
+               server: MCPServerSettingsEntry | undefined;
+               serverLabel: string;
+               title: string;
+               description?: string;
+               titleExtra?: Snippet;
+               subtitle?: Snippet;
+       }
+
+       let { server, serverLabel, title, description, titleExtra, subtitle }: Props = $props();
+
+       let faviconUrl = $derived(server ? mcpStore.getServerFavicon(server.id) : null);
+</script>
+
+<div class="min-w-0 flex-1">
+       <div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
+               {#if faviconUrl}
+                       <img
+                               src={faviconUrl}
+                               alt=""
+                               class="h-3 w-3 shrink-0 rounded-sm"
+                               onerror={(e) => {
+                                       (e.currentTarget as HTMLImageElement).style.display = 'none';
+                               }}
+                       />
+               {/if}
+
+               <span>{serverLabel}</span>
+       </div>
+
+       <div class="flex items-center gap-2">
+               <span class="font-medium">
+                       {title}
+               </span>
+
+               {#if titleExtra}
+                       {@render titleExtra()}
+               {/if}
+       </div>
+
+       {#if description}
+               <p class="mt-0.5 truncate text-sm text-muted-foreground">
+                       {description}
+               </p>
+       {/if}
+
+       {#if subtitle}
+               {@render subtitle()}
+       {/if}
+</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerList.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerList.svelte
new file mode 100644 (file)
index 0000000..1a95cd7
--- /dev/null
@@ -0,0 +1,82 @@
+<script lang="ts" generics="T">
+       import type { Snippet } from 'svelte';
+       import { SearchInput } from '$lib/components/app';
+       import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
+       import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
+
+       interface Props {
+               items: T[];
+               isLoading: boolean;
+               selectedIndex: number;
+               searchQuery: string;
+               showSearchInput: boolean;
+               searchPlaceholder?: string;
+               emptyMessage?: string;
+               itemKey: (item: T, index: number) => string;
+               item: Snippet<[T, number, boolean]>;
+               skeleton?: Snippet;
+               footer?: Snippet;
+       }
+
+       let {
+               items,
+               isLoading,
+               selectedIndex,
+               searchQuery = $bindable(),
+               showSearchInput,
+               searchPlaceholder = 'Search...',
+               emptyMessage = 'No items available',
+               itemKey,
+               item,
+               skeleton,
+               footer
+       }: Props = $props();
+
+       let listContainer = $state<HTMLDivElement | null>(null);
+
+       $effect(() => {
+               if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
+                       const selectedElement = listContainer.querySelector(
+                               `[data-picker-index="${selectedIndex}"]`
+                       ) as HTMLElement;
+
+                       if (selectedElement) {
+                               selectedElement.scrollIntoView({
+                                       behavior: 'smooth',
+                                       block: 'center',
+                                       inline: 'nearest'
+                               });
+                       }
+               }
+       });
+</script>
+
+<ScrollArea>
+       {#if showSearchInput}
+               <div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
+                       <SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
+               </div>
+       {/if}
+
+       <div
+               bind:this={listContainer}
+               class="{CHAT_FORM_POPOVER_MAX_HEIGHT} p-2"
+               class:pt-13={showSearchInput}
+       >
+               {#if isLoading}
+                       {#if skeleton}
+                               {@render skeleton()}
+                       {/if}
+               {:else if items.length === 0}
+                       <div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
+               {:else}
+                       {#each items as itemData, index (itemKey(itemData, index))}
+                               {@render item(itemData, index, index === selectedIndex)}
+                       {/each}
+               {/if}
+       </div>
+
+       {#if footer}
+               {@render footer()}
+       {/if}
+</ScrollArea>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerListItem.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerListItem.svelte
new file mode 100644 (file)
index 0000000..d232d66
--- /dev/null
@@ -0,0 +1,23 @@
+<script lang="ts">
+       import type { Snippet } from 'svelte';
+
+       interface Props {
+               isSelected?: boolean;
+               onClick: () => void;
+               dataIndex?: number;
+               children: Snippet;
+       }
+
+       let { isSelected = false, onClick, dataIndex, children }: Props = $props();
+</script>
+
+<button
+       type="button"
+       data-picker-index={dataIndex}
+       onclick={onClick}
+       class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
+               ? 'bg-accent/50'
+               : ''}"
+>
+       {@render children()}
+</button>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte
new file mode 100644 (file)
index 0000000..5a2ab26
--- /dev/null
@@ -0,0 +1,30 @@
+<script lang="ts">
+       interface Props {
+               titleWidth?: string;
+               showBadge?: boolean;
+       }
+
+       let { titleWidth = 'w-48', showBadge = false }: Props = $props();
+</script>
+
+<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
+       <div class="min-w-0 flex-1 space-y-2">
+               <!-- Server label skeleton -->
+               <div class="mb-2 flex items-center gap-1.5">
+                       <div class="h-3 w-3 shrink-0 animate-pulse rounded-sm bg-muted"></div>
+                       <div class="h-3 w-24 animate-pulse rounded bg-muted"></div>
+               </div>
+
+               <!-- Title skeleton -->
+               <div class="flex items-center gap-2">
+                       <div class="h-4 {titleWidth} animate-pulse rounded bg-muted"></div>
+
+                       {#if showBadge}
+                               <div class="h-4 w-12 animate-pulse rounded-full bg-muted"></div>
+                       {/if}
+               </div>
+
+               <!-- Description skeleton -->
+               <div class="h-3 w-full animate-pulse rounded bg-muted"></div>
+       </div>
+</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickerPopover.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickerPopover.svelte
new file mode 100644 (file)
index 0000000..9957085
--- /dev/null
@@ -0,0 +1,46 @@
+<script lang="ts">
+       import type { Snippet } from 'svelte';
+       import * as Popover from '$lib/components/ui/popover';
+
+       interface Props {
+               class?: string;
+               isOpen?: boolean;
+               srLabel?: string;
+               onClose?: () => void;
+               onKeydown?: (event: KeyboardEvent) => void;
+               children: Snippet;
+       }
+
+       let {
+               class: className = '',
+               isOpen = $bindable(false),
+               srLabel = 'Open picker',
+               onClose,
+               onKeydown,
+               children
+       }: Props = $props();
+</script>
+
+<Popover.Root
+       bind:open={isOpen}
+       onOpenChange={(open) => {
+               if (!open) {
+                       onClose?.();
+               }
+       }}
+>
+       <Popover.Trigger class="pointer-events-none absolute inset-0 opacity-0">
+               <span class="sr-only">{srLabel}</span>
+       </Popover.Trigger>
+
+       <Popover.Content
+               side="top"
+               align="start"
+               sideOffset={12}
+               class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
+               onkeydown={onKeydown}
+               onOpenAutoFocus={(e) => e.preventDefault()}
+       >
+               {@render children()}
+       </Popover.Content>
+</Popover.Root>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte
new file mode 100644 (file)
index 0000000..12d4860
--- /dev/null
@@ -0,0 +1,435 @@
+<script lang="ts">
+       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { debounce, uuid } from '$lib/utils';
+       import { KeyboardKey } from '$lib/enums';
+       import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
+       import { SvelteMap } from 'svelte/reactivity';
+       import Badge from '$lib/components/ui/badge/badge.svelte';
+       import {
+               ChatFormPickerPopover,
+               ChatFormPickerList,
+               ChatFormPickerListItem,
+               ChatFormPickerItemHeader,
+               ChatFormPickerListItemSkeleton,
+               ChatFormPromptPickerArgumentForm
+       } from '$lib/components/app/chat';
+
+       interface Props {
+               class?: string;
+               isOpen?: boolean;
+               searchQuery?: string;
+               onClose?: () => void;
+               onPromptLoadStart?: (
+                       placeholderId: string,
+                       promptInfo: MCPPromptInfo,
+                       args?: Record<string, string>
+               ) => void;
+               onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
+               onPromptLoadError?: (placeholderId: string, error: string) => void;
+       }
+
+       let {
+               class: className = '',
+               isOpen = false,
+               searchQuery = '',
+               onClose,
+               onPromptLoadStart,
+               onPromptLoadComplete,
+               onPromptLoadError
+       }: Props = $props();
+
+       let prompts = $state<MCPPromptInfo[]>([]);
+       let isLoading = $state(false);
+       let selectedPrompt = $state<MCPPromptInfo | null>(null);
+       let promptArgs = $state<Record<string, string>>({});
+       let selectedIndex = $state(0);
+       let internalSearchQuery = $state('');
+       let promptError = $state<string | null>(null);
+       let selectedIndexBeforeArgumentForm = $state<number | null>(null);
+
+       let suggestions = $state<Record<string, string[]>>({});
+       let loadingSuggestions = $state<Record<string, boolean>>({});
+       let activeAutocomplete = $state<string | null>(null);
+       let autocompleteIndex = $state(0);
+
+       let serverSettingsMap = $derived.by(() => {
+               const servers = mcpStore.getServers();
+               const map = new SvelteMap<string, MCPServerSettingsEntry>();
+
+               for (const server of servers) {
+                       map.set(server.id, server);
+               }
+
+               return map;
+       });
+
+       $effect(() => {
+               if (isOpen) {
+                       loadPrompts();
+                       selectedIndex = 0;
+               } else {
+                       selectedPrompt = null;
+                       promptArgs = {};
+                       promptError = null;
+               }
+       });
+
+       $effect(() => {
+               if (filteredPrompts.length > 0 && selectedIndex >= filteredPrompts.length) {
+                       selectedIndex = 0;
+               }
+       });
+
+       async function loadPrompts() {
+               isLoading = true;
+
+               try {
+                       const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
+
+                       const initialized = await mcpStore.ensureInitialized(perChatOverrides);
+
+                       if (!initialized) {
+                               prompts = [];
+
+                               return;
+                       }
+
+                       prompts = await mcpStore.getAllPrompts();
+               } catch (error) {
+                       console.error('[ChatFormPromptPicker] Failed to load prompts:', error);
+                       prompts = [];
+               } finally {
+                       isLoading = false;
+               }
+       }
+
+       function handlePromptClick(prompt: MCPPromptInfo) {
+               const args = prompt.arguments ?? [];
+
+               if (args.length > 0) {
+                       selectedIndexBeforeArgumentForm = selectedIndex;
+                       selectedPrompt = prompt;
+                       promptArgs = {};
+                       promptError = null;
+
+                       requestAnimationFrame(() => {
+                               const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
+                               if (firstInput) {
+                                       firstInput.focus();
+                               }
+                       });
+               } else {
+                       executePrompt(prompt, {});
+               }
+       }
+
+       async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
+               promptError = null;
+
+               const placeholderId = uuid();
+
+               const nonEmptyArgs = Object.fromEntries(
+                       Object.entries(args).filter(([, value]) => value.trim() !== '')
+               );
+               const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
+
+               onPromptLoadStart?.(placeholderId, prompt, argsToPass);
+               onClose?.();
+
+               try {
+                       const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
+                       onPromptLoadComplete?.(placeholderId, result);
+               } catch (error) {
+                       const errorMessage =
+                               error instanceof Error ? error.message : 'Unknown error executing prompt';
+                       onPromptLoadError?.(placeholderId, errorMessage);
+               }
+       }
+
+       function handleArgumentSubmit(event: SubmitEvent) {
+               event.preventDefault();
+
+               if (selectedPrompt) {
+                       executePrompt(selectedPrompt, promptArgs);
+               }
+       }
+
+       const fetchCompletions = debounce(async (argName: string, value: string) => {
+               if (!selectedPrompt || value.length < 1) {
+                       suggestions[argName] = [];
+
+                       return;
+               }
+
+               if (import.meta.env.DEV) {
+                       console.log('[ChatFormPromptPicker] Fetching completions for:', {
+                               serverName: selectedPrompt.serverName,
+                               promptName: selectedPrompt.name,
+                               argName,
+                               value
+                       });
+               }
+
+               loadingSuggestions[argName] = true;
+
+               try {
+                       const result = await mcpStore.getPromptCompletions(
+                               selectedPrompt.serverName,
+                               selectedPrompt.name,
+                               argName,
+                               value
+                       );
+
+                       if (import.meta.env.DEV) {
+                               console.log('[ChatFormPromptPicker] Autocomplete result:', {
+                                       argName,
+                                       value,
+                                       result,
+                                       suggestionsCount: result?.values.length ?? 0
+                               });
+                       }
+
+                       if (result && result.values.length > 0) {
+                               // Filter out empty strings from suggestions
+                               const filteredValues = result.values.filter((v) => v.trim() !== '');
+
+                               if (filteredValues.length > 0) {
+                                       suggestions[argName] = filteredValues;
+                                       activeAutocomplete = argName;
+                                       autocompleteIndex = 0;
+                               } else {
+                                       suggestions[argName] = [];
+                               }
+                       } else {
+                               suggestions[argName] = [];
+                       }
+               } catch (error) {
+                       console.error('[ChatFormPromptPicker] Failed to fetch completions:', error);
+                       suggestions[argName] = [];
+               } finally {
+                       loadingSuggestions[argName] = false;
+               }
+       }, 200);
+
+       function handleArgInput(argName: string, value: string) {
+               promptArgs[argName] = value;
+               fetchCompletions(argName, value);
+       }
+
+       function selectSuggestion(argName: string, value: string) {
+               promptArgs[argName] = value;
+               suggestions[argName] = [];
+               activeAutocomplete = null;
+       }
+
+       function handleArgKeydown(event: KeyboardEvent, argName: string) {
+               const argSuggestions = suggestions[argName] ?? [];
+
+               // Handle Escape - return to prompt selection list
+               if (event.key === KeyboardKey.ESCAPE) {
+                       event.preventDefault();
+                       event.stopPropagation();
+                       handleCancelArgumentForm();
+                       return;
+               }
+
+               if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
+
+               if (event.key === KeyboardKey.ARROW_DOWN) {
+                       event.preventDefault();
+                       autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
+               } else if (event.key === KeyboardKey.ARROW_UP) {
+                       event.preventDefault();
+                       autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
+               } else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
+                       event.preventDefault();
+                       event.stopPropagation();
+                       selectSuggestion(argName, argSuggestions[autocompleteIndex]);
+               }
+       }
+
+       function handleArgBlur(argName: string) {
+               // Delay to allow click on suggestion
+               setTimeout(() => {
+                       if (activeAutocomplete === argName) {
+                               suggestions[argName] = [];
+                               activeAutocomplete = null;
+                       }
+               }, 150);
+       }
+
+       function handleArgFocus(argName: string) {
+               if ((suggestions[argName]?.length ?? 0) > 0) {
+                       activeAutocomplete = argName;
+               }
+       }
+
+       function handleCancelArgumentForm() {
+               // Restore the previously selected prompt index
+               if (selectedIndexBeforeArgumentForm !== null) {
+                       selectedIndex = selectedIndexBeforeArgumentForm;
+                       selectedIndexBeforeArgumentForm = null;
+               }
+               selectedPrompt = null;
+               promptArgs = {};
+               promptError = null;
+       }
+
+       export function handleKeydown(event: KeyboardEvent): boolean {
+               if (!isOpen) return false;
+
+               if (event.key === KeyboardKey.ESCAPE) {
+                       event.preventDefault();
+                       if (selectedPrompt) {
+                               // Return to prompt selection list, keeping the selected prompt active
+                               handleCancelArgumentForm();
+                       } else {
+                               onClose?.();
+                       }
+
+                       return true;
+               }
+
+               if (event.key === KeyboardKey.ARROW_DOWN) {
+                       event.preventDefault();
+                       if (filteredPrompts.length > 0) {
+                               selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
+                       }
+
+                       return true;
+               }
+
+               if (event.key === KeyboardKey.ARROW_UP) {
+                       event.preventDefault();
+                       if (filteredPrompts.length > 0) {
+                               selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
+                       }
+
+                       return true;
+               }
+
+               if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
+                       event.preventDefault();
+                       if (filteredPrompts[selectedIndex]) {
+                               handlePromptClick(filteredPrompts[selectedIndex]);
+                       }
+
+                       return true;
+               }
+
+               return false;
+       }
+
+       let filteredPrompts = $derived.by(() => {
+               const sortedServers = mcpStore.getServersSorted();
+               const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
+
+               const sortedPrompts = [...prompts].sort((a, b) => {
+                       const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
+                       const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
+                       return orderA - orderB;
+               });
+
+               const query = (searchQuery || internalSearchQuery).toLowerCase();
+               if (!query) return sortedPrompts;
+
+               return sortedPrompts.filter(
+                       (prompt) =>
+                               prompt.name.toLowerCase().includes(query) ||
+                               prompt.title?.toLowerCase().includes(query) ||
+                               prompt.description?.toLowerCase().includes(query)
+               );
+       });
+
+       let showSearchInput = $derived(prompts.length > 3);
+</script>
+
+<ChatFormPickerPopover
+       bind:isOpen
+       class={className}
+       srLabel="Open prompt picker"
+       {onClose}
+       onKeydown={handleKeydown}
+>
+       {#if selectedPrompt}
+               {@const prompt = selectedPrompt}
+               {@const server = serverSettingsMap.get(prompt.serverName)}
+               {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
+
+               <div class="p-4">
+                       <ChatFormPickerItemHeader
+                               {server}
+                               {serverLabel}
+                               title={prompt.title || prompt.name}
+                               description={prompt.description}
+                       >
+                               {#snippet titleExtra()}
+                                       {#if prompt.arguments?.length}
+                                               <Badge variant="secondary">
+                                                       {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
+                                               </Badge>
+                                       {/if}
+                               {/snippet}
+                       </ChatFormPickerItemHeader>
+
+                       <ChatFormPromptPickerArgumentForm
+                               prompt={selectedPrompt}
+                               {promptArgs}
+                               {suggestions}
+                               {loadingSuggestions}
+                               {activeAutocomplete}
+                               {autocompleteIndex}
+                               {promptError}
+                               onArgInput={handleArgInput}
+                               onArgKeydown={handleArgKeydown}
+                               onArgBlur={handleArgBlur}
+                               onArgFocus={handleArgFocus}
+                               onSelectSuggestion={selectSuggestion}
+                               onSubmit={handleArgumentSubmit}
+                               onCancel={handleCancelArgumentForm}
+                       />
+               </div>
+       {:else}
+               <ChatFormPickerList
+                       items={filteredPrompts}
+                       {isLoading}
+                       {selectedIndex}
+                       bind:searchQuery={internalSearchQuery}
+                       {showSearchInput}
+                       searchPlaceholder="Search prompts..."
+                       emptyMessage="No MCP prompts available"
+                       itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
+               >
+                       {#snippet item(prompt, index, isSelected)}
+                               {@const server = serverSettingsMap.get(prompt.serverName)}
+                               {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
+
+                               <ChatFormPickerListItem
+                                       dataIndex={index}
+                                       {isSelected}
+                                       onClick={() => handlePromptClick(prompt)}
+                               >
+                                       <ChatFormPickerItemHeader
+                                               {server}
+                                               {serverLabel}
+                                               title={prompt.title || prompt.name}
+                                               description={prompt.description}
+                                       >
+                                               {#snippet titleExtra()}
+                                                       {#if prompt.arguments?.length}
+                                                               <Badge variant="secondary">
+                                                                       {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
+                                                               </Badge>
+                                                       {/if}
+                                               {/snippet}
+                                       </ChatFormPickerItemHeader>
+                               </ChatFormPickerListItem>
+                       {/snippet}
+
+                       {#snippet skeleton()}
+                               <ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
+                       {/snippet}
+               </ChatFormPickerList>
+       {/if}
+</ChatFormPickerPopover>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentForm.svelte
new file mode 100644 (file)
index 0000000..92572b8
--- /dev/null
@@ -0,0 +1,74 @@
+<script lang="ts">
+       import type { MCPPromptInfo } from '$lib/types';
+       import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
+       import { Button } from '$lib/components/ui/button';
+
+       interface Props {
+               prompt: MCPPromptInfo;
+               promptArgs: Record<string, string>;
+               suggestions: Record<string, string[]>;
+               loadingSuggestions: Record<string, boolean>;
+               activeAutocomplete: string | null;
+               autocompleteIndex: number;
+               promptError: string | null;
+               onArgInput: (argName: string, value: string) => void;
+               onArgKeydown: (event: KeyboardEvent, argName: string) => void;
+               onArgBlur: (argName: string) => void;
+               onArgFocus: (argName: string) => void;
+               onSelectSuggestion: (argName: string, value: string) => void;
+               onSubmit: (event: SubmitEvent) => void;
+               onCancel: () => void;
+       }
+
+       let {
+               prompt,
+               promptArgs,
+               suggestions,
+               loadingSuggestions,
+               activeAutocomplete,
+               autocompleteIndex,
+               promptError,
+               onArgInput,
+               onArgKeydown,
+               onArgBlur,
+               onArgFocus,
+               onSelectSuggestion,
+               onSubmit,
+               onCancel
+       }: Props = $props();
+</script>
+
+<form onsubmit={onSubmit} class="space-y-3 pt-4">
+       {#each prompt.arguments ?? [] as arg (arg.name)}
+               <ChatFormPromptPickerArgumentInput
+                       argument={arg}
+                       value={promptArgs[arg.name] ?? ''}
+                       suggestions={suggestions[arg.name] ?? []}
+                       isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
+                       isAutocompleteActive={activeAutocomplete === arg.name}
+                       autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
+                       onInput={(value) => onArgInput(arg.name, value)}
+                       onKeydown={(e) => onArgKeydown(e, arg.name)}
+                       onBlur={() => onArgBlur(arg.name)}
+                       onFocus={() => onArgFocus(arg.name)}
+                       onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
+               />
+       {/each}
+
+       {#if promptError}
+               <div
+                       class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
+                       role="alert"
+               >
+                       <span class="shrink-0">⚠</span>
+
+                       <span>{promptError}</span>
+               </div>
+       {/if}
+
+       <div class="mt-8 flex justify-end gap-2">
+               <Button type="button" size="sm" onclick={onCancel} variant="secondary">Cancel</Button>
+
+               <Button size="sm" type="submit">Use Prompt</Button>
+       </div>
+</form>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentInput.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentInput.svelte
new file mode 100644 (file)
index 0000000..638d10e
--- /dev/null
@@ -0,0 +1,84 @@
+<script lang="ts">
+       import type { MCPPromptInfo } from '$lib/types';
+       import { fly } from 'svelte/transition';
+       import { Input } from '$lib/components/ui/input';
+       import { Label } from '$lib/components/ui/label';
+
+       type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
+
+       interface Props {
+               argument: PromptArgument;
+               value: string;
+               suggestions?: string[];
+               isLoadingSuggestions?: boolean;
+               isAutocompleteActive?: boolean;
+               autocompleteIndex?: number;
+               onInput: (value: string) => void;
+               onKeydown: (event: KeyboardEvent) => void;
+               onBlur: () => void;
+               onFocus: () => void;
+               onSelectSuggestion: (value: string) => void;
+       }
+
+       let {
+               argument,
+               value = '',
+               suggestions = [],
+               isLoadingSuggestions = false,
+               isAutocompleteActive = false,
+               autocompleteIndex = 0,
+               onInput,
+               onKeydown,
+               onBlur,
+               onFocus,
+               onSelectSuggestion
+       }: Props = $props();
+</script>
+
+<div class="relative grid gap-1">
+       <Label for="arg-{argument.name}" class="mb-1 text-muted-foreground">
+               <span>
+                       {argument.name}
+
+                       {#if argument.required}
+                               <span class="text-destructive">*</span>
+                       {/if}
+               </span>
+
+               {#if isLoadingSuggestions}
+                       <span class="text-xs text-muted-foreground/50">...</span>
+               {/if}
+       </Label>
+
+       <Input
+               id="arg-{argument.name}"
+               type="text"
+               {value}
+               oninput={(e) => onInput(e.currentTarget.value)}
+               onkeydown={onKeydown}
+               onblur={onBlur}
+               onfocus={onFocus}
+               placeholder={argument.description || argument.name}
+               required={argument.required}
+               autocomplete="off"
+       />
+
+       {#if isAutocompleteActive && suggestions.length > 0}
+               <div
+                       class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
+                       transition:fly={{ y: -5, duration: 100 }}
+               >
+                       {#each suggestions as suggestion, i (suggestion)}
+                               <button
+                                       type="button"
+                                       onmousedown={() => onSelectSuggestion(suggestion)}
+                                       class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
+                                               ? 'bg-accent'
+                                               : ''}"
+                               >
+                                       {suggestion}
+                               </button>
+                       {/each}
+               </div>
+       {/if}
+</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormResourcePicker/ChatFormResourcePicker.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormResourcePicker/ChatFormResourcePicker.svelte
new file mode 100644 (file)
index 0000000..5c2a945
--- /dev/null
@@ -0,0 +1,236 @@
+<script lang="ts">
+       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
+       import { KeyboardKey } from '$lib/enums';
+       import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
+       import { SvelteMap } from 'svelte/reactivity';
+       import { FolderOpen } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import {
+               ChatFormPickerPopover,
+               ChatFormPickerList,
+               ChatFormPickerListItem,
+               ChatFormPickerItemHeader,
+               ChatFormPickerListItemSkeleton
+       } from '$lib/components/app/chat';
+
+       interface Props {
+               class?: string;
+               isOpen?: boolean;
+               searchQuery?: string;
+               onClose?: () => void;
+               onResourceSelect?: (resource: MCPResourceInfo) => void;
+               onBrowse?: () => void;
+       }
+
+       let {
+               class: className = '',
+               isOpen = false,
+               searchQuery = '',
+               onClose,
+               onResourceSelect,
+               onBrowse
+       }: Props = $props();
+
+       let resources = $state<MCPResourceInfo[]>([]);
+       let isLoading = $state(false);
+       let selectedIndex = $state(0);
+       let internalSearchQuery = $state('');
+
+       let serverSettingsMap = $derived.by(() => {
+               const servers = mcpStore.getServers();
+               const map = new SvelteMap<string, MCPServerSettingsEntry>();
+
+               for (const server of servers) {
+                       map.set(server.id, server);
+               }
+
+               return map;
+       });
+
+       $effect(() => {
+               if (isOpen) {
+                       loadResources();
+                       selectedIndex = 0;
+               }
+       });
+
+       $effect(() => {
+               if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
+                       selectedIndex = 0;
+               }
+       });
+
+       async function loadResources() {
+               isLoading = true;
+
+               try {
+                       const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
+
+                       const initialized = await mcpStore.ensureInitialized(perChatOverrides);
+
+                       if (!initialized) {
+                               resources = [];
+
+                               return;
+                       }
+
+                       await mcpStore.fetchAllResources();
+                       resources = mcpResourceStore.getAllResourceInfos();
+               } catch (error) {
+                       console.error('[ChatFormResourcePicker] Failed to load resources:', error);
+                       resources = [];
+               } finally {
+                       isLoading = false;
+               }
+       }
+
+       function handleResourceClick(resource: MCPResourceInfo) {
+               mcpStore.attachResource(resource.uri);
+               onResourceSelect?.(resource);
+               onClose?.();
+       }
+
+       function isResourceAttached(uri: string): boolean {
+               return mcpResourceStore.isAttached(uri);
+       }
+
+       export function handleKeydown(event: KeyboardEvent): boolean {
+               if (!isOpen) return false;
+
+               if (event.key === KeyboardKey.ESCAPE) {
+                       event.preventDefault();
+                       onClose?.();
+
+                       return true;
+               }
+
+               if (event.key === KeyboardKey.ARROW_DOWN) {
+                       event.preventDefault();
+
+                       if (filteredResources.length > 0) {
+                               selectedIndex = (selectedIndex + 1) % filteredResources.length;
+                       }
+
+                       return true;
+               }
+
+               if (event.key === KeyboardKey.ARROW_UP) {
+                       event.preventDefault();
+                       if (filteredResources.length > 0) {
+                               selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
+                       }
+
+                       return true;
+               }
+
+               if (event.key === KeyboardKey.ENTER) {
+                       event.preventDefault();
+                       if (filteredResources[selectedIndex]) {
+                               handleResourceClick(filteredResources[selectedIndex]);
+                       }
+
+                       return true;
+               }
+
+               return false;
+       }
+
+       let filteredResources = $derived.by(() => {
+               const sortedServers = mcpStore.getServersSorted();
+               const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
+
+               const sortedResources = [...resources].sort((a, b) => {
+                       const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
+                       const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
+                       return orderA - orderB;
+               });
+
+               const query = (searchQuery || internalSearchQuery).toLowerCase();
+               if (!query) return sortedResources;
+
+               return sortedResources.filter(
+                       (resource) =>
+                               resource.name.toLowerCase().includes(query) ||
+                               resource.title?.toLowerCase().includes(query) ||
+                               resource.description?.toLowerCase().includes(query) ||
+                               resource.uri.toLowerCase().includes(query)
+               );
+       });
+
+       let showSearchInput = $derived(resources.length > 3);
+</script>
+
+<ChatFormPickerPopover
+       bind:isOpen
+       class={className}
+       srLabel="Open resource picker"
+       {onClose}
+       onKeydown={handleKeydown}
+>
+       <ChatFormPickerList
+               items={filteredResources}
+               {isLoading}
+               {selectedIndex}
+               bind:searchQuery={internalSearchQuery}
+               {showSearchInput}
+               searchPlaceholder="Search resources..."
+               emptyMessage="No MCP resources available"
+               itemKey={(resource) => resource.serverName + ':' + resource.uri}
+       >
+               {#snippet item(resource, index, isSelected)}
+                       {@const server = serverSettingsMap.get(resource.serverName)}
+                       {@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
+
+                       <ChatFormPickerListItem
+                               dataIndex={index}
+                               {isSelected}
+                               onClick={() => handleResourceClick(resource)}
+                       >
+                               <ChatFormPickerItemHeader
+                                       {server}
+                                       {serverLabel}
+                                       title={resource.title || resource.name}
+                                       description={resource.description}
+                               >
+                                       {#snippet titleExtra()}
+                                               {#if isResourceAttached(resource.uri)}
+                                                       <span
+                                                               class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
+                                                       >
+                                                               attached
+                                                       </span>
+                                               {/if}
+                                       {/snippet}
+
+                                       {#snippet subtitle()}
+                                               <p class="mt-0.5 truncate text-xs text-muted-foreground/60">
+                                                       {resource.uri}
+                                               </p>
+                                       {/snippet}
+                               </ChatFormPickerItemHeader>
+                       </ChatFormPickerListItem>
+               {/snippet}
+
+               {#snippet skeleton()}
+                       <ChatFormPickerListItemSkeleton />
+               {/snippet}
+
+               {#snippet footer()}
+                       {#if onBrowse && resources.length > 3}
+                               <Button
+                                       class="fixed right-3 bottom-3"
+                                       type="button"
+                                       onclick={onBrowse}
+                                       variant="secondary"
+                                       size="sm"
+                               >
+                                       <FolderOpen class="h-3 w-3" />
+
+                                       Browse all
+                               </Button>
+                       {/if}
+               {/snippet}
+       </ChatFormPickerList>
+</ChatFormPickerPopover>
index ba8990f01296f66e6e71cafad71434feaa509cc9..52a9355104a60dc1b34d2e3281b9a403b17ffbf4 100644 (file)
@@ -6,13 +6,15 @@
        import { conversationsStore } from '$lib/stores/conversations.svelte';
        import { DatabaseService } from '$lib/services';
        import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
-       import { MessageRole } from '$lib/enums';
+       import { MessageRole, AttachmentType } from '$lib/enums';
        import {
                ChatMessageAssistant,
                ChatMessageUser,
-               ChatMessageSystem
+               ChatMessageSystem,
+               ChatMessageMcpPrompt
        } from '$lib/components/app/chat';
        import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
+       import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
 
        interface Props {
                class?: string;
                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}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
new file mode 100644 (file)
index 0000000..5977f1c
--- /dev/null
@@ -0,0 +1,354 @@
+<script lang="ts">
+       import {
+               ChatMessageStatistics,
+               CollapsibleContentBlock,
+               MarkdownContent,
+               SyntaxHighlightedCode
+       } from '$lib/components/app';
+       import { config } from '$lib/stores/settings.svelte';
+       import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte';
+       import { AgenticSectionType, AttachmentType, FileTypeText } from '$lib/enums';
+       import { formatJsonPretty } from '$lib/utils';
+       import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
+       import { parseAgenticContent, type AgenticSection } from '$lib/utils';
+       import type { DatabaseMessage, DatabaseMessageExtraImageFile } from '$lib/types/database';
+       import type { ChatMessageAgenticTimings, ChatMessageAgenticTurnStats } from '$lib/types/chat';
+       import { ChatMessageStatsView } from '$lib/enums';
+
+       interface Props {
+               message?: DatabaseMessage;
+               content: string;
+               isStreaming?: boolean;
+               highlightTurns?: boolean;
+       }
+
+       type ToolResultLine = {
+               text: string;
+               image?: DatabaseMessageExtraImageFile;
+       };
+
+       let { content, message, isStreaming = false, highlightTurns = false }: Props = $props();
+
+       let expandedStates: Record<number, boolean> = $state({});
+
+       const sections = $derived(parseAgenticContent(content));
+       const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
+       const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
+
+       // Parse toolResults with images only when sections or message.extra change
+       const sectionsParsed = $derived(
+               sections.map((section) => ({
+                       ...section,
+                       parsedLines: section.toolResult
+                               ? parseToolResultWithImages(section.toolResult, message?.extra)
+                               : []
+               }))
+       );
+
+       // Group flat sections into agentic turns
+       // A new turn starts when a non-tool section follows a tool section
+       const turnGroups = $derived.by(() => {
+               const turns: { sections: (typeof sectionsParsed)[number][]; flatIndices: number[] }[] = [];
+               let currentTurn: (typeof sectionsParsed)[number][] = [];
+               let currentIndices: number[] = [];
+               let prevWasTool = false;
+
+               for (let i = 0; i < sectionsParsed.length; i++) {
+                       const section = sectionsParsed[i];
+                       const isTool =
+                               section.type === AgenticSectionType.TOOL_CALL ||
+                               section.type === AgenticSectionType.TOOL_CALL_PENDING ||
+                               section.type === AgenticSectionType.TOOL_CALL_STREAMING;
+
+                       if (!isTool && prevWasTool && currentTurn.length > 0) {
+                               turns.push({ sections: currentTurn, flatIndices: currentIndices });
+                               currentTurn = [];
+                               currentIndices = [];
+                       }
+
+                       currentTurn.push(section);
+                       currentIndices.push(i);
+                       prevWasTool = isTool;
+               }
+
+               if (currentTurn.length > 0) {
+                       turns.push({ sections: currentTurn, flatIndices: currentIndices });
+               }
+
+               return turns;
+       });
+
+       function getDefaultExpanded(section: AgenticSection): boolean {
+               if (
+                       section.type === AgenticSectionType.TOOL_CALL_PENDING ||
+                       section.type === AgenticSectionType.TOOL_CALL_STREAMING
+               ) {
+                       return showToolCallInProgress;
+               }
+
+               if (section.type === AgenticSectionType.REASONING_PENDING) {
+                       return showThoughtInProgress;
+               }
+
+               return false;
+       }
+
+       function isExpanded(index: number, section: AgenticSection): boolean {
+               if (expandedStates[index] !== undefined) {
+                       return expandedStates[index];
+               }
+
+               return getDefaultExpanded(section);
+       }
+
+       function toggleExpanded(index: number, section: AgenticSection) {
+               const currentState = isExpanded(index, section);
+
+               expandedStates[index] = !currentState;
+       }
+
+       function parseToolResultWithImages(
+               toolResult: string,
+               extras?: DatabaseMessage['extra']
+       ): ToolResultLine[] {
+               const lines = toolResult.split(NEWLINE_SEPARATOR);
+
+               return lines.map((line) => {
+                       const match = line.match(ATTACHMENT_SAVED_REGEX);
+                       if (!match || !extras) return { text: line };
+
+                       const attachmentName = match[1];
+                       const image = extras.find(
+                               (e): e is DatabaseMessageExtraImageFile =>
+                                       e.type === AttachmentType.IMAGE && e.name === attachmentName
+                       );
+
+                       return { text: line, image };
+               });
+       }
+
+       function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
+               return {
+                       turns: 1,
+                       toolCallsCount: stats.toolCalls.length,
+                       toolsMs: stats.toolsMs,
+                       toolCalls: stats.toolCalls,
+                       llm: stats.llm
+               };
+       }
+</script>
+
+{#snippet renderSection(section: (typeof sectionsParsed)[number], index: number)}
+       {#if section.type === AgenticSectionType.TEXT}
+               <div class="agentic-text">
+                       <MarkdownContent content={section.content} attachments={message?.extra} />
+               </div>
+       {:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING}
+               {@const streamingIcon = isStreaming ? Loader2 : AlertTriangle}
+               {@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4 text-yellow-500'}
+               {@const streamingSubtitle = isStreaming ? '' : 'incomplete'}
+
+               <CollapsibleContentBlock
+                       open={isExpanded(index, section)}
+                       class="my-2"
+                       icon={streamingIcon}
+                       iconClass={streamingIconClass}
+                       title={section.toolName || 'Tool call'}
+                       subtitle={streamingSubtitle}
+                       {isStreaming}
+                       onToggle={() => toggleExpanded(index, section)}
+               >
+                       <div class="pt-3">
+                               <div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
+                                       <span>Arguments:</span>
+
+                                       {#if isStreaming}
+                                               <Loader2 class="h-3 w-3 animate-spin" />
+                                       {/if}
+                               </div>
+                               {#if section.toolArgs}
+                                       <SyntaxHighlightedCode
+                                               code={formatJsonPretty(section.toolArgs)}
+                                               language={FileTypeText.JSON}
+                                               maxHeight="20rem"
+                                               class="text-xs"
+                                       />
+                               {:else if isStreaming}
+                                       <div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
+                                               Receiving arguments...
+                                       </div>
+                               {:else}
+                                       <div
+                                               class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
+                                       >
+                                               Response was truncated
+                                       </div>
+                               {/if}
+                       </div>
+               </CollapsibleContentBlock>
+       {:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING}
+               {@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
+               {@const toolIcon = isPending ? Loader2 : Wrench}
+               {@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
+
+               <CollapsibleContentBlock
+                       open={isExpanded(index, section)}
+                       class="my-2"
+                       icon={toolIcon}
+                       iconClass={toolIconClass}
+                       title={section.toolName || ''}
+                       subtitle={isPending ? 'executing...' : undefined}
+                       isStreaming={isPending}
+                       onToggle={() => toggleExpanded(index, section)}
+               >
+                       {#if section.toolArgs && section.toolArgs !== '{}'}
+                               <div class="pt-3">
+                                       <div class="my-3 text-xs text-muted-foreground">Arguments:</div>
+
+                                       <SyntaxHighlightedCode
+                                               code={formatJsonPretty(section.toolArgs)}
+                                               language={FileTypeText.JSON}
+                                               maxHeight="20rem"
+                                               class="text-xs"
+                                       />
+                               </div>
+                       {/if}
+
+                       <div class="pt-3">
+                               <div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
+                                       <span>Result:</span>
+
+                                       {#if isPending}
+                                               <Loader2 class="h-3 w-3 animate-spin" />
+                                       {/if}
+                               </div>
+                               {#if section.toolResult}
+                                       <div class="overflow-auto rounded-lg border border-border bg-muted p-4">
+                                               {#each section.parsedLines as line, i (i)}
+                                                       <div class="font-mono text-xs leading-relaxed whitespace-pre-wrap">{line.text}</div>
+                                                       {#if line.image}
+                                                               <img
+                                                                       src={line.image.base64Url}
+                                                                       alt={line.image.name}
+                                                                       class="mt-2 mb-2 h-auto max-w-full rounded-lg"
+                                                                       loading="lazy"
+                                                               />
+                                                       {/if}
+                                               {/each}
+                                       </div>
+                               {:else if isPending}
+                                       <div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
+                                               Waiting for result...
+                                       </div>
+                               {/if}
+                       </div>
+               </CollapsibleContentBlock>
+       {:else if section.type === AgenticSectionType.REASONING}
+               <CollapsibleContentBlock
+                       open={isExpanded(index, section)}
+                       class="my-2"
+                       icon={Brain}
+                       title="Reasoning"
+                       onToggle={() => toggleExpanded(index, section)}
+               >
+                       <div class="pt-3">
+                               <div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
+                                       {section.content}
+                               </div>
+                       </div>
+               </CollapsibleContentBlock>
+       {:else if section.type === AgenticSectionType.REASONING_PENDING}
+               {@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
+               {@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
+
+               <CollapsibleContentBlock
+                       open={isExpanded(index, section)}
+                       class="my-2"
+                       icon={Brain}
+                       title={reasoningTitle}
+                       subtitle={reasoningSubtitle}
+                       {isStreaming}
+                       onToggle={() => toggleExpanded(index, section)}
+               >
+                       <div class="pt-3">
+                               <div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
+                                       {section.content}
+                               </div>
+                       </div>
+               </CollapsibleContentBlock>
+       {/if}
+{/snippet}
+
+<div class="agentic-content">
+       {#if highlightTurns && turnGroups.length > 1}
+               {#each turnGroups as turn, turnIndex (turnIndex)}
+                       {@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
+                       <div class="agentic-turn my-2 hover:bg-muted/80 dark:hover:bg-muted/30">
+                               <span class="agentic-turn-label">Turn {turnIndex + 1}</span>
+                               {#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
+                                       {@render renderSection(section, turn.flatIndices[sIdx])}
+                               {/each}
+                               {#if turnStats}
+                                       <div class="turn-stats">
+                                               <ChatMessageStatistics
+                                                       promptTokens={turnStats.llm.prompt_n}
+                                                       promptMs={turnStats.llm.prompt_ms}
+                                                       predictedTokens={turnStats.llm.predicted_n}
+                                                       predictedMs={turnStats.llm.predicted_ms}
+                                                       agenticTimings={turnStats.toolCalls.length > 0
+                                                               ? buildTurnAgenticTimings(turnStats)
+                                                               : undefined}
+                                                       initialView={ChatMessageStatsView.GENERATION}
+                                                       hideSummary
+                                               />
+                                       </div>
+                               {/if}
+                       </div>
+               {/each}
+       {:else}
+               {#each sectionsParsed as section, index (index)}
+                       {@render renderSection(section, index)}
+               {/each}
+       {/if}
+</div>
+
+<style>
+       .agentic-content {
+               display: flex;
+               flex-direction: column;
+               gap: 0.5rem;
+               width: 100%;
+               max-width: 48rem;
+       }
+
+       .agentic-text {
+               width: 100%;
+       }
+
+       .agentic-turn {
+               position: relative;
+               border: 1.5px dashed var(--muted-foreground);
+               border-radius: 0.75rem;
+               padding: 1rem;
+               transition: background 0.1s;
+       }
+
+       .agentic-turn-label {
+               position: absolute;
+               top: -1rem;
+               left: 0.75rem;
+               padding: 0 0.375rem;
+               background: var(--background);
+               font-size: 0.7rem;
+               font-weight: 500;
+               color: var(--muted-foreground);
+               text-transform: uppercase;
+               letter-spacing: 0.05em;
+       }
+
+       .turn-stats {
+               margin-top: 0.75rem;
+               padding-top: 0.5rem;
+               border-top: 1px solid hsl(var(--muted) / 0.5);
+       }
+</style>
index 5eb16f53a884b463b9f6739f7f68773c03ce98e4..553c3fd946920e24d3bb531ba5579de4c421c9cb 100644 (file)
@@ -1,23 +1,24 @@
 <script lang="ts">
        import {
+               ChatMessageAgenticContent,
                ChatMessageActions,
                ChatMessageStatistics,
                MarkdownContent,
                ModelBadge,
                ModelsSelector
        } from '$lib/components/app';
-       import ChatMessageThinkingBlock from './ChatMessageThinkingBlock.svelte';
        import { getMessageEditContext } from '$lib/contexts';
        import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
        import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
+       import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
        import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
        import { tick } from 'svelte';
        import { fade } from 'svelte/transition';
        import { Check, X } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import { Checkbox } from '$lib/components/ui/checkbox';
-       import { INPUT_CLASSES, REASONING_TAGS } from '$lib/constants';
-       import { MessageRole, KeyboardKey } from '$lib/enums';
+       import { AGENTIC_TAGS, INPUT_CLASSES, REASONING_TAGS } from '$lib/constants';
+       import { MessageRole, KeyboardKey, ChatMessageStatsView } from '$lib/enums';
        import Label from '$lib/components/ui/label/label.svelte';
        import { config } from '$lib/stores/settings.svelte';
        import { isRouterMode } from '$lib/stores/server.svelte';
                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}
index 299bdc78fc23de15033fe59803074363a3f076c1..1985ae38e7ffc1f0ee0d0368f96749cb5791e634 100644 (file)
                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}
        />
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageMcpPrompt.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageMcpPrompt.svelte
new file mode 100644 (file)
index 0000000..72ece2b
--- /dev/null
@@ -0,0 +1,83 @@
+<script lang="ts">
+       import {
+               ChatMessageActions,
+               ChatMessageEditForm,
+               ChatMessageMcpPromptContent
+       } from '$lib/components/app';
+       import { getMessageEditContext } from '$lib/contexts';
+       import { MessageRole, McpPromptVariant } from '$lib/enums';
+       import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
+
+       interface Props {
+               class?: string;
+               message: DatabaseMessage;
+               mcpPrompt: DatabaseMessageExtraMcpPrompt;
+               siblingInfo?: ChatMessageSiblingInfo | null;
+               showDeleteDialog: boolean;
+               deletionInfo: {
+                       totalCount: number;
+                       userMessages: number;
+                       assistantMessages: number;
+                       messageTypes: string[];
+               } | null;
+               onCopy: () => void;
+               onEdit: () => void;
+               onDelete: () => void;
+               onConfirmDelete: () => void;
+               onNavigateToSibling?: (siblingId: string) => void;
+               onShowDeleteDialogChange: (show: boolean) => void;
+       }
+
+       let {
+               class: className = '',
+               message,
+               mcpPrompt,
+               siblingInfo = null,
+               showDeleteDialog,
+               deletionInfo,
+               onCopy,
+               onEdit,
+               onDelete,
+               onConfirmDelete,
+               onNavigateToSibling,
+               onShowDeleteDialogChange
+       }: Props = $props();
+
+       // Get edit context
+       const editCtx = getMessageEditContext();
+</script>
+
+<div
+       aria-label="MCP Prompt message with actions"
+       class="group flex flex-col items-end gap-3 md:gap-2 {className}"
+       role="group"
+>
+       {#if editCtx.isEditing}
+               <ChatMessageEditForm />
+       {:else}
+               <ChatMessageMcpPromptContent
+                       prompt={mcpPrompt}
+                       variant={McpPromptVariant.MESSAGE}
+                       class="w-full max-w-[80%]"
+               />
+
+               {#if message.timestamp}
+                       <div class="max-w-[80%]">
+                               <ChatMessageActions
+                                       actionsPosition="right"
+                                       {deletionInfo}
+                                       justify="end"
+                                       {onConfirmDelete}
+                                       {onCopy}
+                                       {onDelete}
+                                       {onEdit}
+                                       {onNavigateToSibling}
+                                       {onShowDeleteDialogChange}
+                                       {siblingInfo}
+                                       {showDeleteDialog}
+                                       role={MessageRole.USER}
+                               />
+                       </div>
+               {/if}
+       {/if}
+</div>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageMcpPromptContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageMcpPromptContent.svelte
new file mode 100644 (file)
index 0000000..3d5dec3
--- /dev/null
@@ -0,0 +1,197 @@
+<script lang="ts">
+       import { Card } from '$lib/components/ui/card';
+       import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { SvelteMap } from 'svelte/reactivity';
+       import { McpPromptVariant } from '$lib/enums';
+       import { TruncatedText } from '$lib/components/app/misc';
+       import * as Tooltip from '$lib/components/ui/tooltip';
+
+       interface ContentPart {
+               text: string;
+               argKey: string | null;
+       }
+
+       interface Props {
+               class?: string;
+               prompt: DatabaseMessageExtraMcpPrompt;
+               variant?: McpPromptVariant;
+               isLoading?: boolean;
+               loadError?: string;
+       }
+
+       let {
+               class: className = '',
+               prompt,
+               variant = McpPromptVariant.MESSAGE,
+               isLoading = false,
+               loadError
+       }: Props = $props();
+
+       let hoveredArgKey = $state<string | null>(null);
+       let argumentEntries = $derived(Object.entries(prompt.arguments ?? {}));
+       let hasArguments = $derived(prompt.arguments && Object.keys(prompt.arguments).length > 0);
+       let hasContent = $derived(prompt.content && prompt.content.trim().length > 0);
+
+       let contentParts = $derived.by((): ContentPart[] => {
+               if (!prompt.content || !hasArguments) {
+                       return [{ text: prompt.content || '', argKey: null }];
+               }
+
+               const parts: ContentPart[] = [];
+               let remaining = prompt.content;
+
+               const valueToKey = new SvelteMap<string, string>();
+               for (const [key, value] of argumentEntries) {
+                       if (value && value.trim()) {
+                               valueToKey.set(value, key);
+                       }
+               }
+
+               const sortedValues = [...valueToKey.keys()].sort((a, b) => b.length - a.length);
+
+               while (remaining.length > 0) {
+                       let earliestMatch: { index: number; value: string; key: string } | null = null;
+
+                       for (const value of sortedValues) {
+                               const index = remaining.indexOf(value);
+                               if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
+                                       earliestMatch = { index, value, key: valueToKey.get(value)! };
+                               }
+                       }
+
+                       if (earliestMatch) {
+                               if (earliestMatch.index > 0) {
+                                       parts.push({ text: remaining.slice(0, earliestMatch.index), argKey: null });
+                               }
+
+                               parts.push({ text: earliestMatch.value, argKey: earliestMatch.key });
+                               remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
+                       } else {
+                               parts.push({ text: remaining, argKey: null });
+
+                               break;
+                       }
+               }
+
+               return parts;
+       });
+
+       let showArgBadges = $derived(hasArguments && !isLoading && !loadError);
+       let isAttachment = $derived(variant === McpPromptVariant.ATTACHMENT);
+       let textSizeClass = $derived(isAttachment ? 'text-xs' : 'text-md');
+       let paddingClass = $derived(isAttachment ? 'px-3 py-2' : 'px-3.75 py-2.5');
+       let maxHeightStyle = $derived(
+               isAttachment ? 'max-height: 6rem;' : 'max-height: var(--max-message-height);'
+       );
+
+       const serverFavicon = $derived(mcpStore.getServerFavicon(prompt.serverName));
+       const serverDisplayName = $derived(mcpStore.getServerDisplayName(prompt.serverName));
+</script>
+
+<div class="flex flex-col gap-2 {className}">
+       <div class="flex items-center justify-between gap-2">
+               <div class="inline-flex flex-wrap items-center gap-1.25 text-xs text-muted-foreground">
+                       <Tooltip.Root>
+                               <Tooltip.Trigger>
+                                       {#if serverFavicon}
+                                               <img
+                                                       src={serverFavicon}
+                                                       alt=""
+                                                       class="h-3.5 w-3.5 shrink-0 rounded-sm"
+                                                       onerror={(e) => {
+                                                               (e.currentTarget as HTMLImageElement).style.display = 'none';
+                                                       }}
+                                               />
+                                       {/if}
+                               </Tooltip.Trigger>
+
+                               <Tooltip.Content>
+                                       <span>{serverDisplayName}</span>
+                               </Tooltip.Content>
+                       </Tooltip.Root>
+
+                       <TruncatedText text={prompt.name} />
+               </div>
+
+               {#if showArgBadges}
+                       <div class="flex flex-wrap justify-end gap-1">
+                               {#each argumentEntries as [key, value] (key)}
+                                       <Tooltip.Root>
+                                               <Tooltip.Trigger>
+                                                       <!-- svelte-ignore a11y_no_static_element_interactions -->
+                                                       <span
+                                                               class="rounded-sm bg-purple-200/60 px-1.5 py-0.5 text-[10px] leading-none text-purple-700 transition-opacity dark:bg-purple-800/40 dark:text-purple-300 {hoveredArgKey &&
+                                                               hoveredArgKey !== key
+                                                                       ? 'opacity-30'
+                                                                       : ''}"
+                                                               onmouseenter={() => (hoveredArgKey = key)}
+                                                               onmouseleave={() => (hoveredArgKey = null)}
+                                                       >
+                                                               {key}
+                                                       </span>
+                                               </Tooltip.Trigger>
+
+                                               <Tooltip.Content>
+                                                       <span class="max-w-xs break-all">{value}</span>
+                                               </Tooltip.Content>
+                                       </Tooltip.Root>
+                               {/each}
+                       </div>
+               {/if}
+       </div>
+
+       {#if loadError}
+               <Card
+                       class="relative overflow-hidden rounded-[1.125rem] border border-destructive/50 bg-destructive/10 backdrop-blur-md"
+               >
+                       <div
+                               class="overflow-y-auto {paddingClass}"
+                               style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
+                       >
+                               <span class="{textSizeClass} text-destructive">{loadError}</span>
+                       </div>
+               </Card>
+       {:else if isLoading}
+               <Card
+                       class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 px-1 py-2 backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
+               >
+                       <div
+                               class="overflow-y-auto {paddingClass}"
+                               style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
+                       >
+                               <div class="space-y-2">
+                                       <div class="h-3 w-3/4 animate-pulse rounded bg-foreground/20"></div>
+
+                                       <div class="h-3 w-full animate-pulse rounded bg-foreground/20"></div>
+
+                                       <div class="h-3 w-5/6 animate-pulse rounded bg-foreground/20"></div>
+                               </div>
+                       </div>
+               </Card>
+       {:else if hasContent}
+               <Card
+                       class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 py-0 text-foreground backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
+               >
+                       <div
+                               class="overflow-y-auto {paddingClass}"
+                               style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
+                       >
+                               <span class="{textSizeClass} whitespace-pre-wrap">
+                                       <!-- This formatting is needed to keep the text in proper shape -->
+                                       <!-- svelte-ignore a11y_no_static_element_interactions -->
+                                       {#each contentParts as part, i (i)}{#if part.argKey}<span
+                                                               class="rounded-sm bg-purple-300/50 px-0.5 text-purple-900 transition-opacity dark:bg-purple-700/50 dark:text-purple-100 {hoveredArgKey &&
+                                                               hoveredArgKey !== part.argKey
+                                                                       ? 'opacity-30'
+                                                                       : ''}"
+                                                               onmouseenter={() => (hoveredArgKey = part.argKey)}
+                                                               onmouseleave={() => (hoveredArgKey = null)}>{part.text}</span
+                                                       >{:else}<span class="transition-opacity {hoveredArgKey ? 'opacity-30' : ''}"
+                                                               >{part.text}</span
+                                                       >{/if}{/each}</span
+                               >
+                       </div>
+               </Card>
+       {/if}
+</div>
index d74ecd782a878cef6b9a1730fbc8380468f59cef..945dec9c5e9488d2fe1c37df89330301297d19f9 100644 (file)
@@ -1,8 +1,9 @@
 <script lang="ts">
-       import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
+       import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
        import { BadgeChatStatistic } from '$lib/components/app';
        import * as Tooltip from '$lib/components/ui/tooltip';
        import { ChatMessageStatsView } from '$lib/enums';
+       import type { ChatMessageAgenticTimings } from '$lib/types/chat';
        import { formatPerformanceTime } from '$lib/utils';
        import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
 
@@ -14,7 +15,9 @@
                isLive?: boolean;
                isProcessingPrompt?: boolean;
                initialView?: ChatMessageStatsView;
+               agenticTimings?: ChatMessageAgenticTimings;
                onActiveViewChange?: (view: ChatMessageStatsView) => void;
+               hideSummary?: boolean;
        }
 
        let {
@@ -25,7 +28,9 @@
                isLive = false,
                isProcessingPrompt = false,
                initialView = ChatMessageStatsView.GENERATION,
-               onActiveViewChange
+               agenticTimings,
+               onActiveViewChange,
+               hideSummary = false
        }: Props = $props();
 
        let activeView: ChatMessageStatsView = $derived(initialView);
 
        // 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"
index 9dadf3231b60e762ecfb7883f719f61bb6c70962..a27616f9bed4f99725815f8aec5cb8482d924730 100644 (file)
                                        <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>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte
deleted file mode 100644 (file)
index 9245ad5..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-<script lang="ts">
-       import { Brain } from '@lucide/svelte';
-       import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
-       import * as Collapsible from '$lib/components/ui/collapsible/index.js';
-       import { buttonVariants } from '$lib/components/ui/button/index.js';
-       import { Card } from '$lib/components/ui/card';
-       import { config } from '$lib/stores/settings.svelte';
-
-       interface Props {
-               class?: string;
-               hasRegularContent?: boolean;
-               isStreaming?: boolean;
-               reasoningContent: string | null;
-       }
-
-       let {
-               class: className = '',
-               hasRegularContent = false,
-               isStreaming = false,
-               reasoningContent
-       }: Props = $props();
-
-       const currentConfig = config();
-
-       let isExpanded = $state(currentConfig.showThoughtInProgress);
-
-       $effect(() => {
-               if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) {
-                       isExpanded = false;
-               }
-       });
-</script>
-
-<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
-       <Card class="gap-0 border-muted bg-muted/30 py-0">
-               <Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
-                       <div class="flex items-center gap-2 text-muted-foreground">
-                               <Brain class="h-4 w-4" />
-
-                               <span class="text-sm font-medium">
-                                       {isStreaming ? 'Reasoning...' : 'Reasoning'}
-                               </span>
-                       </div>
-
-                       <div
-                               class={buttonVariants({
-                                       variant: 'ghost',
-                                       size: 'sm',
-                                       class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
-                               })}
-                       >
-                               <ChevronsUpDownIcon class="h-4 w-4" />
-
-                               <span class="sr-only">Toggle reasoning content</span>
-                       </div>
-               </Collapsible.Trigger>
-
-               <Collapsible.Content>
-                       <div class="border-t border-muted px-3 pb-3">
-                               <div class="pt-3">
-                                       <div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
-                                               {reasoningContent ?? ''}
-                                       </div>
-                               </div>
-                       </div>
-               </Collapsible.Content>
-       </Card>
-</Collapsible.Root>
index 05a02e27281069357378053eb87ea611a773fdbd..9b9d9a3e993bba3ddbfcc9b2b9db208cf2780288 100644 (file)
@@ -82,7 +82,7 @@
        {:else}
                {#if message.extra && message.extra.length > 0}
                        <div class="mb-2 max-w-[80%]">
-                               <ChatAttachmentsList attachments={message.extra} readonly={true} imageHeight="h-80" />
+                               <ChatAttachmentsList attachments={message.extra} readonly imageHeight="h-80" />
                        </div>
                {/if}
 
index 4efe2b212b4973dfc673c9e56c3f22107ea4025a..1e0c801b6d7a8da600c501cfa3cf8b77a13d4293 100644 (file)
        >
                <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>
index 4d22c83993b5d48ad25df5c27632e0506fd345c8..086044e8c45150a07b879c46f7c081ed8bac980c 100644 (file)
                class={className}
                {disabled}
                {isLoading}
+               showMcpPromptButton
                onFilesAdd={handleFilesAdd}
                {onStop}
                onSubmit={handleSubmit}
index 4909d6045739c358d4b8f6a82cb2b5273971d9f4..2730d5e738d7fe118dcb1e90ef0c29b6c9070954 100644 (file)
 </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"
                >
index ce2925b020e187290e0241266948c867a25713dd..44d59e2b3602fee2a4e1eafb7fddf067c0aec650 100644 (file)
@@ -12,7 +12,9 @@
        import {
                ChatSettingsFooter,
                ChatSettingsImportExportTab,
-               ChatSettingsFields
+               ChatSettingsFields,
+               McpLogo,
+               McpServersSettings
        } from '$lib/components/app';
        import { ScrollArea } from '$lib/components/ui/scroll-area';
        import { config, settingsStore } from '$lib/stores/settings.svelte';
                        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
index 8c0622ffdabf1a001dd287be4627855bd4ee9468..2eee7e2dfc0b4f584d937d758ae52516b97e19cd 100644 (file)
@@ -29,6 +29,7 @@
  * - Horizontal scroll with smooth navigation arrows
  * - Image thumbnails with lazy loading and error fallback
  * - File type icons for non-image files (PDF, text, audio, etc.)
+ * - MCP prompt attachments with expandable content preview
  * - Click-to-preview with full-size dialog and download option
  * - "View All" button when `limitToSingleRow` is enabled and content overflows
  * - Vision modality validation to warn about unsupported image uploads
  */
 export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachmentsList.svelte';
 
+/**
+ * Displays MCP Prompt attachment with expandable content preview.
+ * Shows server name, prompt name, and allows expanding to view full prompt arguments
+ * and content. Used when user selects a prompt from ChatFormPromptPicker.
+ */
+export { default as ChatAttachmentMcpPrompt } from './ChatAttachments/ChatAttachmentMcpPrompt.svelte';
+
+/**
+ * Displays a single MCP Resource attachment with icon, name, and server info.
+ * Shows loading/error states and supports remove action.
+ * Used within ChatAttachmentMcpResources for individual resource display.
+ */
+export { default as ChatAttachmentMcpResource } from './ChatAttachments/ChatAttachmentMcpResource.svelte';
+
+/**
+ * Full-size attachment preview component for dialog display. Handles different file types:
+ * images (full-size display), text files (syntax highlighted), PDFs (text extraction or image preview),
+ * audio (placeholder with download), and generic files (download option).
+ */
+export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachmentPreview.svelte';
+
+/**
+ * Displays MCP Resource attachments as a horizontal carousel.
+ * Shows resource name, URI, and allows clicking to view resource content.
+ */
+export { default as ChatAttachmentMcpResources } from './ChatAttachments/ChatAttachmentMcpResources.svelte';
+
 /**
  * Thumbnail for non-image file attachments. Displays file type icon based on extension,
  * file name (truncated), and file size.
@@ -71,20 +99,15 @@ export { default as ChatAttachmentThumbnailImage } from './ChatAttachments/ChatA
  */
 export { default as ChatAttachmentsViewAll } from './ChatAttachments/ChatAttachmentsViewAll.svelte';
 
-/**
- * Full-size preview dialog for attachments. Opens when clicking on any attachment
- * thumbnail. Shows the attachment in full size with options to download or close.
- * Handles both image and non-image attachments with appropriate rendering.
- */
-export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachmentPreview.svelte';
 /**
  *
  * FORM
  *
  * Components for the chat input area. The form handles user input, file attachments,
- * audio recording. It integrates with multiple stores:
+ * audio recording, and MCP prompts & resources selection. It integrates with multiple stores:
  * - `chatStore` for message submission and generation control
  * - `modelsStore` for model selection and validation
+ * - `mcpStore` for MCP prompt browsing and loading
  *
  * The form exposes a public API for programmatic control from parent components
  * (focus, height reset, model selector, validation).
@@ -95,7 +118,7 @@ export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachme
  * **ChatForm** - Main chat input component with rich features
  *
  * The primary input interface for composing and sending chat messages.
- * Orchestrates text input, file attachments, audio recording.
+ * Orchestrates text input, file attachments, audio recording, and MCP prompts.
  * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
  *
  * **Architecture:**
@@ -108,11 +131,14 @@ export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachme
  * - IME-safe Enter key handling (waits for composition end)
  * - Shift+Enter for newline, Enter for submit
  * - Paste handler for files and long text (> {pasteLongTextToFileLen} chars → file conversion)
+ * - Keyboard shortcut `/` triggers MCP prompt picker
  *
  * **Features:**
  * - Auto-resizing textarea with placeholder
  * - File upload via button dropdown (images/text/PDF), drag-drop, or paste
  * - Audio recording with WAV conversion (when model supports audio)
+ * - MCP prompt picker with search and argument forms
+ * - MCP reource picker with component to list attached resources at the bottom of Chat Form
  * - Model selector integration (router mode)
  * - Loading state with stop button, disabled state for errors
  *
@@ -144,6 +170,13 @@ export { default as ChatForm } from './ChatForm/ChatForm.svelte';
  */
 export { default as ChatFormActionAttachmentsDropdown } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
 
+/**
+ * Mobile sheet variant of the file attachment selector. Renders a bottom sheet
+ * with the same options as ChatFormActionAttachmentsDropdown, optimized for
+ * touch interaction on mobile devices.
+ */
+export { default as ChatFormActionAttachmentsSheet } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsSheet.svelte';
+
 /**
  * Audio recording button with real-time recording indicator. Records audio
  * and converts to WAV format for upload. Only visible when the active model
@@ -182,6 +215,118 @@ export { default as ChatFormHelperText } from './ChatForm/ChatFormHelperText.sve
  */
 export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
 
+/**
+ * **ChatFormPromptPicker** - MCP prompt selection interface
+ *
+ * Floating picker for browsing and selecting MCP Server Prompts.
+ * Triggered by typing `/` in the chat input or choosing `MCP Prompt` option in ChatFormActionAttachmentsDropdown.
+ * Loads prompts from connected MCP servers and allows users to select and configure them.
+ *
+ * **Architecture:**
+ * - Fetches available prompts from mcpStore
+ * - Manages selection state and keyboard navigation internally
+ * - Delegates argument input to ChatFormPromptPickerArgumentForm
+ * - Communicates prompt loading lifecycle via callbacks
+ *
+ * **Prompt Loading Flow:**
+ * 1. User selects prompt → `onPromptLoadStart` called with placeholder ID
+ * 2. Prompt content fetched from MCP server asynchronously
+ * 3. On success → `onPromptLoadComplete` with full prompt data
+ * 4. On failure → `onPromptLoadError` with error details
+ *
+ * **Features:**
+ * - Search/filter prompts by name across all connected servers
+ * - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
+ * - Argument input forms for prompts with required parameters
+ * - Autocomplete suggestions for argument values
+ * - Loading states with skeleton placeholders
+ * - Server information header per prompt for visual identification
+ *
+ * **Exported API:**
+ * - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
+ *
+ * @example
+ * ```svelte
+ * <ChatFormPromptPicker
+ *   bind:this={pickerRef}
+ *   isOpen={showPicker}
+ *   searchQuery={promptQuery}
+ *   onClose={() => showPicker = false}
+ *   onPromptLoadStart={(id, info) => addPlaceholder(id, info)}
+ *   onPromptLoadComplete={(id, result) => replacePlaceholder(id, result)}
+ *   onPromptLoadError={(id, error) => handleError(id, error)}
+ * />
+ * ```
+ */
+export { default as ChatFormPromptPicker } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte';
+
+/**
+ * Form for entering MCP prompt arguments. Displays input fields for each
+ * required argument defined by the prompt. Validates input and submits
+ * when all required fields are filled. Shows argument descriptions as hints.
+ */
+export { default as ChatFormPromptPickerArgumentForm } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentForm.svelte';
+
+/**
+ * Single argument input field with autocomplete suggestions. Fetches suggestions
+ * from MCP server based on argument type. Supports keyboard navigation through
+ * suggestions list. Used within ChatFormPromptPickerArgumentForm.
+ */
+export { default as ChatFormPromptPickerArgumentInput } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentInput.svelte';
+
+/**
+ * Shared popover wrapper for inline picker popovers (prompts, resources).
+ * Provides consistent positioning, styling, and open/close behavior.
+ */
+export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickerPopover.svelte';
+
+/**
+ * Generic scrollable list for picker popovers. Provides search input,
+ * scroll-into-view for keyboard navigation, loading skeletons, empty state,
+ * and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
+ * Shared by ChatFormPromptPicker and ChatFormResourcePicker.
+ */
+export { default as ChatFormPickerList } from './ChatForm/ChatFormPicker/ChatFormPickerList.svelte';
+
+/**
+ * Generic button wrapper for picker list items. Provides consistent styling,
+ * hover/selected states, and data-picker-index attribute for scroll-into-view.
+ * Shared by ChatFormPromptPicker and ChatFormResourcePicker.
+ */
+export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPicker/ChatFormPickerListItem.svelte';
+
+/**
+ * Generic header for picker items displaying server favicon, label, item title,
+ * and optional description. Accepts `titleExtra` and `subtitle` snippets for
+ * custom content like badges or URIs. Shared by both pickers.
+ */
+export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPicker/ChatFormPickerItemHeader.svelte';
+
+/**
+ * Generic skeleton loading placeholder for picker list items. Configurable
+ * title width and optional badge skeleton. Shared by both pickers.
+ */
+export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
+
+/**
+ * **ChatFormResourcePicker** - MCP resource selection interface
+ *
+ * Floating picker for browsing and attaching MCP Server Resources.
+ * Triggered by typing `@` in the chat input.
+ * Loads resources from connected MCP servers and allows users to attach them to the chat context.
+ *
+ * **Features:**
+ * - Search/filter resources by name, title, description, or URI across all connected servers
+ * - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
+ * - Shows attached state for already-attached resources
+ * - Loading states with skeleton placeholders
+ * - Server information header per resource for visual identification
+ *
+ * **Exported API:**
+ * - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
+ */
+export { default as ChatFormResourcePicker } from './ChatForm/ChatFormResourcePicker/ChatFormResourcePicker.svelte';
+
 /**
  *
  * MESSAGES
@@ -248,6 +393,7 @@ export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
  *
  * **User Messages:**
  * - Shows attachments via ChatAttachmentsList
+ * - Displays MCP prompts if present
  * - Edit creates new branch or preserves responses
  *
  * **Assistant Messages:**
@@ -276,6 +422,46 @@ export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
  */
 export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
 
+/**
+ * **ChatMessageAgenticContent** - Agentic workflow output display
+ *
+ * Specialized renderer for assistant messages containing agentic workflow markers.
+ * Parses structured content and displays tool calls and reasoning blocks as
+ * interactive collapsible sections with real-time streaming support.
+ *
+ * **Architecture:**
+ * - Uses `parseAgenticContent()` from `$lib/utils` to parse markers
+ * - Renders sections as CollapsibleContentBlock components
+ * - Handles streaming state for progressive content display
+ * - Falls back to MarkdownContent for plain text sections
+ *
+ * **Marker Format:**
+ * - Tool calls: in constants/agentic.ts (AGENTIC_TAGS)
+ * - Reasoning: in constants/agentic.ts (REASONING_TAGS)
+ * - Partial markers handled gracefully during streaming
+ *
+ * **Execution States:**
+ * - **Streaming**: Animated spinner, block expanded, auto-scroll enabled
+ * - **Pending**: Waiting indicator for queued tool calls
+ * - **Completed**: Static display, block collapsed by default
+ *
+ * **Features:**
+ * - JSON arguments syntax highlighting via SyntaxHighlightedCode
+ * - Tool results display with formatting
+ * - Plain text sections between markers rendered as markdown
+ * - Smart collapse defaults (expanded while streaming, collapsed when done)
+ *
+ * @example
+ * ```svelte
+ * <ChatMessageAgenticContent
+ *   content={message.content}
+ *   {message}
+ *   isStreaming={isGenerating}
+ * />
+ * ```
+ */
+export { default as ChatMessageAgenticContent } from './ChatMessages/ChatMessageAgenticContent.svelte';
+
 /**
  * Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate
  * buttons based on message role. Includes branching controls when message has siblings.
@@ -299,6 +485,20 @@ export { default as ChatMessageBranchingControls } from './ChatMessages/ChatMess
  */
 export { default as ChatMessageStatistics } from './ChatMessages/ChatMessageStatistics.svelte';
 
+/**
+ * MCP prompt display in user messages. Shows when user selected an MCP prompt
+ * via ChatFormPromptPicker. Displays server name, prompt name, and expandable
+ * content preview. Stored in message.extra as DatabaseMessageExtraMcpPrompt.
+ */
+export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessageMcpPrompt.svelte';
+
+/**
+ * Formatted content display for MCP prompt messages. Renders the full prompt
+ * content with arguments in a readable format. Used within ChatMessageMcpPrompt
+ * for the expanded view.
+ */
+export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessageMcpPromptContent.svelte';
+
 /**
  * System message display component. Renders system messages with distinct styling.
  * Visibility controlled by `showSystemMessage` config setting.
@@ -307,7 +507,7 @@ export { default as ChatMessageSystem } from './ChatMessages/ChatMessageSystem.s
 
 /**
  * User message display component. Renders user messages with right-aligned bubble styling.
- * Shows message content, attachments via ChatAttachmentsList.
+ * Shows message content, attachments via ChatAttachmentsList, and MCP prompts if present.
  * Supports inline editing mode with ChatMessageEditForm integration.
  */
 export { default as ChatMessageUser } from './ChatMessages/ChatMessageUser.svelte';
@@ -385,7 +585,7 @@ export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditFo
  * @example
  * ```svelte
  * <!-- In chat route -->
- * <ChatScreen showCenteredEmpty={true} />
+ * <ChatScreen showCenteredEmpty />
  *
  * <!-- In conversation route -->
  * <ChatScreen showCenteredEmpty={false} />
@@ -460,6 +660,7 @@ export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProc
  * - **Sampling**: Temperature, top_p, top_k, min_p, repeat_penalty, etc.
  * - **Penalties**: Frequency penalty, presence penalty, repeat last N
  * - **Import/Export**: Conversation backup and restore
+ * - **MCP**: MCP server management (opens DialogChatSettings with MCP tab)
  * - **Developer**: Debug options, disable auto-scroll
  *
  * **Parameter Sync:**
index e4155afc5f46dda0489eeb4888b50dc1fd9a1656..0b10e140080b2d79cc0be9b3694611a22b00f471 100644 (file)
                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;
index 625fdc7b1b615b3b5fbe7d7d9d39d7169daeb0c8..41d59324cb4723ccac247334279e0b492ad83594 100644 (file)
@@ -5,6 +5,7 @@
 
        import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
        import githubLightCss from 'highlight.js/styles/github.css?inline';
+       import { ColorMode } from '$lib/enums';
 
        interface Props {
                code: string;
@@ -39,7 +40,7 @@
 
        $effect(() => {
                const currentMode = mode.current;
-               const isDark = currentMode === 'dark';
+               const isDark = currentMode === ColorMode.DARK;
 
                loadHighlightTheme(isDark);
        });
index bca1c9f4c2961e7fa29402c7abe4640f27ce744f..8ab488bc71ea25cc7aaeda319316dc588959e63f 100644 (file)
@@ -70,7 +70,7 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte
  *   bind:open
  *   icon={BrainIcon}
  *   title="Thinking..."
- *   isStreaming={true}
+ *   isStreaming
  * >
  *   {reasoningContent}
  * </CollapsibleContentBlock>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte
new file mode 100644 (file)
index 0000000..ad6c313
--- /dev/null
@@ -0,0 +1,119 @@
+<script lang="ts">
+       import * as Dialog from '$lib/components/ui/dialog';
+       import { Download } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { SyntaxHighlightedCode, ActionIconCopyToClipboard } from '$lib/components/app';
+       import {
+               getLanguageFromFilename,
+               isCodeResource,
+               isImageResource,
+               downloadResourceContent
+       } from '$lib/utils';
+       import { MimeTypeIncludes, MimeTypeText } from '$lib/enums';
+       import { DEFAULT_RESOURCE_FILENAME } from '$lib/constants';
+       import type { DatabaseMessageExtraMcpResource } from '$lib/types';
+
+       interface Props {
+               open: boolean;
+               onOpenChange?: (open: boolean) => void;
+               extra: DatabaseMessageExtraMcpResource;
+       }
+
+       let { open = $bindable(), onOpenChange, extra }: Props = $props();
+
+       const serverName = $derived(mcpStore.getServerDisplayName(extra.serverName));
+       const favicon = $derived(mcpStore.getServerFavicon(extra.serverName));
+
+       function getLanguage(): string {
+               if (extra.mimeType?.includes(MimeTypeIncludes.JSON)) return MimeTypeIncludes.JSON;
+               if (extra.mimeType?.includes(MimeTypeIncludes.JAVASCRIPT)) return MimeTypeIncludes.JAVASCRIPT;
+               if (extra.mimeType?.includes(MimeTypeIncludes.TYPESCRIPT)) return MimeTypeIncludes.TYPESCRIPT;
+
+               const name = extra.name || extra.uri || '';
+               return getLanguageFromFilename(name) || 'plaintext';
+       }
+
+       function handleDownload() {
+               if (!extra.content) return;
+               downloadResourceContent(
+                       extra.content,
+                       extra.mimeType || MimeTypeText.PLAIN,
+                       extra.name || DEFAULT_RESOURCE_FILENAME
+               );
+       }
+</script>
+
+<Dialog.Root bind:open {onOpenChange}>
+       <Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
+               <Dialog.Header>
+                       <Dialog.Title class="pr-8">{extra.name}</Dialog.Title>
+                       <Dialog.Description>
+                               <div class="flex items-center gap-2">
+                                       <span class="text-xs text-muted-foreground">{extra.uri}</span>
+
+                                       {#if serverName}
+                                               <span class="flex items-center gap-1 text-xs text-muted-foreground">
+                                                       ·
+                                                       {#if favicon}
+                                                               <img
+                                                                       src={favicon}
+                                                                       alt=""
+                                                                       class="h-3 w-3 shrink-0 rounded-sm"
+                                                                       onerror={(e) => {
+                                                                               (e.currentTarget as HTMLImageElement).style.display = 'none';
+                                                                       }}
+                                                               />
+                                                       {/if}
+                                                       {serverName}
+                                               </span>
+                                       {/if}
+
+                                       {#if extra.mimeType}
+                                               <span class="rounded bg-muted px-1.5 py-0.5 text-xs">{extra.mimeType}</span>
+                                       {/if}
+                               </div>
+                       </Dialog.Description>
+               </Dialog.Header>
+
+               <div class="flex items-center justify-end gap-1">
+                       <ActionIconCopyToClipboard
+                               text={extra.content}
+                               canCopy={!!extra.content}
+                               ariaLabel="Copy content"
+                       />
+
+                       <Button
+                               variant="ghost"
+                               size="sm"
+                               class="h-7 w-7 p-0"
+                               onclick={handleDownload}
+                               disabled={!extra.content}
+                               title="Download content"
+                       >
+                               <Download class="h-3.5 w-3.5" />
+                       </Button>
+               </div>
+
+               <div class="overflow-auto">
+                       {#if isImageResource(extra.mimeType, extra.uri) && extra.content}
+                               <div class="flex items-center justify-center">
+                                       <img
+                                               src={extra.content.startsWith('data:')
+                                                       ? extra.content
+                                                       : `data:${extra.mimeType || 'image/png'};base64,${extra.content}`}
+                                               alt={extra.name}
+                                               class="max-h-[70vh] max-w-full rounded object-contain"
+                                       />
+                               </div>
+                       {:else if isCodeResource(extra.mimeType, extra.uri) && extra.content}
+                               <SyntaxHighlightedCode code={extra.content} language={getLanguage()} maxHeight="70vh" />
+                       {:else if extra.content}
+                               <pre
+                                       class="max-h-[70vh] overflow-auto rounded-md border bg-muted/30 p-4 font-mono text-sm break-words whitespace-pre-wrap">{extra.content}</pre>
+                       {:else}
+                               <div class="py-8 text-center text-sm text-muted-foreground">No content available</div>
+                       {/if}
+               </div>
+       </Dialog.Content>
+</Dialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogMcpResources.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogMcpResources.svelte
new file mode 100644 (file)
index 0000000..db93bb4
--- /dev/null
@@ -0,0 +1,394 @@
+<script lang="ts">
+       import { FolderOpen, Plus, Loader2, Braces } from '@lucide/svelte';
+       import { toast } from 'svelte-sonner';
+       import * as Dialog from '$lib/components/ui/dialog';
+       import { Button } from '$lib/components/ui/button';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import {
+               mcpResources,
+               mcpTotalResourceCount,
+               mcpResourceStore
+       } from '$lib/stores/mcp-resources.svelte';
+       import {
+               McpResourceBrowser,
+               McpResourcePreview,
+               McpResourceTemplateForm
+       } from '$lib/components/app';
+       import { getResourceDisplayName } from '$lib/utils';
+       import type { MCPResourceInfo, MCPResourceContent, MCPResourceTemplateInfo } from '$lib/types';
+       import { SvelteSet } from 'svelte/reactivity';
+
+       interface Props {
+               open?: boolean;
+               onOpenChange?: (open: boolean) => void;
+               onAttach?: (resource: MCPResourceInfo) => void;
+               preSelectedUri?: string;
+       }
+
+       let { open = $bindable(false), onOpenChange, onAttach, preSelectedUri }: Props = $props();
+
+       let selectedResources = new SvelteSet<string>();
+       let lastSelectedUri = $state<string | null>(null);
+       let isAttaching = $state(false);
+
+       let selectedTemplate = $state<MCPResourceTemplateInfo | null>(null);
+       let templatePreviewUri = $state<string | null>(null);
+       let templatePreviewContent = $state<MCPResourceContent[] | null>(null);
+       let templatePreviewLoading = $state(false);
+       let templatePreviewError = $state<string | null>(null);
+
+       const totalCount = $derived(mcpTotalResourceCount());
+
+       $effect(() => {
+               if (open) {
+                       loadResources();
+
+                       if (preSelectedUri) {
+                               selectedResources.clear();
+                               selectedResources.add(preSelectedUri);
+                               lastSelectedUri = preSelectedUri;
+                       }
+               }
+       });
+
+       async function loadResources() {
+               const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
+               const initialized = await mcpStore.ensureInitialized(perChatOverrides);
+
+               if (initialized) {
+                       await mcpStore.fetchAllResources();
+               }
+       }
+
+       function handleOpenChange(newOpen: boolean) {
+               open = newOpen;
+               onOpenChange?.(newOpen);
+
+               if (!newOpen) {
+                       selectedResources.clear();
+                       lastSelectedUri = null;
+                       clearTemplateState();
+               }
+       }
+
+       function clearTemplateState() {
+               selectedTemplate = null;
+               templatePreviewUri = null;
+               templatePreviewContent = null;
+               templatePreviewLoading = false;
+               templatePreviewError = null;
+       }
+
+       function handleTemplateSelect(template: MCPResourceTemplateInfo) {
+               selectedResources.clear();
+               lastSelectedUri = null;
+
+               if (
+                       selectedTemplate?.uriTemplate === template.uriTemplate &&
+                       selectedTemplate?.serverName === template.serverName
+               ) {
+                       clearTemplateState();
+
+                       return;
+               }
+
+               selectedTemplate = template;
+               templatePreviewUri = null;
+               templatePreviewContent = null;
+               templatePreviewLoading = false;
+               templatePreviewError = null;
+       }
+
+       async function handleTemplateResolve(uri: string, serverName: string) {
+               templatePreviewUri = uri;
+               templatePreviewContent = null;
+               templatePreviewLoading = true;
+               templatePreviewError = null;
+
+               try {
+                       const content = await mcpStore.readResourceByUri(serverName, uri);
+
+                       if (content) {
+                               templatePreviewContent = content;
+                       } else {
+                               templatePreviewError = 'Failed to read resource';
+                       }
+               } catch (error) {
+                       templatePreviewError = error instanceof Error ? error.message : 'Unknown error';
+               } finally {
+                       templatePreviewLoading = false;
+               }
+       }
+
+       function handleTemplateCancelForm() {
+               clearTemplateState();
+       }
+
+       async function handleAttachTemplateResource() {
+               if (!templatePreviewUri || !selectedTemplate || !templatePreviewContent) return;
+
+               isAttaching = true;
+
+               try {
+                       const knownResource = mcpResourceStore.findResourceByUri(templatePreviewUri);
+
+                       if (knownResource) {
+                               if (!mcpResourceStore.isAttached(knownResource.uri)) {
+                                       await mcpStore.attachResource(knownResource.uri);
+                               }
+
+                               toast.success(`Resource attached: ${knownResource.title || knownResource.name}`);
+                       } else {
+                               if (mcpResourceStore.isAttached(templatePreviewUri)) {
+                                       toast.info('Resource already attached');
+                                       handleOpenChange(false);
+                                       return;
+                               }
+
+                               const resourceInfo: MCPResourceInfo = {
+                                       uri: templatePreviewUri,
+                                       name: templatePreviewUri.split('/').pop() || templatePreviewUri,
+                                       serverName: selectedTemplate.serverName
+                               };
+
+                               const attachment = mcpResourceStore.addAttachment(resourceInfo);
+                               mcpResourceStore.updateAttachmentContent(attachment.id, templatePreviewContent);
+
+                               toast.success(`Resource attached: ${resourceInfo.name}`);
+                       }
+
+                       handleOpenChange(false);
+               } catch (error) {
+                       console.error('Failed to attach template resource:', error);
+               } finally {
+                       isAttaching = false;
+               }
+       }
+
+       function handleResourceSelect(resource: MCPResourceInfo, shiftKey: boolean = false) {
+               clearTemplateState();
+
+               if (shiftKey && lastSelectedUri) {
+                       const allResources = getAllResourcesFlatInTreeOrder();
+                       const lastIndex = allResources.findIndex((r) => r.uri === lastSelectedUri);
+                       const currentIndex = allResources.findIndex((r) => r.uri === resource.uri);
+
+                       if (lastIndex !== -1 && currentIndex !== -1) {
+                               const start = Math.min(lastIndex, currentIndex);
+                               const end = Math.max(lastIndex, currentIndex);
+
+                               for (let i = start; i <= end; i++) {
+                                       selectedResources.add(allResources[i].uri);
+                               }
+                       }
+               } else {
+                       selectedResources.clear();
+                       selectedResources.add(resource.uri);
+                       lastSelectedUri = resource.uri;
+               }
+       }
+
+       function handleResourceToggle(resource: MCPResourceInfo, checked: boolean) {
+               clearTemplateState();
+
+               if (checked) {
+                       selectedResources.add(resource.uri);
+               } else {
+                       selectedResources.delete(resource.uri);
+               }
+
+               lastSelectedUri = resource.uri;
+       }
+
+       function getAllResourcesFlatInTreeOrder(): MCPResourceInfo[] {
+               const allResources: MCPResourceInfo[] = [];
+               const resourcesMap = mcpResources();
+
+               for (const [serverName, serverRes] of resourcesMap.entries()) {
+                       for (const resource of serverRes.resources) {
+                               allResources.push({ ...resource, serverName });
+                       }
+               }
+
+               return allResources.sort((a, b) => {
+                       const aName = getResourceDisplayName(a);
+                       const bName = getResourceDisplayName(b);
+                       return aName.localeCompare(bName);
+               });
+       }
+
+       async function handleAttach() {
+               if (selectedResources.size === 0) return;
+
+               isAttaching = true;
+
+               try {
+                       const allResources = getAllResourcesFlatInTreeOrder();
+                       const resourcesToAttach = allResources.filter((r) => selectedResources.has(r.uri));
+
+                       for (const resource of resourcesToAttach) {
+                               await mcpStore.attachResource(resource.uri);
+                               onAttach?.(resource);
+                       }
+
+                       const count = resourcesToAttach.length;
+
+                       toast.success(
+                               count === 1
+                                       ? `Resource attached: ${resourcesToAttach[0].name}`
+                                       : `${count} resources attached`
+                       );
+
+                       handleOpenChange(false);
+               } catch (error) {
+                       console.error('Failed to attach resources:', error);
+               } finally {
+                       isAttaching = false;
+               }
+       }
+
+       const selectedTemplateUri = $derived(selectedTemplate?.uriTemplate ?? null);
+
+       const hasTemplateResult = $derived(
+               !!selectedTemplate && !!templatePreviewContent && !!templatePreviewUri
+       );
+</script>
+
+<Dialog.Root {open} onOpenChange={handleOpenChange}>
+       <Dialog.Content class="max-h-[80vh] !max-w-4xl overflow-hidden p-0">
+               <Dialog.Header class="border-b border-border/30 px-6 py-4">
+                       <Dialog.Title class="flex items-center gap-2">
+                               <FolderOpen class="h-5 w-5" />
+
+                               <span>MCP Resources</span>
+
+                               {#if totalCount > 0}
+                                       <span class="text-sm font-normal text-muted-foreground">({totalCount})</span>
+                               {/if}
+                       </Dialog.Title>
+
+                       <Dialog.Description>
+                               Browse and attach resources from connected MCP servers to your chat context.
+                       </Dialog.Description>
+               </Dialog.Header>
+
+               <div class="flex h-[500px] min-w-0">
+                       <div class="w-72 shrink-0 overflow-y-auto border-r border-border/30 p-4">
+                               <McpResourceBrowser
+                                       onSelect={handleResourceSelect}
+                                       onToggle={handleResourceToggle}
+                                       onTemplateSelect={handleTemplateSelect}
+                                       selectedUris={selectedResources}
+                                       {selectedTemplateUri}
+                                       expandToUri={preSelectedUri}
+                               />
+                       </div>
+
+                       <div class="min-w-0 flex-1 overflow-auto p-4">
+                               {#if selectedTemplate && !templatePreviewContent}
+                                       <div class="flex h-full flex-col">
+                                               <div class="mb-3 flex items-center gap-2">
+                                                       <Braces class="h-4 w-4 text-muted-foreground" />
+
+                                                       <span class="text-sm font-medium">
+                                                               {selectedTemplate.title || selectedTemplate.name}
+                                                       </span>
+                                               </div>
+
+                                               {#if selectedTemplate.description}
+                                                       <p class="mb-4 text-xs text-muted-foreground">
+                                                               {selectedTemplate.description}
+                                                       </p>
+                                               {/if}
+
+                                               <div class="mb-4 rounded-md border border-border/50 bg-muted/30 px-3 py-2">
+                                                       <p class="font-mono text-xs break-all text-muted-foreground">
+                                                               {selectedTemplate.uriTemplate}
+                                                       </p>
+                                               </div>
+
+                                               {#if templatePreviewLoading}
+                                                       <div class="flex flex-1 items-center justify-center">
+                                                               <Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
+                                                       </div>
+                                               {:else if templatePreviewError}
+                                                       <div class="flex flex-1 flex-col items-center justify-center gap-2 text-red-500">
+                                                               <span class="text-sm">{templatePreviewError}</span>
+
+                                                               <Button
+                                                                       size="sm"
+                                                                       variant="outline"
+                                                                       onclick={() => {
+                                                                               templatePreviewError = null;
+                                                                       }}
+                                                               >
+                                                                       Try again
+                                                               </Button>
+                                                       </div>
+                                               {:else}
+                                                       <McpResourceTemplateForm
+                                                               template={selectedTemplate}
+                                                               onResolve={handleTemplateResolve}
+                                                               onCancel={handleTemplateCancelForm}
+                                                       />
+                                               {/if}
+                                       </div>
+                               {:else if hasTemplateResult}
+                                       <!-- Template resolved: show preview -->
+                                       <McpResourcePreview
+                                               resource={{
+                                                       uri: templatePreviewUri ?? '',
+                                                       name: templatePreviewUri?.split('/').pop() || (templatePreviewUri ?? ''),
+                                                       serverName: selectedTemplate?.serverName || ''
+                                               }}
+                                               preloadedContent={templatePreviewContent}
+                                       />
+                               {:else if selectedResources.size === 1}
+                                       {@const allResources = getAllResourcesFlatInTreeOrder()}
+                                       {@const selectedResource = allResources.find((r) => selectedResources.has(r.uri))}
+
+                                       <McpResourcePreview resource={selectedResource ?? null} />
+                               {:else if selectedResources.size > 1}
+                                       <div class="flex flex-col gap-10">
+                                               {#each getAllResourcesFlatInTreeOrder() as resource (resource.uri)}
+                                                       {#if selectedResources.has(resource.uri)}
+                                                               <McpResourcePreview {resource} />
+                                                       {/if}
+                                               {/each}
+                                       </div>
+                               {:else}
+                                       <div class="flex h-full items-center justify-center text-sm text-muted-foreground">
+                                               Select a resource to preview
+                                       </div>
+                               {/if}
+                       </div>
+               </div>
+
+               <Dialog.Footer class="border-t border-border/30 px-6 py-4">
+                       <Button variant="outline" onclick={() => handleOpenChange(false)}>Cancel</Button>
+
+                       {#if hasTemplateResult}
+                               <Button onclick={handleAttachTemplateResource} disabled={isAttaching}>
+                                       {#if isAttaching}
+                                               <Loader2 class="mr-2 h-4 w-4 animate-spin" />
+                                       {:else}
+                                               <Plus class="mr-2 h-4 w-4" />
+                                       {/if}
+
+                                       Attach Resource
+                               </Button>
+                       {:else}
+                               <Button onclick={handleAttach} disabled={selectedResources.size === 0 || isAttaching}>
+                                       {#if isAttaching}
+                                               <Loader2 class="mr-2 h-4 w-4 animate-spin" />
+                                       {:else}
+                                               <Plus class="mr-2 h-4 w-4" />
+                                       {/if}
+
+                                       Attach {selectedResources.size > 0 ? `(${selectedResources.size})` : 'Resource'}
+                               </Button>
+                       {/if}
+               </Dialog.Footer>
+       </Dialog.Content>
+</Dialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogMcpServersSettings.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogMcpServersSettings.svelte
new file mode 100644 (file)
index 0000000..f0cd325
--- /dev/null
@@ -0,0 +1,39 @@
+<script lang="ts">
+       import * as Dialog from '$lib/components/ui/dialog';
+       import { McpLogo, McpServersSettings } from '$lib/components/app';
+
+       interface Props {
+               onOpenChange?: (open: boolean) => void;
+               open?: boolean;
+       }
+
+       let { onOpenChange, open = $bindable(false) }: Props = $props();
+
+       function handleClose() {
+               onOpenChange?.(false);
+       }
+</script>
+
+<Dialog.Root {open} onOpenChange={handleClose}>
+       <Dialog.Content
+               class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
+                       md:h-[80dvh] md:h-auto md:max-h-[80dvh] md:min-h-0 md:rounded-lg"
+               style="max-width: 56rem;"
+       >
+               <div class="grid gap-2 border-b border-border/30 p-4 md:p-6">
+                       <Dialog.Title class="inline-flex items-center text-lg font-semibold">
+                               <McpLogo class="mr-2 inline h-4 w-4" />
+
+                               MCP Servers
+                       </Dialog.Title>
+
+                       <Dialog.Description class="text-sm text-muted-foreground">
+                               Add and configure MCP servers to enable agentic tool execution capabilities.
+                       </Dialog.Description>
+               </div>
+
+               <div class="flex-1 overflow-y-auto px-4 py-6">
+                       <McpServersSettings />
+               </div>
+       </Dialog.Content>
+</Dialog.Root>
index f34af734d0cba13f6fd05391fec04611647f74e0..216eae6843ed023c4de7b1de367967942b1fdc7f 100644 (file)
@@ -414,3 +414,57 @@ export { default as DialogConversationSelection } from './DialogConversationSele
  * ```
  */
 export { default as DialogModelInformation } from './DialogModelInformation.svelte';
+
+/**
+ * **DialogMcpResources** - MCP resources browser dialog
+ *
+ * Dialog for browsing and attaching MCP resources to chat context.
+ * Displays resources from connected MCP servers in a tree structure
+ * with preview panel and multi-select support.
+ *
+ * **Architecture:**
+ * - Uses ShadCN Dialog with two-panel layout
+ * - Left panel: McpResourceBrowser with tree navigation
+ * - Right panel: McpResourcePreview for selected resource
+ * - Integrates with mcpStore for resource fetching and attachment
+ *
+ * **Features:**
+ * - Tree-based resource navigation by server and path
+ * - Single and multi-select with shift+click
+ * - Resource preview with content display
+ * - Quick attach button per resource
+ * - Batch attach for multiple selections
+ *
+ * @example
+ * ```svelte
+ * <DialogMcpResources
+ *   bind:open={showResources}
+ *   onAttach={handleResourceAttach}
+ * />
+ * ```
+ */
+export { default as DialogMcpResources } from './DialogMcpResources.svelte';
+
+/**
+ * **DialogMcpResourcePreview** - MCP resource content preview
+ *
+ * Dialog for previewing the content of a stored MCP resource attachment.
+ * Displays the resource content with syntax highlighting for code,
+ * image rendering for images, and plain text for other content.
+ *
+ * **Features:**
+ * - Syntax highlighted code preview
+ * - Image rendering for image resources
+ * - Copy to clipboard and download actions
+ * - Server name and favicon display
+ * - MIME type badge
+ *
+ * @example
+ * ```svelte
+ * <DialogMcpResourcePreview
+ *   bind:open={previewOpen}
+ *   extra={mcpResourceExtra}
+ * />
+ * ```
+ */
+export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.svelte';
diff --git a/tools/server/webui/src/lib/components/app/forms/InputWithSuggestions.svelte b/tools/server/webui/src/lib/components/app/forms/InputWithSuggestions.svelte
new file mode 100644 (file)
index 0000000..5d047c5
--- /dev/null
@@ -0,0 +1,78 @@
+<script lang="ts">
+       import { fly } from 'svelte/transition';
+       import { Input } from '$lib/components/ui/input';
+       import { Label } from '$lib/components/ui/label';
+
+       interface Props {
+               name: string;
+               value: string;
+               suggestions?: string[];
+               isLoadingSuggestions?: boolean;
+               isAutocompleteActive?: boolean;
+               autocompleteIndex?: number;
+               onInput: (value: string) => void;
+               onKeydown: (event: KeyboardEvent) => void;
+               onBlur: () => void;
+               onFocus: () => void;
+               onSelectSuggestion: (value: string) => void;
+       }
+
+       let {
+               name,
+               value = '',
+               suggestions = [],
+               isLoadingSuggestions = false,
+               isAutocompleteActive = false,
+               autocompleteIndex = 0,
+               onInput,
+               onKeydown,
+               onBlur,
+               onFocus,
+               onSelectSuggestion
+       }: Props = $props();
+</script>
+
+<div class="relative grid gap-1">
+       <Label for="tpl-arg-{name}" class="mb-1 text-muted-foreground">
+               <span>
+                       {name}
+
+                       <span class="text-destructive">*</span>
+               </span>
+
+               {#if isLoadingSuggestions}
+                       <span class="text-xs text-muted-foreground/50">...</span>
+               {/if}
+       </Label>
+
+       <Input
+               id="tpl-arg-{name}"
+               type="text"
+               {value}
+               oninput={(e) => onInput(e.currentTarget.value)}
+               onkeydown={onKeydown}
+               onblur={onBlur}
+               onfocus={onFocus}
+               placeholder="Enter {name}"
+               autocomplete="off"
+       />
+
+       {#if isAutocompleteActive && suggestions.length > 0}
+               <div
+                       class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
+                       transition:fly={{ y: -5, duration: 100 }}
+               >
+                       {#each suggestions as suggestion, i (suggestion)}
+                               <button
+                                       type="button"
+                                       onmousedown={() => onSelectSuggestion(suggestion)}
+                                       class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
+                                               ? 'bg-accent'
+                                               : ''}"
+                               >
+                                       {suggestion}
+                               </button>
+                       {/each}
+               </div>
+       {/if}
+</div>
index ca3da02e565f3ebe14117c74cf593b65f6d50baa..e0bd8d98e8e6c0339b2d7ab85605c39025cb8a75 100644 (file)
@@ -1,7 +1,12 @@
 <script lang="ts">
        import { Plus, Trash2 } from '@lucide/svelte';
        import { Input } from '$lib/components/ui/input';
-       import { autoResizeTextarea } from '$lib/utils';
+       import {
+               autoResizeTextarea,
+               sanitizeKeyValuePairKey,
+               sanitizeKeyValuePairValue
+       } from '$lib/utils';
+       import { KEY_VALUE_PAIR_KEY_MAX_LENGTH, KEY_VALUE_PAIR_VALUE_MAX_LENGTH } from '$lib/constants';
        import type { KeyValuePair } from '$lib/types';
 
        interface Props {
                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>
index b0280a20a9e4d55b25a802b3fe7fddc5a2c12227..4cf56cdc9d046e0bb6388c44d97bc79bf41357a5 100644 (file)
@@ -7,12 +7,18 @@
  */
 
 /**
- * **SearchInput** - Search field with clear button
+ * **InputWithSuggestions** - Input field with autocomplete suggestions
  *
- * Input field optimized for search with clear button and keyboard handling.
- * Supports placeholder, autofocus, and change callbacks.
+ * Text input with dropdown suggestions and keyboard navigation.
+ * Supports autocomplete functionality with suggestion loading.
+ *
+ * **Features:**
+ * - Autocomplete dropdown with suggestions
+ * - Keyboard navigation (arrow keys, enter)
+ * - Loading state for suggestions
+ * - Focus and blur handling
  */
-export { default as SearchInput } from './SearchInput.svelte';
+export { default as InputWithSuggestions } from './InputWithSuggestions.svelte';
 
 /**
  * **KeyValuePairs** - Editable key-value list
@@ -28,3 +34,11 @@ export { default as SearchInput } from './SearchInput.svelte';
  * - Auto-resize value textarea
  */
 export { default as KeyValuePairs } from './KeyValuePairs.svelte';
+
+/**
+ * **SearchInput** - Search field with clear button
+ *
+ * Input field optimized for search with clear button and keyboard handling.
+ * Supports placeholder, autofocus, and change callbacks.
+ */
+export { default as SearchInput } from './SearchInput.svelte';
index 3e3df48fd805e13d309b0bff9c8e61ca617e6d83..56bd8a4852035aff1ed08af3de3b7560a44d2dbc 100644 (file)
@@ -4,6 +4,7 @@ export * from './chat';
 export * from './content';
 export * from './dialogs';
 export * from './forms';
+export * from './mcp';
 export * from './misc';
 export * from './models';
 export * from './navigation';
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte b/tools/server/webui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte
new file mode 100644 (file)
index 0000000..7585849
--- /dev/null
@@ -0,0 +1,57 @@
+<script lang="ts">
+       import { cn } from '$lib/components/ui/utils';
+       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { HealthCheckStatus } from '$lib/enums';
+       import { MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
+
+       interface Props {
+               class?: string;
+       }
+
+       let { class: className = '' }: Props = $props();
+
+       let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
+       let enabledMcpServersForChat = $derived(
+               mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
+       );
+       let healthyEnabledMcpServers = $derived(
+               enabledMcpServersForChat.filter((s) => {
+                       const healthState = mcpStore.getHealthCheckState(s.id);
+                       return healthState.status !== HealthCheckStatus.ERROR;
+               })
+       );
+       let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
+       let extraServersCount = $derived(
+               Math.max(0, healthyEnabledMcpServers.length - MAX_DISPLAYED_MCP_AVATARS)
+       );
+       let mcpFavicons = $derived(
+               healthyEnabledMcpServers
+                       .slice(0, MAX_DISPLAYED_MCP_AVATARS)
+                       .map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
+                       .filter((f) => f.url !== null)
+       );
+</script>
+
+{#if hasEnabledMcpServers && mcpFavicons.length > 0}
+       <div class={cn('inline-flex items-center gap-1.5', className)}>
+               <div class="flex -space-x-1">
+                       {#each mcpFavicons as favicon (favicon.id)}
+                               <div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
+                                       <img
+                                               src={favicon.url}
+                                               alt=""
+                                               class="h-4 w-4"
+                                               onerror={(e) => {
+                                                       (e.currentTarget as HTMLImageElement).style.display = 'none';
+                                               }}
+                                       />
+                               </div>
+                       {/each}
+               </div>
+
+               {#if extraServersCount > 0}
+                       <span class="text-xs text-muted-foreground">+{extraServersCount}</span>
+               {/if}
+       </div>
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpCapabilitiesBadges.svelte b/tools/server/webui/src/lib/components/app/mcp/McpCapabilitiesBadges.svelte
new file mode 100644 (file)
index 0000000..d17b24e
--- /dev/null
@@ -0,0 +1,61 @@
+<script lang="ts">
+       import { Wrench, Database, MessageSquare, FileText, Sparkles, ListChecks } from '@lucide/svelte';
+       import type { MCPCapabilitiesInfo } from '$lib/types';
+       import { Badge } from '$lib/components/ui/badge';
+
+       interface Props {
+               capabilities?: MCPCapabilitiesInfo;
+       }
+
+       let { capabilities }: Props = $props();
+</script>
+
+{#if capabilities}
+       {#if capabilities.server.tools}
+               <Badge variant="outline" class="h-5 gap-1 bg-green-50 px-1.5 text-[10px] dark:bg-green-950">
+                       <Wrench class="h-3 w-3 text-green-600 dark:text-green-400" />
+
+                       Tools
+               </Badge>
+       {/if}
+
+       {#if capabilities.server.resources}
+               <Badge variant="outline" class="h-5 gap-1 bg-blue-50 px-1.5 text-[10px] dark:bg-blue-950">
+                       <Database class="h-3 w-3 text-blue-600 dark:text-blue-400" />
+
+                       Resources
+               </Badge>
+       {/if}
+
+       {#if capabilities.server.prompts}
+               <Badge variant="outline" class="h-5 gap-1 bg-purple-50 px-1.5 text-[10px] dark:bg-purple-950">
+                       <MessageSquare class="h-3 w-3 text-purple-600 dark:text-purple-400" />
+
+                       Prompts
+               </Badge>
+       {/if}
+
+       {#if capabilities.server.logging}
+               <Badge variant="outline" class="h-5 gap-1 bg-orange-50 px-1.5 text-[10px] dark:bg-orange-950">
+                       <FileText class="h-3 w-3 text-orange-600 dark:text-orange-400" />
+
+                       Logging
+               </Badge>
+       {/if}
+
+       {#if capabilities.server.completions}
+               <Badge variant="outline" class="h-5 gap-1 bg-cyan-50 px-1.5 text-[10px] dark:bg-cyan-950">
+                       <Sparkles class="h-3 w-3 text-cyan-600 dark:text-cyan-400" />
+
+                       Completions
+               </Badge>
+       {/if}
+
+       {#if capabilities.server.tasks}
+               <Badge variant="outline" class="h-5 gap-1 bg-pink-50 px-1.5 text-[10px] dark:bg-pink-950">
+                       <ListChecks class="h-3 w-3 text-pink-600 dark:text-pink-400" />
+
+                       Tasks
+               </Badge>
+       {/if}
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpConnectionLogs.svelte b/tools/server/webui/src/lib/components/app/mcp/McpConnectionLogs.svelte
new file mode 100644 (file)
index 0000000..6e24ce6
--- /dev/null
@@ -0,0 +1,60 @@
+<script lang="ts">
+       import { ChevronDown, ChevronRight } from '@lucide/svelte';
+       import * as Collapsible from '$lib/components/ui/collapsible';
+       import { cn } from '$lib/components/ui/utils';
+       import type { MCPConnectionLog } from '$lib/types';
+       import { formatTime, getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils';
+
+       interface Props {
+               logs: MCPConnectionLog[];
+               connectionTimeMs?: number;
+               defaultExpanded?: boolean;
+               class?: string;
+       }
+
+       let { logs, connectionTimeMs, defaultExpanded = false, class: className }: Props = $props();
+
+       let isExpanded = $derived(defaultExpanded);
+</script>
+
+{#if logs.length > 0}
+       <Collapsible.Root bind:open={isExpanded} class={className}>
+               <div class="space-y-2">
+                       <Collapsible.Trigger
+                               class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+                       >
+                               {#if isExpanded}
+                                       <ChevronDown class="h-3.5 w-3.5" />
+                               {:else}
+                                       <ChevronRight class="h-3.5 w-3.5" />
+                               {/if}
+
+                               <span>Connection Log ({logs.length})</span>
+
+                               {#if connectionTimeMs !== undefined}
+                                       <span class="ml-1">· Connected in {connectionTimeMs}ms</span>
+                               {/if}
+                       </Collapsible.Trigger>
+               </div>
+
+               <Collapsible.Content class="mt-2">
+                       <div
+                               class="max-h-64 space-y-0.5 overflow-y-auto rounded bg-muted/50 p-2 font-mono text-[10px]"
+                       >
+                               {#each logs as log (log.timestamp.getTime() + log.message)}
+                                       {@const Icon = getMcpLogLevelIcon(log.level)}
+
+                                       <div class={cn('flex items-start gap-1.5', getMcpLogLevelClass(log.level))}>
+                                               <span class="shrink-0 text-muted-foreground">
+                                                       {formatTime(log.timestamp)}
+                                               </span>
+
+                                               <Icon class="mt-0.5 h-3 w-3 shrink-0" />
+
+                                               <span class="break-all">{log.message}</span>
+                                       </div>
+                               {/each}
+                       </div>
+               </Collapsible.Content>
+       </Collapsible.Root>
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpLogo.svelte b/tools/server/webui/src/lib/components/app/mcp/McpLogo.svelte
new file mode 100644 (file)
index 0000000..9f73db8
--- /dev/null
@@ -0,0 +1,111 @@
+<script>
+       let { class: className = '', style = '' } = $props();
+</script>
+
+<svg
+       class={className}
+       {style}
+       xmlns="http://www.w3.org/2000/svg"
+       viewBox="0 0 174 174"
+       xmlns:xlink="http://www.w3.org/1999/xlink"
+       fill="none"
+       version="1.1"
+       ><g id="shape-320b5b95-d08d-8089-8007-585a8e498184"
+               ><defs
+                       ><clipPath
+                               id="frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1"
+                               class="frame-clip frame-clip-def"
+                               ><rect
+                                       rx="0"
+                                       ry="0"
+                                       x="0"
+                                       y="0"
+                                       width="174.00000000000045"
+                                       height="174"
+                                       transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
+                               /></clipPath
+                       ></defs
+               ><g class="frame-container-wrapper"
+                       ><g class="frame-container-blur"
+                               ><g class="frame-container-shadows"
+                                       ><g clip-path="url(#frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1)" fill="none"
+                                               ><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a8e498184"
+                                                       ><rect
+                                                               rx="0"
+                                                               ry="0"
+                                                               x="0"
+                                                               y="0"
+                                                               width="174.00000000000045"
+                                                               height="174"
+                                                               transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
+                                                               class="frame-background"
+                                                       /></g
+                                               ><g class="frame-children"
+                                                       ><g id="shape-320b5b95-d08d-8089-8007-585a974337b1"
+                                                               ><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b1"
+                                                                       ><path
+                                                                               d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
+                                                                               fill="none"
+                                                                               stroke-linecap="round"
+                                                                               style="fill: none;"
+                                                                       /></g
+                                                               ><g
+                                                                       fill="none"
+                                                                       stroke-linecap="round"
+                                                                       id="strokes-b954dcef-3e3e-8015-8007-585acd4382b6-320b5b95-d08d-8089-8007-585a974337b1"
+                                                                       class="strokes"
+                                                                       ><g class="stroke-shape"
+                                                                               ><path
+                                                                                       d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
+                                                                                       style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
+                                                                               /></g
+                                                                       ></g
+                                                               ></g
+                                                       ><g id="shape-320b5b95-d08d-8089-8007-585a974337b2"
+                                                               ><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b2"
+                                                                       ><path
+                                                                               d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
+                                                                               fill="none"
+                                                                               stroke-linecap="round"
+                                                                               style="fill: none;"
+                                                                       /></g
+                                                               ><g
+                                                                       fill="none"
+                                                                       stroke-linecap="round"
+                                                                       id="strokes-b954dcef-3e3e-8015-8007-585acd447743-320b5b95-d08d-8089-8007-585a974337b2"
+                                                                       class="strokes"
+                                                                       ><g class="stroke-shape"
+                                                                               ><path
+                                                                                       d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
+                                                                                       style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
+                                                                               /></g
+                                                                       ></g
+                                                               ></g
+                                                       ><g id="shape-320b5b95-d08d-8089-8007-585a974337b3"
+                                                               ><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b3"
+                                                                       ><path
+                                                                               d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
+                                                                               fill="none"
+                                                                               stroke-linecap="round"
+                                                                               style="fill: none;"
+                                                                       /></g
+                                                               ><g
+                                                                       fill="none"
+                                                                       stroke-linecap="round"
+                                                                       id="strokes-b954dcef-3e3e-8015-8007-585acd44c5c9-320b5b95-d08d-8089-8007-585a974337b3"
+                                                                       class="strokes"
+                                                                       ><g class="stroke-shape"
+                                                                               ><path
+                                                                                       d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
+                                                                                       style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
+                                                                               /></g
+                                                                       ></g
+                                                               ></g
+                                                       ></g
+                                               ></g
+                                       ></g
+                               ></g
+                       ></g
+               ></g
+       ></svg
+>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowser.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowser.svelte
new file mode 100644 (file)
index 0000000..9d7e9c3
--- /dev/null
@@ -0,0 +1,154 @@
+<script lang="ts">
+       import { cn } from '$lib/components/ui/utils';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
+       import type { MCPServerResources, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
+       import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+       import { parseResourcePath } from '$lib/utils';
+       import McpResourceBrowserHeader from './McpResourceBrowserHeader.svelte';
+       import McpResourceBrowserEmptyState from './McpResourceBrowserEmptyState.svelte';
+       import McpResourceBrowserServerItem from './McpResourceBrowserServerItem.svelte';
+
+       interface Props {
+               onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
+               onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
+               onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
+               selectedUris?: Set<string>;
+               selectedTemplateUri?: string | null;
+               expandToUri?: string;
+               class?: string;
+       }
+
+       let {
+               onSelect,
+               onToggle,
+               onTemplateSelect,
+               selectedUris = new Set(),
+               selectedTemplateUri,
+               expandToUri,
+               class: className
+       }: Props = $props();
+
+       let expandedServers = new SvelteSet<string>();
+       let expandedFolders = new SvelteSet<string>();
+       let searchQuery = $state('');
+
+       const resources = $derived(mcpResources());
+       const isLoading = $derived(mcpResourcesLoading());
+
+       const filteredResources = $derived.by(() => {
+               if (!searchQuery.trim()) {
+                       return resources;
+               }
+
+               const query = searchQuery.toLowerCase();
+               const filtered = new SvelteMap();
+
+               for (const [serverName, serverRes] of resources.entries()) {
+                       const filteredResources = serverRes.resources.filter((r) => {
+                               return (
+                                       r.title?.toLowerCase().includes(query) ||
+                                       r.uri.toLowerCase().includes(query) ||
+                                       serverName.toLowerCase().includes(query)
+                               );
+                       });
+
+                       const filteredTemplates = serverRes.templates.filter((t) => {
+                               return (
+                                       t.name?.toLowerCase().includes(query) ||
+                                       t.title?.toLowerCase().includes(query) ||
+                                       t.uriTemplate.toLowerCase().includes(query) ||
+                                       serverName.toLowerCase().includes(query)
+                               );
+                       });
+
+                       if (filteredResources.length > 0 || filteredTemplates.length > 0 || query.trim()) {
+                               filtered.set(serverName, {
+                                       ...serverRes,
+                                       resources: filteredResources,
+                                       templates: filteredTemplates
+                               });
+                       }
+               }
+
+               return filtered;
+       });
+
+       $effect(() => {
+               if (expandToUri && resources.size > 0) {
+                       autoExpandToResource(expandToUri);
+               }
+       });
+
+       function autoExpandToResource(uri: string) {
+               for (const [serverName, serverRes] of resources.entries()) {
+                       const resource = serverRes.resources.find((r) => r.uri === uri);
+                       if (resource) {
+                               expandedServers.add(serverName);
+
+                               const pathParts = parseResourcePath(uri);
+                               if (pathParts.length > 1) {
+                                       let currentPath = '';
+                                       for (let i = 0; i < pathParts.length - 1; i++) {
+                                               currentPath = `${currentPath}/${pathParts[i]}`;
+                                               const folderId = `${serverName}:${currentPath}`;
+                                               expandedFolders.add(folderId);
+                                       }
+                               }
+                               break;
+                       }
+               }
+       }
+
+       function toggleServer(serverName: string) {
+               if (expandedServers.has(serverName)) {
+                       expandedServers.delete(serverName);
+               } else {
+                       expandedServers.add(serverName);
+               }
+       }
+
+       function toggleFolder(folderId: string) {
+               if (expandedFolders.has(folderId)) {
+                       expandedFolders.delete(folderId);
+               } else {
+                       expandedFolders.add(folderId);
+               }
+       }
+
+       function handleRefresh() {
+               mcpStore.fetchAllResources();
+       }
+</script>
+
+<div class={cn('flex flex-col gap-2', className)}>
+       <McpResourceBrowserHeader
+               {isLoading}
+               onRefresh={handleRefresh}
+               onSearch={(q) => (searchQuery = q)}
+               {searchQuery}
+       />
+
+       <div class="flex flex-col gap-1">
+               {#if filteredResources.size === 0}
+                       <McpResourceBrowserEmptyState {isLoading} />
+               {:else}
+                       {#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)}
+                               <McpResourceBrowserServerItem
+                                       serverName={serverName as string}
+                                       serverRes={serverRes as MCPServerResources}
+                                       isExpanded={expandedServers.has(serverName as string)}
+                                       {selectedUris}
+                                       {selectedTemplateUri}
+                                       {expandedFolders}
+                                       onToggleServer={() => toggleServer(serverName as string)}
+                                       onToggleFolder={toggleFolder}
+                                       {onSelect}
+                                       {onToggle}
+                                       {onTemplateSelect}
+                                       {searchQuery}
+                               />
+                       {/each}
+               {/if}
+       </div>
+</div>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserEmptyState.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserEmptyState.svelte
new file mode 100644 (file)
index 0000000..4fb0c1e
--- /dev/null
@@ -0,0 +1,15 @@
+<script lang="ts">
+       interface Props {
+               isLoading: boolean;
+       }
+
+       let { isLoading }: Props = $props();
+</script>
+
+<div class="py-4 text-center text-sm text-muted-foreground">
+       {#if isLoading}
+               Loading resources...
+       {:else}
+               No resources available
+       {/if}
+</div>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserHeader.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserHeader.svelte
new file mode 100644 (file)
index 0000000..419654c
--- /dev/null
@@ -0,0 +1,41 @@
+<script lang="ts">
+       import { RefreshCw, Loader2 } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import { SearchInput } from '$lib/components/app/forms';
+
+       interface Props {
+               isLoading: boolean;
+               onRefresh: () => void;
+               onSearch?: (query: string) => void;
+               searchQuery?: string;
+       }
+
+       let { isLoading, onRefresh, onSearch, searchQuery = '' }: Props = $props();
+</script>
+
+<div class="flex flex-col gap-2">
+       <div class="mb-2 flex items-center gap-4">
+               <SearchInput
+                       placeholder="Search resources..."
+                       value={searchQuery}
+                       onInput={(value) => onSearch?.(value)}
+               />
+
+               <Button
+                       variant="ghost"
+                       size="sm"
+                       class="h-8 w-8 p-0"
+                       onclick={onRefresh}
+                       disabled={isLoading}
+                       title="Refresh resources"
+               >
+                       {#if isLoading}
+                               <Loader2 class="h-4 w-4 animate-spin" />
+                       {:else}
+                               <RefreshCw class="h-4 w-4" />
+                       {/if}
+               </Button>
+       </div>
+
+       <h3 class="text-sm font-medium">Available resources</h3>
+</div>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserServerItem.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/McpResourceBrowserServerItem.svelte
new file mode 100644 (file)
index 0000000..01ab108
--- /dev/null
@@ -0,0 +1,235 @@
+<script lang="ts">
+       import { FolderOpen, ChevronDown, ChevronRight, Loader2, Braces } from '@lucide/svelte';
+       import { Checkbox } from '$lib/components/ui/checkbox';
+       import * as Collapsible from '$lib/components/ui/collapsible';
+       import { cn } from '$lib/components/ui/utils';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
+       import { SvelteSet } from 'svelte/reactivity';
+       import {
+               type ResourceTreeNode,
+               buildResourceTree,
+               countTreeResources,
+               sortTreeChildren
+       } from './mcp-resource-browser';
+       import { getDisplayName, getResourceIcon } from '$lib/utils';
+
+       interface Props {
+               serverName: string;
+               serverRes: MCPServerResources;
+               isExpanded: boolean;
+               selectedUris: Set<string>;
+               selectedTemplateUri?: string | null;
+               expandedFolders: SvelteSet<string>;
+               onToggleServer: () => void;
+               onToggleFolder: (folderId: string) => void;
+               onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
+               onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
+               onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
+               searchQuery?: string;
+       }
+
+       let {
+               serverName,
+               serverRes,
+               isExpanded,
+               selectedUris,
+               selectedTemplateUri,
+               expandedFolders,
+               onToggleServer,
+               onToggleFolder,
+               onSelect,
+               onToggle,
+               onTemplateSelect,
+               searchQuery = ''
+       }: Props = $props();
+
+       const hasResources = $derived(serverRes.resources.length > 0);
+       const hasTemplates = $derived(serverRes.templates.length > 0);
+       const hasContent = $derived(hasResources || hasTemplates);
+       const displayName = $derived(mcpStore.getServerDisplayName(serverName));
+       const favicon = $derived(mcpStore.getServerFavicon(serverName));
+       const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
+
+       const templateInfos = $derived<MCPResourceTemplateInfo[]>(
+               serverRes.templates.map((t) => ({
+                       uriTemplate: t.uriTemplate,
+                       name: t.name,
+                       title: t.title,
+                       description: t.description,
+                       mimeType: t.mimeType,
+                       serverName,
+                       annotations: t.annotations,
+                       icons: t.icons
+               }))
+       );
+
+       function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
+               onSelect?.(resource, event.shiftKey);
+       }
+
+       function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
+               onToggle?.(resource, checked);
+       }
+
+       function isResourceSelected(resource: MCPResourceInfo): boolean {
+               return selectedUris.has(resource.uri);
+       }
+</script>
+
+{#snippet renderTreeNode(node: ResourceTreeNode, depth: number, parentPath: string)}
+       {@const isFolder = !node.resource && node.children.size > 0}
+       {@const folderId = `${serverName}:${parentPath}/${node.name}`}
+       {@const isFolderExpanded = expandedFolders.has(folderId)}
+
+       {#if isFolder}
+               {@const folderCount = countTreeResources(node)}
+               <Collapsible.Root open={isFolderExpanded} onOpenChange={() => onToggleFolder(folderId)}>
+                       <Collapsible.Trigger
+                               class="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50"
+                       >
+                               {#if isFolderExpanded}
+                                       <ChevronDown class="h-3 w-3" />
+                               {:else}
+                                       <ChevronRight class="h-3 w-3" />
+                               {/if}
+
+                               <FolderOpen class="h-3.5 w-3.5 text-muted-foreground" />
+
+                               <span class="font-medium">{node.name}</span>
+
+                               <span class="text-xs text-muted-foreground">({folderCount})</span>
+                       </Collapsible.Trigger>
+
+                       <Collapsible.Content>
+                               <div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
+                                       {#each sortTreeChildren( [...node.children.values()] ) as child (child.resource?.uri || `${serverName}:${parentPath}/${node.name}/${child.name}`)}
+                                               {@render renderTreeNode(child, depth + 1, `${parentPath}/${node.name}`)}
+                                       {/each}
+                               </div>
+                       </Collapsible.Content>
+               </Collapsible.Root>
+       {:else if node.resource}
+               {@const resource = node.resource}
+               {@const ResourceIcon = getResourceIcon(resource.mimeType, resource.uri)}
+               {@const isSelected = isResourceSelected(resource)}
+               {@const resourceDisplayName = resource.title || getDisplayName(node.name)}
+
+               <div class="group flex w-full items-center gap-2">
+                       {#if onToggle}
+                               <Checkbox
+                                       checked={isSelected}
+                                       onCheckedChange={(checked: boolean | 'indeterminate') =>
+                                               handleCheckboxChange(resource, checked === true)}
+                                       class="h-4 w-4"
+                               />
+                       {/if}
+
+                       <button
+                               class={cn(
+                                       'flex flex-1 items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
+                                       'hover:bg-muted/50',
+                                       isSelected && 'bg-muted'
+                               )}
+                               onclick={(e: MouseEvent) => handleResourceClick(resource, e)}
+                               title={resourceDisplayName}
+                       >
+                               <ResourceIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+
+                               <span class="min-w-0 flex-1 truncate text-left">
+                                       {resourceDisplayName}
+                               </span>
+                       </button>
+               </div>
+       {/if}
+{/snippet}
+
+<Collapsible.Root open={isExpanded} onOpenChange={onToggleServer}>
+       <Collapsible.Trigger
+               class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
+       >
+               {#if isExpanded}
+                       <ChevronDown class="h-3.5 w-3.5" />
+               {:else}
+                       <ChevronRight class="h-3.5 w-3.5" />
+               {/if}
+
+               <span class="inline-flex flex-col items-start text-left">
+                       <span class="inline-flex items-center justify-start gap-1.5 font-medium">
+                               {#if favicon}
+                                       <img
+                                               src={favicon}
+                                               alt=""
+                                               class="h-4 w-4 shrink-0 rounded-sm"
+                                               onerror={(e) => {
+                                                       (e.currentTarget as HTMLImageElement).style.display = 'none';
+                                               }}
+                                       />
+                               {/if}
+
+                               {displayName}
+                       </span>
+
+                       <span class="text-xs text-muted-foreground">
+                               ({serverRes.resources.length} resource{serverRes.resources.length !== 1
+                                       ? 's'
+                                       : ''}{#if hasTemplates}, {serverRes.templates.length} template{serverRes.templates
+                                               .length !== 1
+                                               ? 's'
+                                               : ''}{/if})
+                       </span>
+               </span>
+
+               {#if serverRes.loading}
+                       <Loader2 class="ml-auto h-3 w-3 animate-spin text-muted-foreground" />
+               {/if}
+       </Collapsible.Trigger>
+
+       <Collapsible.Content>
+               <div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
+                       {#if serverRes.error}
+                               <div class="py-1 text-xs text-red-500">
+                                       Error: {serverRes.error}
+                               </div>
+                       {:else if !hasContent}
+                               <div class="py-1 text-xs text-muted-foreground">No resources</div>
+                       {:else}
+                               {#if hasResources}
+                                       {#each sortTreeChildren( [...resourceTree.children.values()] ) as child (child.resource?.uri || `${serverName}:${child.name}`)}
+                                               {@render renderTreeNode(child, 1, '')}
+                                       {/each}
+                               {/if}
+
+                               {#if hasTemplates && onTemplateSelect}
+                                       {#if hasResources}
+                                               <div class="my-1 border-t border-border/30"></div>
+                                       {/if}
+
+                                       <div
+                                               class="py-0.5 text-[11px] font-medium tracking-wide text-muted-foreground/70 uppercase"
+                                       >
+                                               Templates
+                                       </div>
+
+                                       {#each templateInfos as template (template.uriTemplate)}
+                                               <button
+                                                       class={cn(
+                                                               'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
+                                                               'hover:bg-muted/50',
+                                                               selectedTemplateUri === template.uriTemplate && 'bg-muted'
+                                                       )}
+                                                       onclick={() => onTemplateSelect(template)}
+                                                       title={template.uriTemplate}
+                                               >
+                                                       <Braces class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+
+                                                       <span class="min-w-0 flex-1 truncate text-left">
+                                                               {template.title || template.name}
+                                                       </span>
+                                               </button>
+                                       {/each}
+                               {/if}
+                       {/if}
+               </div>
+       </Collapsible.Content>
+</Collapsible.Root>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/mcp-resource-browser.ts b/tools/server/webui/src/lib/components/app/mcp/McpResourceBrowser/mcp-resource-browser.ts
new file mode 100644 (file)
index 0000000..804fa7f
--- /dev/null
@@ -0,0 +1,118 @@
+import type { MCPResource, MCPResourceInfo } from '$lib/types';
+import { parseResourcePath } from '$lib/utils';
+
+export interface ResourceTreeNode {
+       name: string;
+       resource?: MCPResourceInfo;
+       children: Map<string, ResourceTreeNode>;
+       isFiltered?: boolean;
+}
+
+function resourceMatchesSearch(resource: MCPResource, query: string): boolean {
+       return (
+               resource.title?.toLowerCase().includes(query) || resource.uri.toLowerCase().includes(query)
+       );
+}
+
+export function buildResourceTree(
+       resourceList: MCPResource[],
+       serverName: string,
+       searchQuery?: string
+): ResourceTreeNode {
+       const root: ResourceTreeNode = { name: 'root', children: new Map() };
+
+       if (!searchQuery || !searchQuery.trim()) {
+               for (const resource of resourceList) {
+                       const pathParts = parseResourcePath(resource.uri);
+                       let current = root;
+
+                       for (let i = 0; i < pathParts.length - 1; i++) {
+                               const part = pathParts[i];
+                               if (!current.children.has(part)) {
+                                       current.children.set(part, { name: part, children: new Map() });
+                               }
+                               current = current.children.get(part)!;
+                       }
+
+                       const fileName = pathParts[pathParts.length - 1] || resource.name;
+                       current.children.set(resource.uri, {
+                               name: fileName,
+                               resource: { ...resource, serverName },
+                               children: new Map()
+                       });
+               }
+
+               return root;
+       }
+
+       const query = searchQuery.toLowerCase();
+
+       // Build tree with filtering
+       for (const resource of resourceList) {
+               if (!resourceMatchesSearch(resource, query)) continue;
+
+               const pathParts = parseResourcePath(resource.uri);
+               let current = root;
+
+               for (let i = 0; i < pathParts.length - 1; i++) {
+                       const part = pathParts[i];
+                       if (!current.children.has(part)) {
+                               current.children.set(part, { name: part, children: new Map(), isFiltered: true });
+                       }
+                       current = current.children.get(part)!;
+               }
+
+               const fileName = pathParts[pathParts.length - 1] || resource.name;
+
+               current.children.set(resource.uri, {
+                       name: fileName,
+                       resource: { ...resource, serverName },
+                       children: new Map(),
+                       isFiltered: true
+               });
+       }
+
+       function cleanupEmptyFolders(node: ResourceTreeNode): boolean {
+               if (node.resource) return true;
+
+               const toDelete: string[] = [];
+               for (const [name, child] of node.children.entries()) {
+                       if (!cleanupEmptyFolders(child)) {
+                               toDelete.push(name);
+                       }
+               }
+
+               for (const name of toDelete) {
+                       node.children.delete(name);
+               }
+
+               return node.children.size > 0;
+       }
+
+       cleanupEmptyFolders(root);
+
+       return root;
+}
+
+export function countTreeResources(node: ResourceTreeNode): number {
+       if (node.resource) return 1;
+       let count = 0;
+
+       for (const child of node.children.values()) {
+               count += countTreeResources(child);
+       }
+
+       return count;
+}
+
+export function sortTreeChildren(children: ResourceTreeNode[]): ResourceTreeNode[] {
+       return children.sort((a, b) => {
+               const aIsFolder = !a.resource && a.children.size > 0;
+               const bIsFolder = !b.resource && b.children.size > 0;
+
+               if (aIsFolder && !bIsFolder) return -1;
+               if (!aIsFolder && bIsFolder) return 1;
+
+               return a.name.localeCompare(b.name);
+       });
+}
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourcePreview.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourcePreview.svelte
new file mode 100644 (file)
index 0000000..6508c02
--- /dev/null
@@ -0,0 +1,175 @@
+<script lang="ts">
+       import { FileText, Loader2, AlertCircle, Download } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import { cn } from '$lib/components/ui/utils';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import {
+               isImageMimeType,
+               createBase64DataUrl,
+               getResourceTextContent,
+               getResourceBlobContent,
+               downloadResourceContent
+       } from '$lib/utils';
+       import { MimeTypeApplication, MimeTypeText } from '$lib/enums';
+       import { ActionIconCopyToClipboard } from '$lib/components/app';
+       import type { MCPResourceInfo, MCPResourceContent } from '$lib/types';
+
+       interface Props {
+               resource: MCPResourceInfo | null;
+               /** Pre-loaded content (e.g., from template resolution). Skips store fetch when provided. */
+               preloadedContent?: MCPResourceContent[] | null;
+               class?: string;
+       }
+
+       let { resource, preloadedContent, class: className }: Props = $props();
+
+       let content = $state<MCPResourceContent[] | null>(null);
+       let isLoading = $state(false);
+       let error = $state<string | null>(null);
+
+       $effect(() => {
+               if (resource) {
+                       if (preloadedContent) {
+                               content = preloadedContent;
+                               isLoading = false;
+                               error = null;
+                       } else {
+                               loadContent(resource.uri);
+                       }
+               } else {
+                       content = null;
+                       error = null;
+               }
+       });
+
+       async function loadContent(uri: string) {
+               isLoading = true;
+               error = null;
+
+               try {
+                       const result = await mcpStore.readResource(uri);
+                       if (result) {
+                               content = result;
+                       } else {
+                               error = 'Failed to load resource content';
+                       }
+               } catch (e) {
+                       error = e instanceof Error ? e.message : 'Unknown error';
+               } finally {
+                       isLoading = false;
+               }
+       }
+
+       function handleDownload() {
+               const text = getResourceTextContent(content);
+               if (!text || !resource) return;
+               downloadResourceContent(
+                       text,
+                       resource.mimeType || MimeTypeText.PLAIN,
+                       resource.name || 'resource.txt'
+               );
+       }
+</script>
+
+<div class={cn('flex flex-col gap-3', className)}>
+       {#if !resource}
+               <div class="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
+                       <FileText class="h-8 w-8 opacity-50" />
+
+                       <span class="text-sm">Select a resource to preview</span>
+               </div>
+       {:else}
+               <div class="flex items-start justify-between gap-2">
+                       <div class="min-w-0 flex-1">
+                               <h3 class="truncate font-medium">{resource.title || resource.name}</h3>
+
+                               <p class="truncate text-xs text-muted-foreground">{resource.uri}</p>
+
+                               {#if resource.description}
+                                       <p class="mt-1 text-sm text-muted-foreground">{resource.description}</p>
+                               {/if}
+                       </div>
+
+                       <div class="flex items-center gap-1">
+                               <ActionIconCopyToClipboard
+                                       text={getResourceTextContent(content)}
+                                       canCopy={!isLoading && !!getResourceTextContent(content)}
+                                       ariaLabel="Copy content"
+                               />
+
+                               <Button
+                                       variant="ghost"
+                                       size="sm"
+                                       class="h-7 w-7 p-0"
+                                       onclick={handleDownload}
+                                       disabled={isLoading || !getResourceTextContent(content)}
+                                       title="Download content"
+                               >
+                                       <Download class="h-3.5 w-3.5" />
+                               </Button>
+                       </div>
+               </div>
+
+               <div class="min-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3 break-all">
+                       {#if isLoading}
+                               <div class="flex items-center justify-center py-8">
+                                       <Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
+                               </div>
+                       {:else if error}
+                               <div class="flex flex-col items-center justify-center gap-2 py-8 text-red-500">
+                                       <AlertCircle class="h-6 w-6" />
+
+                                       <span class="text-sm">{error}</span>
+                               </div>
+                       {:else if content}
+                               {@const textContent = getResourceTextContent(content)}
+                               {@const blobContent = getResourceBlobContent(content)}
+
+                               {#if textContent}
+                                       <pre class="font-mono text-xs break-words whitespace-pre-wrap">{textContent}</pre>
+                               {/if}
+
+                               {#each blobContent as blob (blob.uri)}
+                                       {#if isImageMimeType(blob.mimeType ?? MimeTypeApplication.OCTET_STREAM)}
+                                               <img
+                                                       src={createBase64DataUrl(
+                                                               blob.mimeType ?? MimeTypeApplication.OCTET_STREAM,
+                                                               blob.blob
+                                                       )}
+                                                       alt="Resource content"
+                                                       class="max-w-full rounded"
+                                               />
+                                       {:else}
+                                               <div class="flex items-center gap-2 rounded bg-muted p-2 text-sm text-muted-foreground">
+                                                       <FileText class="h-4 w-4" />
+
+                                                       <span>Binary content ({blob.mimeType || 'unknown type'})</span>
+                                               </div>
+                                       {/if}
+                               {/each}
+
+                               {#if !textContent && blobContent.length === 0}
+                                       <div class="py-4 text-center text-sm text-muted-foreground">No content available</div>
+                               {/if}
+                       {/if}
+               </div>
+
+               {#if resource.mimeType || resource.annotations}
+                       <div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
+                               {#if resource.mimeType}
+                                       <span class="rounded bg-muted px-1.5 py-0.5">{resource.mimeType}</span>
+                               {/if}
+
+                               {#if resource.annotations?.priority !== undefined}
+                                       <span class="rounded bg-muted px-1.5 py-0.5">
+                                               Priority: {resource.annotations.priority}
+                                       </span>
+                               {/if}
+
+                               <span class="rounded bg-muted px-1.5 py-0.5">
+                                       Server: {resource.serverName}
+                               </span>
+                       </div>
+               {/if}
+       {/if}
+</div>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceTemplateForm.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourceTemplateForm.svelte
new file mode 100644 (file)
index 0000000..f626325
--- /dev/null
@@ -0,0 +1,171 @@
+<script lang="ts">
+       import { Button } from '$lib/components/ui/button';
+       import { InputWithSuggestions } from '$lib/components/app';
+       import { KeyboardKey } from '$lib/enums';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { MIN_AUTOCOMPLETE_INPUT_LENGTH } from '$lib/constants';
+       import type { MCPResourceTemplateInfo } from '$lib/types';
+       import {
+               debounce,
+               extractTemplateVariables,
+               expandTemplate,
+               isTemplateComplete
+       } from '$lib/utils';
+
+       interface Props {
+               template: MCPResourceTemplateInfo;
+               onResolve: (uri: string, serverName: string) => void;
+               onCancel: () => void;
+       }
+
+       let { template, onResolve, onCancel }: Props = $props();
+
+       const variables = $derived(extractTemplateVariables(template.uriTemplate));
+
+       let values = $state<Record<string, string>>({});
+       let suggestions = $state<Record<string, string[]>>({});
+       let loadingSuggestions = $state<Record<string, boolean>>({});
+       let activeAutocomplete = $state<string | null>(null);
+       let autocompleteIndex = $state(0);
+
+       const expandedUri = $derived(expandTemplate(template.uriTemplate, values));
+       const isComplete = $derived(isTemplateComplete(template.uriTemplate, values));
+
+       const fetchCompletions = debounce(async (argName: string, value: string) => {
+               if (value.length < 1) {
+                       suggestions[argName] = [];
+
+                       return;
+               }
+
+               loadingSuggestions[argName] = true;
+
+               try {
+                       const result = await mcpStore.getResourceCompletions(
+                               template.serverName,
+                               template.uriTemplate,
+                               argName,
+                               value
+                       );
+
+                       if (result && result.values.length > 0) {
+                               const filteredValues = result.values.filter((v) => v.trim() !== '');
+
+                               if (filteredValues.length > 0) {
+                                       suggestions[argName] = filteredValues;
+                                       activeAutocomplete = argName;
+                                       autocompleteIndex = 0;
+                               } else {
+                                       suggestions[argName] = [];
+                               }
+                       } else {
+                               suggestions[argName] = [];
+                       }
+               } catch (error) {
+                       console.error('[McpResourceTemplateForm] Failed to fetch completions:', error);
+                       suggestions[argName] = [];
+               } finally {
+                       loadingSuggestions[argName] = false;
+               }
+       }, 200);
+
+       function handleArgInput(argName: string, value: string) {
+               values[argName] = value;
+               fetchCompletions(argName, value);
+       }
+
+       function selectSuggestion(argName: string, value: string) {
+               values[argName] = value;
+               suggestions[argName] = [];
+               activeAutocomplete = null;
+       }
+
+       function handleArgKeydown(event: KeyboardEvent, argName: string) {
+               const argSuggestions = suggestions[argName] ?? [];
+
+               if (event.key === KeyboardKey.ESCAPE) {
+                       event.preventDefault();
+                       event.stopPropagation();
+
+                       if (argSuggestions.length > 0 && activeAutocomplete === argName) {
+                               suggestions[argName] = [];
+                               activeAutocomplete = null;
+                       } else {
+                               onCancel();
+                       }
+
+                       return;
+               }
+
+               if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
+
+               if (event.key === KeyboardKey.ARROW_DOWN) {
+                       event.preventDefault();
+                       autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
+               } else if (event.key === KeyboardKey.ARROW_UP) {
+                       event.preventDefault();
+                       autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
+               } else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
+                       event.preventDefault();
+                       event.stopPropagation();
+                       selectSuggestion(argName, argSuggestions[autocompleteIndex]);
+               }
+       }
+
+       function handleArgBlur(argName: string) {
+               setTimeout(() => {
+                       if (activeAutocomplete === argName) {
+                               suggestions[argName] = [];
+                               activeAutocomplete = null;
+                       }
+               }, 150);
+       }
+
+       function handleArgFocus(argName: string) {
+               const value = values[argName] ?? '';
+
+               if (value.length >= MIN_AUTOCOMPLETE_INPUT_LENGTH) {
+                       fetchCompletions(argName, value);
+               }
+       }
+
+       function handleSubmit(event: SubmitEvent) {
+               event.preventDefault();
+
+               if (isComplete) {
+                       onResolve(expandedUri, template.serverName);
+               }
+       }
+</script>
+
+<form onsubmit={handleSubmit} class="space-y-3">
+       {#each variables as variable (variable.name)}
+               <InputWithSuggestions
+                       name={variable.name}
+                       value={values[variable.name] ?? ''}
+                       suggestions={suggestions[variable.name] ?? []}
+                       isLoadingSuggestions={loadingSuggestions[variable.name] ?? false}
+                       isAutocompleteActive={activeAutocomplete === variable.name}
+                       autocompleteIndex={activeAutocomplete === variable.name ? autocompleteIndex : 0}
+                       onInput={(value) => handleArgInput(variable.name, value)}
+                       onKeydown={(e) => handleArgKeydown(e, variable.name)}
+                       onBlur={() => handleArgBlur(variable.name)}
+                       onFocus={() => handleArgFocus(variable.name)}
+                       onSelectSuggestion={(value) => selectSuggestion(variable.name, value)}
+               />
+       {/each}
+
+       {#if isComplete}
+               <div class="rounded-md bg-muted/50 px-3 py-2">
+                       <p class="text-xs text-muted-foreground">Resolved URI:</p>
+
+                       <p class="mt-0.5 font-mono text-xs break-all">{expandedUri}</p>
+               </div>
+       {/if}
+
+       <div class="flex justify-end gap-2 pt-1">
+               <Button type="button" size="sm" variant="secondary" onclick={onCancel}>Cancel</Button>
+
+               <Button size="sm" type="submit" disabled={!isComplete}>Read Resource</Button>
+       </div>
+</form>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte
new file mode 100644 (file)
index 0000000..6a7b563
--- /dev/null
@@ -0,0 +1,192 @@
+<script lang="ts">
+       import { tick } from 'svelte';
+       import * as Card from '$lib/components/ui/card';
+       import { Skeleton } from '$lib/components/ui/skeleton';
+       import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
+       import { HealthCheckStatus } from '$lib/enums';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import {
+               McpServerCardActions,
+               McpServerCardDeleteDialog,
+               McpServerCardEditForm,
+               McpServerCardHeader,
+               McpServerCardToolsList,
+               McpConnectionLogs,
+               McpServerInfo
+       } from '$lib/components/app/mcp';
+
+       interface Props {
+               server: MCPServerSettingsEntry;
+               faviconUrl: string | null;
+               enabled?: boolean;
+               onToggle: (enabled: boolean) => void;
+               onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
+               onDelete: () => void;
+       }
+
+       let { server, faviconUrl, enabled, onToggle, onUpdate, onDelete }: Props = $props();
+
+       let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
+       let displayName = $derived(mcpStore.getServerLabel(server));
+       let isIdle = $derived(healthState.status === HealthCheckStatus.IDLE);
+       let isHealthChecking = $derived(healthState.status === HealthCheckStatus.CONNECTING);
+       let isConnected = $derived(healthState.status === HealthCheckStatus.SUCCESS);
+       let isError = $derived(healthState.status === HealthCheckStatus.ERROR);
+       let showSkeleton = $derived(isIdle || isHealthChecking);
+       let errorMessage = $derived(
+               healthState.status === HealthCheckStatus.ERROR ? healthState.message : undefined
+       );
+       let tools = $derived(healthState.status === HealthCheckStatus.SUCCESS ? healthState.tools : []);
+
+       let connectionLogs = $derived(
+               healthState.status === HealthCheckStatus.CONNECTING ||
+                       healthState.status === HealthCheckStatus.SUCCESS ||
+                       healthState.status === HealthCheckStatus.ERROR
+                       ? healthState.logs
+                       : []
+       );
+
+       let successState = $derived(
+               healthState.status === HealthCheckStatus.SUCCESS ? healthState : null
+       );
+       let serverInfo = $derived(successState?.serverInfo);
+       let capabilities = $derived(successState?.capabilities);
+       let transportType = $derived(successState?.transportType);
+       let protocolVersion = $derived(successState?.protocolVersion);
+       let connectionTimeMs = $derived(successState?.connectionTimeMs);
+       let instructions = $derived(successState?.instructions);
+
+       let isEditing = $derived(!server.url.trim());
+       let showDeleteDialog = $state(false);
+       let editFormRef: McpServerCardEditForm | null = $state(null);
+
+       function handleHealthCheck() {
+               mcpStore.runHealthCheck(server);
+       }
+
+       async function startEditing() {
+               isEditing = true;
+               await tick();
+               editFormRef?.setInitialValues(server.url, server.headers || '', server.useProxy || false);
+       }
+
+       function cancelEditing() {
+               if (server.url.trim()) {
+                       isEditing = false;
+               } else {
+                       onDelete();
+               }
+       }
+
+       function saveEditing(url: string, headers: string, useProxy: boolean) {
+               onUpdate({
+                       url: url,
+                       headers: headers || undefined,
+                       useProxy: useProxy
+               });
+               isEditing = false;
+
+               if (server.enabled && url) {
+                       setTimeout(() => mcpStore.runHealthCheck({ ...server, url, useProxy }), 100);
+               }
+       }
+
+       function handleDeleteClick() {
+               showDeleteDialog = true;
+       }
+</script>
+
+<Card.Root class="!gap-3 bg-muted/30 p-4">
+       {#if isEditing}
+               <McpServerCardEditForm
+                       bind:this={editFormRef}
+                       serverId={server.id}
+                       serverUrl={server.url}
+                       serverUseProxy={server.useProxy}
+                       onSave={saveEditing}
+                       onCancel={cancelEditing}
+               />
+       {:else}
+               <McpServerCardHeader
+                       {displayName}
+                       {faviconUrl}
+                       enabled={enabled ?? server.enabled}
+                       disabled={isError}
+                       {onToggle}
+                       {serverInfo}
+                       {capabilities}
+                       {transportType}
+               />
+
+               {#if isError && errorMessage}
+                       <p class="text-xs text-destructive">{errorMessage}</p>
+               {/if}
+
+               {#if isConnected && serverInfo?.description}
+                       <p class="line-clamp-2 text-xs text-muted-foreground">
+                               {serverInfo.description}
+                       </p>
+               {/if}
+
+               <div class="grid gap-3">
+                       {#if showSkeleton}
+                               <div class="space-y-2">
+                                       <div class="flex items-center gap-2">
+                                               <Skeleton class="h-4 w-4 rounded" />
+                                               <Skeleton class="h-3 w-24" />
+                                       </div>
+                                       <div class="flex flex-wrap gap-1.5">
+                                               <Skeleton class="h-5 w-16 rounded-full" />
+                                               <Skeleton class="h-5 w-20 rounded-full" />
+                                               <Skeleton class="h-5 w-14 rounded-full" />
+                                       </div>
+                               </div>
+
+                               <div class="space-y-1.5">
+                                       <div class="flex items-center gap-2">
+                                               <Skeleton class="h-4 w-4 rounded" />
+                                               <Skeleton class="h-3 w-32" />
+                                       </div>
+                               </div>
+                       {:else}
+                               {#if isConnected && instructions}
+                                       <McpServerInfo {instructions} />
+                               {/if}
+
+                               {#if tools.length > 0}
+                                       <McpServerCardToolsList {tools} />
+                               {/if}
+
+                               {#if connectionLogs.length > 0}
+                                       <McpConnectionLogs logs={connectionLogs} {connectionTimeMs} />
+                               {/if}
+                       {/if}
+               </div>
+
+               <div class="flex justify-between gap-4">
+                       {#if showSkeleton}
+                               <Skeleton class="h-3 w-28" />
+                       {:else if protocolVersion}
+                               <div class="flex flex-wrap items-center gap-1">
+                                       <span class="text-[10px] text-muted-foreground">
+                                               Protocol version: {protocolVersion}
+                                       </span>
+                               </div>
+                       {/if}
+
+                       <McpServerCardActions
+                               {isHealthChecking}
+                               onEdit={startEditing}
+                               onRefresh={handleHealthCheck}
+                               onDelete={handleDeleteClick}
+                       />
+               </div>
+       {/if}
+</Card.Root>
+
+<McpServerCardDeleteDialog
+       bind:open={showDeleteDialog}
+       {displayName}
+       onOpenChange={(open) => (showDeleteDialog = open)}
+       onConfirm={onDelete}
+/>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardActions.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardActions.svelte
new file mode 100644 (file)
index 0000000..6f137fa
--- /dev/null
@@ -0,0 +1,40 @@
+<script lang="ts">
+       import { Trash2, RefreshCw, Pencil } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+
+       interface Props {
+               isHealthChecking: boolean;
+               onEdit: () => void;
+               onRefresh: () => void;
+               onDelete: () => void;
+       }
+
+       let { isHealthChecking, onEdit, onRefresh, onDelete }: Props = $props();
+</script>
+
+<div class="flex shrink-0 items-center gap-1">
+       <Button variant="ghost" size="icon" class="h-7 w-7" onclick={onEdit} aria-label="Edit">
+               <Pencil class="h-3.5 w-3.5" />
+       </Button>
+
+       <Button
+               variant="ghost"
+               size="icon"
+               class="h-7 w-7"
+               onclick={onRefresh}
+               disabled={isHealthChecking}
+               aria-label="Refresh"
+       >
+               <RefreshCw class="h-3.5 w-3.5" />
+       </Button>
+
+       <Button
+               variant="ghost"
+               size="icon"
+               class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive/10"
+               onclick={onDelete}
+               aria-label="Delete"
+       >
+               <Trash2 class="h-3.5 w-3.5" />
+       </Button>
+</div>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardDeleteDialog.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardDeleteDialog.svelte
new file mode 100644 (file)
index 0000000..8f65014
--- /dev/null
@@ -0,0 +1,36 @@
+<script lang="ts">
+       import * as AlertDialog from '$lib/components/ui/alert-dialog';
+
+       interface Props {
+               open: boolean;
+               displayName: string;
+               onOpenChange: (open: boolean) => void;
+               onConfirm: () => void;
+       }
+
+       let { open = $bindable(), displayName, onOpenChange, onConfirm }: Props = $props();
+</script>
+
+<AlertDialog.Root bind:open {onOpenChange}>
+       <AlertDialog.Content>
+               <AlertDialog.Header>
+                       <AlertDialog.Title>Delete Server</AlertDialog.Title>
+
+                       <AlertDialog.Description>
+                               Are you sure you want to delete <strong>{displayName}</strong>? This action cannot be
+                               undone.
+                       </AlertDialog.Description>
+               </AlertDialog.Header>
+
+               <AlertDialog.Footer>
+                       <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
+
+                       <AlertDialog.Action
+                               class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
+                               onclick={onConfirm}
+                       >
+                               Delete
+                       </AlertDialog.Action>
+               </AlertDialog.Footer>
+       </AlertDialog.Content>
+</AlertDialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte
new file mode 100644 (file)
index 0000000..6727a90
--- /dev/null
@@ -0,0 +1,64 @@
+<script lang="ts">
+       import { Button } from '$lib/components/ui/button';
+       import { McpServerForm } from '$lib/components/app/mcp';
+
+       interface Props {
+               serverId: string;
+               serverUrl: string;
+               serverUseProxy?: boolean;
+               onSave: (url: string, headers: string, useProxy: boolean) => void;
+               onCancel: () => void;
+       }
+
+       let { serverId, serverUrl, serverUseProxy = false, onSave, onCancel }: Props = $props();
+
+       let editUrl = $derived(serverUrl);
+       let editHeaders = $state('');
+       let editUseProxy = $derived(serverUseProxy);
+
+       let urlError = $derived.by(() => {
+               if (!editUrl.trim()) return 'URL is required';
+               try {
+                       new URL(editUrl);
+                       return null;
+               } catch {
+                       return 'Invalid URL format';
+               }
+       });
+
+       let canSave = $derived(!urlError);
+
+       function handleSave() {
+               if (!canSave) return;
+               onSave(editUrl.trim(), editHeaders.trim(), editUseProxy);
+       }
+
+       export function setInitialValues(url: string, headers: string, useProxy: boolean) {
+               editUrl = url;
+               editHeaders = headers;
+               editUseProxy = useProxy;
+       }
+</script>
+
+<div class="space-y-4">
+       <p class="font-medium">Configure Server</p>
+
+       <McpServerForm
+               url={editUrl}
+               headers={editHeaders}
+               useProxy={editUseProxy}
+               onUrlChange={(v) => (editUrl = v)}
+               onHeadersChange={(v) => (editHeaders = v)}
+               onUseProxyChange={(v) => (editUseProxy = v)}
+               urlError={editUrl ? urlError : null}
+               id={serverId}
+       />
+
+       <div class="flex items-center justify-end gap-2">
+               <Button variant="secondary" size="sm" onclick={onCancel}>Cancel</Button>
+
+               <Button size="sm" onclick={handleSave} disabled={!canSave}>
+                       {serverUrl.trim() ? 'Update' : 'Add'}
+               </Button>
+       </div>
+</div>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardHeader.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardHeader.svelte
new file mode 100644 (file)
index 0000000..5af45bb
--- /dev/null
@@ -0,0 +1,97 @@
+<script lang="ts">
+       import { Cable, ExternalLink } from '@lucide/svelte';
+       import { Switch } from '$lib/components/ui/switch';
+       import { Badge } from '$lib/components/ui/badge';
+       import { McpCapabilitiesBadges } from '$lib/components/app/mcp';
+       import { MCP_TRANSPORT_LABELS, MCP_TRANSPORT_ICONS } from '$lib/constants';
+       import { MCPTransportType } from '$lib/enums';
+       import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
+
+       interface Props {
+               displayName: string;
+               faviconUrl: string | null;
+               enabled: boolean;
+               disabled?: boolean;
+               onToggle: (enabled: boolean) => void;
+               serverInfo?: MCPServerInfo;
+               capabilities?: MCPCapabilitiesInfo;
+               transportType?: MCPTransportType;
+       }
+
+       let {
+               displayName,
+               faviconUrl,
+               enabled,
+               disabled = false,
+               onToggle,
+               serverInfo,
+               capabilities,
+               transportType
+       }: Props = $props();
+</script>
+
+<div class="space-y-3">
+       <div class="flex items-start justify-between gap-3">
+               <div class="grid min-w-0 gap-3">
+                       <div class="flex items-center gap-2 overflow-hidden">
+                               {#if faviconUrl}
+                                       <img
+                                               src={faviconUrl}
+                                               alt=""
+                                               class="h-5 w-5 shrink-0 rounded"
+                                               onerror={(e) => {
+                                                       (e.currentTarget as HTMLImageElement).style.display = 'none';
+                                               }}
+                                       />
+                               {:else}
+                                       <div class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted">
+                                               <Cable class="h-3 w-3 text-muted-foreground" />
+                                       </div>
+                               {/if}
+
+                               <p class="min-w-0 shrink-0 truncate leading-none font-medium">{displayName}</p>
+
+                               {#if serverInfo?.version}
+                                       <Badge variant="secondary" class="h-4 min-w-0 truncate px-1 text-[10px]">
+                                               v{serverInfo.version}
+                                       </Badge>
+                               {/if}
+
+                               {#if serverInfo?.websiteUrl}
+                                       <a
+                                               href={serverInfo.websiteUrl}
+                                               target="_blank"
+                                               rel="noopener noreferrer"
+                                               class="shrink-0 text-muted-foreground hover:text-foreground"
+                                               aria-label="Open website"
+                                       >
+                                               <ExternalLink class="h-3 w-3" />
+                                       </a>
+                               {/if}
+                       </div>
+
+                       {#if capabilities || transportType}
+                               <div class="flex flex-wrap items-center gap-1">
+                                       {#if transportType}
+                                               {@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]}
+                                               <Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">
+                                                       {#if TransportIcon}
+                                                               <TransportIcon class="h-3 w-3" />
+                                                       {/if}
+
+                                                       {MCP_TRANSPORT_LABELS[transportType] || transportType}
+                                               </Badge>
+                                       {/if}
+
+                                       {#if capabilities}
+                                               <McpCapabilitiesBadges {capabilities} />
+                                       {/if}
+                               </div>
+                       {/if}
+               </div>
+
+               <div class="flex shrink-0 items-center pl-2">
+                       <Switch checked={enabled} {disabled} onCheckedChange={onToggle} />
+               </div>
+       </div>
+</div>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardToolsList.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardToolsList.svelte
new file mode 100644 (file)
index 0000000..d0397c1
--- /dev/null
@@ -0,0 +1,47 @@
+<script lang="ts">
+       import { ChevronDown, ChevronRight } from '@lucide/svelte';
+       import * as Collapsible from '$lib/components/ui/collapsible';
+       import { Badge } from '$lib/components/ui/badge';
+
+       interface Tool {
+               name: string;
+               description?: string;
+       }
+
+       interface Props {
+               tools: Tool[];
+       }
+
+       let { tools }: Props = $props();
+
+       let isExpanded = $state(false);
+       let toolsCount = $derived(tools.length);
+</script>
+
+<Collapsible.Root bind:open={isExpanded}>
+       <Collapsible.Trigger
+               class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+       >
+               {#if isExpanded}
+                       <ChevronDown class="h-3.5 w-3.5" />
+               {:else}
+                       <ChevronRight class="h-3.5 w-3.5" />
+               {/if}
+
+               <span>{toolsCount} tools available · Show details</span>
+       </Collapsible.Trigger>
+
+       <Collapsible.Content class="mt-2">
+               <div class="max-h-64 space-y-3 overflow-y-auto">
+                       {#each tools as tool (tool.name)}
+                               <div>
+                                       <Badge variant="secondary">{tool.name}</Badge>
+
+                                       {#if tool.description}
+                                               <p class="mt-1 text-xs text-muted-foreground">{tool.description}</p>
+                                       {/if}
+                               </div>
+                       {/each}
+               </div>
+       </Collapsible.Content>
+</Collapsible.Root>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCardSkeleton.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCardSkeleton.svelte
new file mode 100644 (file)
index 0000000..39a1372
--- /dev/null
@@ -0,0 +1,34 @@
+<script lang="ts">
+       import * as Card from '$lib/components/ui/card';
+       import { Skeleton } from '$lib/components/ui/skeleton';
+</script>
+
+<Card.Root class="grid gap-3 p-4">
+       <div class="flex items-center justify-between gap-4">
+               <div class="flex items-center gap-2">
+                       <Skeleton class="h-5 w-5 rounded" />
+                       <Skeleton class="h-5 w-28" />
+                       <Skeleton class="h-5 w-12 rounded-full" />
+               </div>
+               <Skeleton class="h-6 w-11 rounded-full" />
+       </div>
+
+       <div class="flex flex-wrap gap-1.5">
+               <Skeleton class="h-5 w-14 rounded-full" />
+               <Skeleton class="h-5 w-12 rounded-full" />
+               <Skeleton class="h-5 w-16 rounded-full" />
+       </div>
+
+       <div class="space-y-1.5">
+               <Skeleton class="h-4 w-40" />
+               <Skeleton class="h-4 w-52" />
+       </div>
+
+       <Skeleton class="h-3.5 w-36" />
+
+       <div class="flex justify-end gap-2">
+               <Skeleton class="h-8 w-8 rounded" />
+               <Skeleton class="h-8 w-8 rounded" />
+               <Skeleton class="h-8 w-8 rounded" />
+       </div>
+</Card.Root>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte
new file mode 100644 (file)
index 0000000..518311f
--- /dev/null
@@ -0,0 +1,88 @@
+<script lang="ts">
+       import { Input } from '$lib/components/ui/input';
+       import { Switch } from '$lib/components/ui/switch';
+       import { KeyValuePairs } from '$lib/components/app';
+       import type { KeyValuePair } from '$lib/types';
+       import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
+       import { UrlProtocol } from '$lib/enums';
+       import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants';
+
+       interface Props {
+               url: string;
+               headers: string;
+               useProxy?: boolean;
+               onUrlChange: (url: string) => void;
+               onHeadersChange: (headers: string) => void;
+               onUseProxyChange?: (useProxy: boolean) => void;
+               urlError?: string | null;
+               id?: string;
+       }
+
+       let {
+               url,
+               headers,
+               useProxy = false,
+               onUrlChange,
+               onHeadersChange,
+               onUseProxyChange,
+               urlError = null,
+               id = 'server'
+       }: Props = $props();
+
+       let isWebSocket = $derived(
+               url.toLowerCase().startsWith(UrlProtocol.WEBSOCKET) ||
+                       url.toLowerCase().startsWith(UrlProtocol.WEBSOCKET_SECURE)
+       );
+
+       let headerPairs = $derived<KeyValuePair[]>(parseHeadersToArray(headers));
+
+       function updateHeaderPairs(newPairs: KeyValuePair[]) {
+               headerPairs = newPairs;
+               onHeadersChange(serializeHeaders(newPairs));
+       }
+</script>
+
+<div class="grid gap-3">
+       <div>
+               <label for="server-url-{id}" class="mb-2 block text-xs font-medium">
+                       Server URL <span class="text-destructive">*</span>
+               </label>
+
+               <Input
+                       id="server-url-{id}"
+                       type="url"
+                       placeholder={MCP_SERVER_URL_PLACEHOLDER}
+                       value={url}
+                       oninput={(e) => onUrlChange(e.currentTarget.value)}
+                       class={urlError ? 'border-destructive' : ''}
+               />
+
+               {#if urlError}
+                       <p class="mt-1.5 text-xs text-destructive">{urlError}</p>
+               {/if}
+
+               {#if !isWebSocket && onUseProxyChange}
+                       <label class="mt-3 flex cursor-pointer items-center gap-2">
+                               <Switch
+                                       id="use-proxy-{id}"
+                                       checked={useProxy}
+                                       onCheckedChange={(checked) => onUseProxyChange?.(checked)}
+                               />
+
+                               <span class="text-xs text-muted-foreground">Use llama-server proxy</span>
+                       </label>
+               {/if}
+       </div>
+
+       <KeyValuePairs
+               class="mt-2"
+               pairs={headerPairs}
+               onPairsChange={updateHeaderPairs}
+               keyPlaceholder="Header name"
+               valuePlaceholder="Value"
+               addButtonLabel="Add"
+               emptyMessage="No custom headers configured."
+               sectionLabel="Custom Headers"
+               sectionLabelOptional
+       />
+</div>
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerInfo.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerInfo.svelte
new file mode 100644 (file)
index 0000000..aecae6e
--- /dev/null
@@ -0,0 +1,35 @@
+<script lang="ts">
+       import { ChevronDown, ChevronRight } from '@lucide/svelte';
+       import * as Collapsible from '$lib/components/ui/collapsible';
+
+       interface Props {
+               instructions?: string;
+               class?: string;
+       }
+
+       let { instructions, class: className }: Props = $props();
+
+       let isExpanded = $state(false);
+</script>
+
+{#if instructions}
+       <Collapsible.Root bind:open={isExpanded} class={className}>
+               <Collapsible.Trigger
+                       class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+               >
+                       {#if isExpanded}
+                               <ChevronDown class="h-3.5 w-3.5" />
+                       {:else}
+                               <ChevronRight class="h-3.5 w-3.5" />
+                       {/if}
+
+                       <span>Server instructions</span>
+               </Collapsible.Trigger>
+
+               <Collapsible.Content class="mt-2">
+                       <p class="rounded bg-muted/50 p-2 text-xs text-muted-foreground">
+                               {instructions}
+                       </p>
+               </Collapsible.Content>
+       </Collapsible.Root>
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServersSelector.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServersSelector.svelte
new file mode 100644 (file)
index 0000000..ef701a5
--- /dev/null
@@ -0,0 +1,160 @@
+<script lang="ts">
+       import { Settings } from '@lucide/svelte';
+       import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+       import { Switch } from '$lib/components/ui/switch';
+       import { DropdownMenuSearchable, McpActiveServersAvatars } from '$lib/components/app';
+       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { HealthCheckStatus } from '$lib/enums';
+       import type { MCPServerSettingsEntry } from '$lib/types';
+
+       interface Props {
+               class?: string;
+               disabled?: boolean;
+               onSettingsClick?: () => void;
+       }
+
+       let { class: className = '', disabled = false, onSettingsClick }: Props = $props();
+
+       let searchQuery = $state('');
+       let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
+       let hasMcpServers = $derived(mcpServers.length > 0);
+       let enabledMcpServersForChat = $derived(
+               mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
+       );
+       let healthyEnabledMcpServers = $derived(
+               enabledMcpServersForChat.filter((s) => {
+                       const healthState = mcpStore.getHealthCheckState(s.id);
+                       return healthState.status !== HealthCheckStatus.ERROR;
+               })
+       );
+       let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
+       let mcpFavicons = $derived(
+               healthyEnabledMcpServers
+                       .slice(0, 3)
+                       .map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
+                       .filter((f) => f.url !== null)
+       );
+       let filteredMcpServers = $derived.by(() => {
+               const query = searchQuery.toLowerCase().trim();
+               if (query) {
+                       return mcpServers.filter((s) => {
+                               const name = getServerLabel(s).toLowerCase();
+                               const url = s.url.toLowerCase();
+                               return name.includes(query) || url.includes(query);
+                       });
+               }
+               return mcpServers;
+       });
+
+       function getServerLabel(server: MCPServerSettingsEntry): string {
+               return mcpStore.getServerLabel(server);
+       }
+
+       function handleDropdownOpen(open: boolean) {
+               if (open) {
+                       mcpStore.runHealthChecksForServers(mcpServers);
+               }
+       }
+
+       function isServerEnabledForChat(serverId: string): boolean {
+               return conversationsStore.isMcpServerEnabledForChat(serverId);
+       }
+
+       async function toggleServerForChat(serverId: string) {
+               await conversationsStore.toggleMcpServerForChat(serverId);
+       }
+</script>
+
+{#if hasMcpServers && hasEnabledMcpServers && mcpFavicons.length > 0}
+       <DropdownMenu.Root
+               onOpenChange={(open) => {
+                       if (!open) {
+                               searchQuery = '';
+                       }
+                       handleDropdownOpen(open);
+               }}
+       >
+               <DropdownMenu.Trigger
+                       {disabled}
+                       onclick={(e) => {
+                               e.preventDefault();
+                               e.stopPropagation();
+                       }}
+               >
+                       <button
+                               type="button"
+                               class="inline-flex cursor-pointer items-center rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
+                               {disabled}
+                               aria-label="MCP Servers"
+                       >
+                               <McpActiveServersAvatars class={className} />
+                       </button>
+               </DropdownMenu.Trigger>
+
+               <DropdownMenu.Content align="start" class="w-72 pt-0">
+                       <DropdownMenuSearchable
+                               bind:searchValue={searchQuery}
+                               placeholder="Search servers..."
+                               emptyMessage="No servers found"
+                               isEmpty={filteredMcpServers.length === 0}
+                       >
+                               <div class="max-h-64 overflow-y-auto">
+                                       {#each filteredMcpServers as server (server.id)}
+                                               {@const healthState = mcpStore.getHealthCheckState(server.id)}
+                                               {@const hasError = healthState.status === HealthCheckStatus.ERROR}
+                                               {@const isEnabledForChat = isServerEnabledForChat(server.id)}
+
+                                               <button
+                                                       type="button"
+                                                       class="flex w-full items-center justify-between gap-2 px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
+                                                       onclick={() => !hasError && toggleServerForChat(server.id)}
+                                                       disabled={hasError}
+                                               >
+                                                       <div class="flex min-w-0 flex-1 items-center gap-2">
+                                                               {#if mcpStore.getServerFavicon(server.id)}
+                                                                       <img
+                                                                               src={mcpStore.getServerFavicon(server.id)}
+                                                                               alt=""
+                                                                               class="h-4 w-4 shrink-0 rounded-sm"
+                                                                               onerror={(e) => {
+                                                                                       (e.currentTarget as HTMLImageElement).style.display = 'none';
+                                                                               }}
+                                                                       />
+                                                               {/if}
+
+                                                               <span class="truncate text-sm">{getServerLabel(server)}</span>
+
+                                                               {#if hasError}
+                                                                       <span
+                                                                               class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
+                                                                       >
+                                                                               Error
+                                                                       </span>
+                                                               {/if}
+                                                       </div>
+
+                                                       <Switch
+                                                               checked={isEnabledForChat}
+                                                               disabled={hasError}
+                                                               onclick={(e: MouseEvent) => e.stopPropagation()}
+                                                               onCheckedChange={() => toggleServerForChat(server.id)}
+                                                       />
+                                               </button>
+                                       {/each}
+                               </div>
+
+                               {#snippet footer()}
+                                       <DropdownMenu.Item
+                                               class="flex cursor-pointer items-center gap-2"
+                                               onclick={onSettingsClick}
+                                       >
+                                               <Settings class="h-4 w-4" />
+
+                                               <span>Manage MCP Servers</span>
+                                       </DropdownMenu.Item>
+                               {/snippet}
+                       </DropdownMenuSearchable>
+               </DropdownMenu.Content>
+       </DropdownMenu.Root>
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServersSettings.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServersSettings.svelte
new file mode 100644 (file)
index 0000000..85232b5
--- /dev/null
@@ -0,0 +1,150 @@
+<script lang="ts">
+       import { Plus } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import * as Card from '$lib/components/ui/card';
+       import { uuid } from '$lib/utils';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import { McpServerCard, McpServerCardSkeleton, McpServerForm } from '$lib/components/app/mcp';
+       import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
+       import { HealthCheckStatus } from '$lib/enums';
+
+       let servers = $derived(mcpStore.getServersSorted());
+
+       let initialLoadComplete = $state(false);
+
+       $effect(() => {
+               if (initialLoadComplete) return;
+
+               const allChecked =
+                       servers.length > 0 &&
+                       servers.every((server) => {
+                               const state = mcpStore.getHealthCheckState(server.id);
+
+                               return (
+                                       state.status === HealthCheckStatus.SUCCESS || state.status === HealthCheckStatus.ERROR
+                               );
+                       });
+
+               if (allChecked) {
+                       initialLoadComplete = true;
+               }
+       });
+
+       let isAddingServer = $state(false);
+       let newServerUrl = $state('');
+       let newServerHeaders = $state('');
+       let newServerUrlError = $derived.by(() => {
+               if (!newServerUrl.trim()) return 'URL is required';
+               try {
+                       new URL(newServerUrl);
+
+                       return null;
+               } catch {
+                       return 'Invalid URL format';
+               }
+       });
+
+       function showAddServerForm() {
+               isAddingServer = true;
+               newServerUrl = '';
+               newServerHeaders = '';
+       }
+
+       function cancelAddServer() {
+               isAddingServer = false;
+               newServerUrl = '';
+               newServerHeaders = '';
+       }
+
+       function saveNewServer() {
+               if (newServerUrlError) return;
+
+               const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
+
+               mcpStore.addServer({
+                       id: newServerId,
+                       enabled: true,
+                       url: newServerUrl.trim(),
+                       headers: newServerHeaders.trim() || undefined
+               });
+
+               conversationsStore.setMcpServerOverride(newServerId, true);
+
+               isAddingServer = false;
+               newServerUrl = '';
+               newServerHeaders = '';
+       }
+</script>
+
+<div class="space-y-5 md:space-y-4">
+       <div class="flex items-start justify-between gap-4">
+               <div>
+                       <h4 class="text-base font-semibold">Manage Servers</h4>
+               </div>
+
+               {#if !isAddingServer}
+                       <Button variant="outline" size="sm" class="shrink-0" onclick={showAddServerForm}>
+                               <Plus class="h-4 w-4" />
+
+                               Add New Server
+                       </Button>
+               {/if}
+       </div>
+
+       {#if isAddingServer}
+               <Card.Root class="bg-muted/30 p-4">
+                       <div class="space-y-4">
+                               <p class="font-medium">Add New Server</p>
+
+                               <McpServerForm
+                                       url={newServerUrl}
+                                       headers={newServerHeaders}
+                                       onUrlChange={(v) => (newServerUrl = v)}
+                                       onHeadersChange={(v) => (newServerHeaders = v)}
+                                       urlError={newServerUrl ? newServerUrlError : null}
+                                       id="new-server"
+                               />
+
+                               <div class="flex items-center justify-end gap-2">
+                                       <Button variant="secondary" size="sm" onclick={cancelAddServer}>Cancel</Button>
+
+                                       <Button
+                                               variant="default"
+                                               size="sm"
+                                               onclick={saveNewServer}
+                                               disabled={!!newServerUrlError}
+                                               aria-label="Save"
+                                       >
+                                               Add
+                                       </Button>
+                               </div>
+                       </div>
+               </Card.Root>
+       {/if}
+
+       {#if servers.length === 0 && !isAddingServer}
+               <div class="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
+                       No MCP Servers configured yet. Add one to enable agentic features.
+               </div>
+       {/if}
+
+       {#if servers.length > 0}
+               <div class="space-y-3">
+                       {#each servers as server (server.id)}
+                               {#if !initialLoadComplete}
+                                       <McpServerCardSkeleton />
+                               {:else}
+                                       <McpServerCard
+                                               {server}
+                                               faviconUrl={mcpStore.getServerFavicon(server.id)}
+                                               enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
+                                               onToggle={async () => await conversationsStore.toggleMcpServerForChat(server.id)}
+                                               onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
+                                               onDelete={() => mcpStore.removeServer(server.id)}
+                                       />
+                               {/if}
+                       {/each}
+               </div>
+       {/if}
+</div>
diff --git a/tools/server/webui/src/lib/components/app/mcp/index.ts b/tools/server/webui/src/lib/components/app/mcp/index.ts
new file mode 100644 (file)
index 0000000..6ab262a
--- /dev/null
@@ -0,0 +1,262 @@
+/**
+ *
+ * MCP (Model Context Protocol)
+ *
+ * Components for managing MCP server connections and displaying server status.
+ * MCP enables agentic workflows by connecting to external tool servers.
+ *
+ * The MCP system integrates with:
+ * - `mcpStore` for server CRUD operations and health checks
+ * - `conversationsStore` for per-conversation server enable/disable
+ *
+ */
+
+/**
+ * **McpServersSettings** - MCP servers configuration section
+ *
+ * Settings section for configuring MCP server connections.
+ * Displays server cards with status, tools, and management actions.
+ * Used within the MCP tab of ChatSettings.
+ *
+ * **Architecture:**
+ * - Manages add server form state locally
+ * - Delegates server display to McpServerCard components
+ * - Integrates with mcpStore for server operations
+ * - Shows skeleton loading states during health checks
+ *
+ * **Features:**
+ * - Add new MCP servers by URL with validation
+ * - Server cards with connection status indicators
+ * - Health check status (connected/disconnected/error)
+ * - Tools list per server showing available capabilities
+ * - Enable/disable toggle per conversation
+ * - Edit/delete server actions
+ * - Skeleton loading states during connection
+ * - Empty state with helpful message
+ *
+ * @example
+ * ```svelte
+ * <McpServersSettings />
+ * ```
+ */
+export { default as McpServersSettings } from './McpServersSettings.svelte';
+
+/**
+ * **McpActiveServersAvatars** - Active MCP servers indicator
+ *
+ * Compact avatar row showing favicons of active MCP servers.
+ * Displays up to 3 server icons with "+N" counter for additional servers.
+ * Clickable to open MCP settings dialog.
+ *
+ * **Architecture:**
+ * - Filters servers by enabled status and health check
+ * - Fetches favicons from server URLs
+ * - Integrates with conversationsStore for per-chat server state
+ *
+ * **Features:**
+ * - Overlapping favicon avatars (max 3 visible)
+ * - "+N" counter for additional servers
+ * - Click handler for settings navigation
+ * - Disabled state support
+ * - Only shows healthy, enabled servers
+ *
+ * @example
+ * ```svelte
+ * <McpActiveServersAvatars
+ *   onSettingsClick={() => showMcpSettings = true}
+ * />
+ * ```
+ */
+export { default as McpActiveServersAvatars } from './McpActiveServersAvatars.svelte';
+
+/**
+ * **McpServersSelector** - Quick MCP server toggle dropdown
+ *
+ * Compact dropdown for quickly enabling/disabling MCP servers for the current chat.
+ * Uses McpActiveServersAvatars as trigger and shows searchable server list with switches.
+ *
+ * **Architecture:**
+ * - Uses DropdownMenuSearchable for searchable dropdown UI
+ * - McpActiveServersAvatars as the trigger element
+ * - Integrates with conversationsStore for per-chat toggle
+ * - Runs health checks on dropdown open
+ *
+ * **Features:**
+ * - Searchable server list by name/URL
+ * - Switch toggles matching McpServersSettings behavior
+ * - Error state display for unhealthy servers
+ * - Footer link to full MCP settings dialog
+ *
+ * @example
+ * ```svelte
+ * <McpServersSelector
+ *   onSettingsClick={() => showMcpSettings = true}
+ * />
+ * ```
+ */
+export { default as McpServersSelector } from './McpServersSelector.svelte';
+
+/**
+ * **McpCapabilitiesBadges** - Server capabilities display
+ *
+ * Displays MCP server capabilities as colored badges.
+ * Shows which features the server supports (tools, resources, prompts, etc.).
+ *
+ * **Features:**
+ * - Tools badge (green) - server provides callable tools
+ * - Resources badge (blue) - server provides data resources
+ * - Prompts badge (purple) - server provides prompt templates
+ * - Logging badge (orange) - server supports logging
+ * - Completions badge (cyan) - server provides completions
+ * - Tasks badge (pink) - server supports task management
+ */
+export { default as McpCapabilitiesBadges } from './McpCapabilitiesBadges.svelte';
+
+/**
+ * **McpConnectionLogs** - Connection log viewer
+ *
+ * Collapsible panel showing MCP server connection logs.
+ * Displays timestamped log entries with level-based styling.
+ *
+ * **Features:**
+ * - Collapsible log list with entry count
+ * - Connection time display in milliseconds
+ * - Log level icons and color coding
+ * - Scrollable log container with max height
+ * - Monospace font for log readability
+ */
+export { default as McpConnectionLogs } from './McpConnectionLogs.svelte';
+
+/**
+ * **McpServerForm** - Server URL and headers input form
+ *
+ * Reusable form for entering MCP server connection details.
+ * Used in both add new server and edit server flows.
+ *
+ * **Features:**
+ * - URL input with validation error display
+ * - Custom headers key-value pairs editor
+ * - Controlled component with change callbacks
+ *
+ * @example
+ * ```svelte
+ * <McpServerForm
+ *   url={serverUrl}
+ *   headers={serverHeaders}
+ *   onUrlChange={(v) => serverUrl = v}
+ *   onHeadersChange={(v) => serverHeaders = v}
+ *   urlError={validationError}
+ * />
+ * ```
+ */
+export { default as McpServerForm } from './McpServerForm.svelte';
+
+/**
+ * MCP protocol logo SVG component. Renders the official MCP icon
+ * with customizable size via class and style props.
+ */
+export { default as McpLogo } from './McpLogo.svelte';
+
+/**
+ *
+ * SERVER CARD
+ *
+ * Components for displaying individual MCP server status and controls.
+ * McpServerCard is the main component, with sub-components for specific sections.
+ *
+ */
+
+/**
+ * **McpServerCard** - Individual server display card
+ *
+ * Main component for displaying a single MCP server with all its details.
+ * Manages edit mode, delete confirmation, and health check actions.
+ *
+ * **Architecture:**
+ * - Composes header, tools list, logs, and actions sub-components
+ * - Manages local edit/delete state
+ * - Reads health state from mcpStore
+ * - Triggers health checks via mcpStore
+ *
+ * **Features:**
+ * - Server header with favicon, name, version, and toggle
+ * - Capabilities badges display
+ * - Tools list with descriptions
+ * - Connection logs viewer
+ * - Edit form for URL and headers
+ * - Delete confirmation dialog
+ * - Skeleton loading states
+ */
+export { default as McpServerCard } from './McpServerCard/McpServerCard.svelte';
+
+/** Server card header with favicon, name, version badge, and enable toggle. */
+export { default as McpServerCardHeader } from './McpServerCard/McpServerCardHeader.svelte';
+
+/** Action buttons row: edit, refresh, delete. */
+export { default as McpServerCardActions } from './McpServerCard/McpServerCardActions.svelte';
+
+/** Collapsible tools list showing available server tools with descriptions. */
+export { default as McpServerCardToolsList } from './McpServerCard/McpServerCardToolsList.svelte';
+
+/** Inline edit form for server URL and custom headers. */
+export { default as McpServerCardEditForm } from './McpServerCard/McpServerCardEditForm.svelte';
+
+/** Delete confirmation dialog with server name display. */
+export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerCardDeleteDialog.svelte';
+
+/** Skeleton loading state for server card during health checks. */
+export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
+
+/**
+ * **McpServerInfo** - Server instructions display
+ *
+ * Collapsible panel showing server-provided instructions.
+ * Displays guidance text from the MCP server for users.
+ */
+export { default as McpServerInfo } from './McpServerInfo.svelte';
+
+/**
+ * **McpResourceBrowser** - MCP resources tree browser
+ *
+ * Tree view component showing resources grouped by server.
+ * Supports resource selection and quick attach actions.
+ *
+ * **Features:**
+ * - Collapsible server sections
+ * - Resource icons based on MIME type
+ * - Resource selection highlighting
+ * - Quick attach button per resource
+ * - Refresh all resources action
+ * - Loading states per server
+ */
+export { default as McpResourceBrowser } from './McpResourceBrowser/McpResourceBrowser.svelte';
+
+/**
+ * **McpResourcePreview** - MCP resource content preview
+ *
+ * Preview panel showing resource content with metadata.
+ * Supports text and binary content display.
+ *
+ * **Features:**
+ * - Text content display with monospace formatting
+ * - Image preview for image MIME types
+ * - Copy to clipboard action
+ * - Download content action
+ * - Resource metadata display (MIME type, priority, server)
+ * - Loading and error states
+ */
+export { default as McpResourcePreview } from './McpResourcePreview.svelte';
+
+/**
+ * **McpResourceTemplateForm** - MCP resource template variable form
+ *
+ * Form for filling in resource template variables with auto-completion
+ * via the Completions API. Shows live URI preview as variables are filled.
+ *
+ * **Features:**
+ * - Template variable input fields
+ * - Completions API integration for variable auto-complete
+ * - Live URI preview as variables are filled
+ * - Read resolved resource action
+ */
+export { default as McpResourceTemplateForm } from './McpResourceTemplateForm.svelte';
index e302f83e11e3fd64cdb0a01c0c0897ce11e03472..dc0621cecdab4c9128dd2d5ae5ad1eeaa2b0422b 100644 (file)
@@ -1,9 +1,10 @@
 <script lang="ts">
        import { ChevronLeft, ChevronRight } from '@lucide/svelte';
+       import type { Snippet } from 'svelte';
 
        interface Props {
                class?: string;
-               children?: import('svelte').Snippet;
+               children?: Snippet;
                gapSize?: string;
                onScrollableChange?: (isScrollable: boolean) => void;
        }
index 9a8731fc787ab7a8adcfe0346583929c9a5cd3eb..eecc5b1d634563015737357a589b10ebc2a11818 100644 (file)
@@ -4,9 +4,10 @@
        interface Props {
                text: string;
                class?: string;
+               showTooltip?: boolean;
        }
 
-       let { text, class: className = '' }: Props = $props();
+       let { text, class: className = '', showTooltip = true }: Props = $props();
 
        let textElement: HTMLSpanElement | undefined = $state();
        let isTruncated = $state(false);
@@ -29,7 +30,7 @@
        });
 </script>
 
-{#if isTruncated}
+{#if isTruncated && showTooltip}
        <Tooltip.Root>
                <Tooltip.Trigger class={className}>
                        <span bind:this={textElement} class="block truncate">
index 817e88286159cf7814f3735a6cd13e9773696883..9b25d05c13c22d26c25ed5faa05df3020e655cb8 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import { ModelsService } from '$lib/services/models.service';
        import { config } from '$lib/stores/settings.svelte';
+       import { TruncatedText } from '$lib/components/app';
 
        interface Props {
                modelId: string;
 </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}
diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelectorSheet.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelectorSheet.svelte
new file mode 100644 (file)
index 0000000..6fdb3e3
--- /dev/null
@@ -0,0 +1,408 @@
+<script lang="ts">
+       import { onMount } from 'svelte';
+       import { SvelteMap } from 'svelte/reactivity';
+       import { ChevronDown, Loader2, Package } from '@lucide/svelte';
+       import * as Sheet from '$lib/components/ui/sheet';
+       import { cn } from '$lib/components/ui/utils';
+       import {
+               modelsStore,
+               modelOptions,
+               modelsLoading,
+               modelsUpdating,
+               selectedModelId,
+               singleModelName
+       } from '$lib/stores/models.svelte';
+       import { isRouterMode } from '$lib/stores/server.svelte';
+       import {
+               DialogModelInformation,
+               SearchInput,
+               TruncatedText,
+               ModelsSelectorOption
+       } from '$lib/components/app';
+       import type { ModelOption } from '$lib/types/models';
+
+       interface Props {
+               class?: string;
+               currentModel?: string | null;
+               /** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
+               onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
+               disabled?: boolean;
+               forceForegroundText?: boolean;
+               /** When true, user's global selection takes priority over currentModel (for form selector) */
+               useGlobalSelection?: boolean;
+       }
+
+       let {
+               class: className = '',
+               currentModel = null,
+               onModelChange,
+               disabled = false,
+               forceForegroundText = false,
+               useGlobalSelection = false
+       }: Props = $props();
+
+       let options = $derived(
+               modelOptions().filter((option) => {
+                       const modelProps = modelsStore.getModelProps(option.model);
+                       return modelProps?.webui !== false;
+               })
+       );
+       let loading = $derived(modelsLoading());
+       let updating = $derived(modelsUpdating());
+       let activeId = $derived(selectedModelId());
+       let isRouter = $derived(isRouterMode());
+       let serverModel = $derived(singleModelName());
+
+       let isLoadingModel = $state(false);
+
+       let isHighlightedCurrentModelActive = $derived(
+               !isRouter || !currentModel
+                       ? false
+                       : (() => {
+                                       const currentOption = options.find((option) => option.model === currentModel);
+
+                                       return currentOption ? currentOption.id === activeId : false;
+                               })()
+       );
+
+       let isCurrentModelInCache = $derived.by(() => {
+               if (!isRouter || !currentModel) return true;
+
+               return options.some((option) => option.model === currentModel);
+       });
+
+       let searchTerm = $state('');
+
+       let filteredOptions: ModelOption[] = $derived.by(() => {
+               const term = searchTerm.trim().toLowerCase();
+               if (!term) return options;
+
+               return options.filter(
+                       (option) =>
+                               option.model.toLowerCase().includes(term) ||
+                               option.name?.toLowerCase().includes(term) ||
+                               option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
+                               option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
+               );
+       });
+
+       let groupedFilteredOptions = $derived.by(() => {
+               const favIds = modelsStore.favouriteModelIds;
+               const result: {
+                       orgName: string | null;
+                       isFavouritesGroup: boolean;
+                       isLoadedGroup: boolean;
+                       items: { option: ModelOption; flatIndex: number }[];
+               }[] = [];
+
+               // Loaded models group (top)
+               const loadedItems: { option: ModelOption; flatIndex: number }[] = [];
+               for (let i = 0; i < filteredOptions.length; i++) {
+                       if (modelsStore.isModelLoaded(filteredOptions[i].model)) {
+                               loadedItems.push({ option: filteredOptions[i], flatIndex: i });
+                       }
+               }
+               if (loadedItems.length > 0) {
+                       result.push({
+                               orgName: null,
+                               isFavouritesGroup: false,
+                               isLoadedGroup: true,
+                               items: loadedItems
+                       });
+               }
+
+               // Favourites group
+               const loadedModelIds = new Set(loadedItems.map((item) => item.option.model));
+               const favItems: { option: ModelOption; flatIndex: number }[] = [];
+               for (let i = 0; i < filteredOptions.length; i++) {
+                       if (favIds.has(filteredOptions[i].model) && !loadedModelIds.has(filteredOptions[i].model)) {
+                               favItems.push({ option: filteredOptions[i], flatIndex: i });
+                       }
+               }
+               if (favItems.length > 0) {
+                       result.push({
+                               orgName: null,
+                               isFavouritesGroup: true,
+                               isLoadedGroup: false,
+                               items: favItems
+                       });
+               }
+
+               // Org groups (excluding loaded and favourites)
+               const orgGroups = new SvelteMap<string, { option: ModelOption; flatIndex: number }[]>();
+               for (let i = 0; i < filteredOptions.length; i++) {
+                       const option = filteredOptions[i];
+                       if (loadedModelIds.has(option.model) || favIds.has(option.model)) continue;
+                       const orgName = option.parsedId?.orgName ?? null;
+                       const key = orgName ?? '';
+                       if (!orgGroups.has(key)) orgGroups.set(key, []);
+                       orgGroups.get(key)!.push({ option, flatIndex: i });
+               }
+               for (const [orgName, items] of orgGroups) {
+                       result.push({
+                               orgName: orgName || null,
+                               isFavouritesGroup: false,
+                               isLoadedGroup: false,
+                               items
+                       });
+               }
+
+               return result;
+       });
+
+       let sheetOpen = $state(false);
+       let showModelDialog = $state(false);
+
+       onMount(() => {
+               modelsStore.fetch().catch((error) => {
+                       console.error('Unable to load models:', error);
+               });
+       });
+
+       function handleOpenChange(open: boolean) {
+               if (loading || updating) return;
+
+               if (isRouter) {
+                       if (open) {
+                               sheetOpen = true;
+                               searchTerm = '';
+
+                               modelsStore.fetchRouterModels().then(() => {
+                                       modelsStore.fetchModalitiesForLoadedModels();
+                               });
+                       } else {
+                               sheetOpen = false;
+                               searchTerm = '';
+                       }
+               } else {
+                       showModelDialog = open;
+               }
+       }
+
+       export function open() {
+               handleOpenChange(true);
+       }
+
+       function handleSheetOpenChange(open: boolean) {
+               if (!open) {
+                       handleOpenChange(false);
+               }
+       }
+
+       async function handleSelect(modelId: string) {
+               const option = options.find((opt) => opt.id === modelId);
+               if (!option) return;
+
+               let shouldCloseMenu = true;
+
+               if (onModelChange) {
+                       const result = await onModelChange(option.id, option.model);
+
+                       if (result === false) {
+                               shouldCloseMenu = false;
+                       }
+               } else {
+                       await modelsStore.selectModelById(option.id);
+               }
+
+               if (shouldCloseMenu) {
+                       handleOpenChange(false);
+
+                       requestAnimationFrame(() => {
+                               const textarea = document.querySelector<HTMLTextAreaElement>(
+                                       '[data-slot="chat-form"] textarea'
+                               );
+                               textarea?.focus();
+                       });
+               }
+
+               if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
+                       isLoadingModel = true;
+                       modelsStore
+                               .loadModel(option.model)
+                               .catch((error) => console.error('Failed to load model:', error))
+                               .finally(() => (isLoadingModel = false));
+               }
+       }
+
+       function getDisplayOption(): ModelOption | undefined {
+               if (!isRouter) {
+                       if (serverModel) {
+                               return {
+                                       id: 'current',
+                                       model: serverModel,
+                                       name: serverModel.split('/').pop() || serverModel,
+                                       capabilities: []
+                               };
+                       }
+
+                       return undefined;
+               }
+
+               if (useGlobalSelection && activeId) {
+                       const selected = options.find((option) => option.id === activeId);
+                       if (selected) return selected;
+               }
+
+               if (currentModel) {
+                       if (!isCurrentModelInCache) {
+                               return {
+                                       id: 'not-in-cache',
+                                       model: currentModel,
+                                       name: currentModel.split('/').pop() || currentModel,
+                                       capabilities: []
+                               };
+                       }
+
+                       return options.find((option) => option.model === currentModel);
+               }
+
+               if (activeId) {
+                       return options.find((option) => option.id === activeId);
+               }
+
+               return undefined;
+       }
+</script>
+
+<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
+       {#if loading && options.length === 0 && isRouter}
+               <div class="flex items-center gap-2 text-xs text-muted-foreground">
+                       <Loader2 class="h-3.5 w-3.5 animate-spin" />
+                       Loading models…
+               </div>
+       {:else if options.length === 0 && isRouter}
+               <p class="text-xs text-muted-foreground">No models available.</p>
+       {:else}
+               {@const selectedOption = getDisplayOption()}
+
+               {#if isRouter}
+                       <button
+                               type="button"
+                               class={cn(
+                                       `inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
+                                       !isCurrentModelInCache
+                                               ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
+                                               : forceForegroundText
+                                                       ? 'text-foreground'
+                                                       : isHighlightedCurrentModelActive
+                                                               ? 'text-foreground'
+                                                               : 'text-muted-foreground',
+                                       sheetOpen ? 'text-foreground' : ''
+                               )}
+                               style="max-width: min(calc(100cqw - 9rem), 20rem)"
+                               disabled={disabled || updating}
+                               onclick={() => handleOpenChange(true)}
+                       >
+                               <Package class="h-3.5 w-3.5" />
+
+                               <TruncatedText text={selectedOption?.model || 'Select model'} class="min-w-0 font-medium" />
+
+                               {#if updating || isLoadingModel}
+                                       <Loader2 class="h-3 w-3.5 animate-spin" />
+                               {:else}
+                                       <ChevronDown class="h-3 w-3.5" />
+                               {/if}
+                       </button>
+
+                       <Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>
+                               <Sheet.Content side="bottom" class="max-h-[85vh] gap-1">
+                                       <Sheet.Header>
+                                               <Sheet.Title>Select Model</Sheet.Title>
+
+                                               <Sheet.Description class="sr-only">
+                                                       Choose a model to use for the conversation
+                                               </Sheet.Description>
+                                       </Sheet.Header>
+
+                                       <div class="flex flex-col gap-1 pb-4">
+                                               <div class="mb-3 px-4">
+                                                       <SearchInput placeholder="Search models..." bind:value={searchTerm} />
+                                               </div>
+
+                                               <div class="max-h-[60vh] overflow-y-auto px-2">
+                                                       {#if !isCurrentModelInCache && currentModel}
+                                                               <button
+                                                                       type="button"
+                                                                       class="flex w-full cursor-not-allowed items-center rounded-md bg-red-400/10 px-3 py-2.5 text-left text-sm text-red-400"
+                                                                       disabled
+                                                               >
+                                                                       <span class="min-w-0 flex-1 truncate">
+                                                                               {selectedOption?.name || currentModel}
+                                                                       </span>
+                                                                       <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
+                                                               </button>
+                                                               <div class="my-1 h-px bg-border"></div>
+                                                       {/if}
+
+                                                       {#if filteredOptions.length === 0}
+                                                               <p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
+                                                       {/if}
+
+                                                       {#each groupedFilteredOptions as group (group.isLoadedGroup ? '__loaded__' : group.isFavouritesGroup ? '__favourites__' : group.orgName)}
+                                                               {#if group.isLoadedGroup}
+                                                                       <p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
+                                                                               Loaded models
+                                                                       </p>
+                                                               {:else if group.isFavouritesGroup}
+                                                                       <p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
+                                                                               Favourite models
+                                                                       </p>
+                                                               {:else if group.orgName}
+                                                                       <p
+                                                                               class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
+                                                                       >
+                                                                               {group.orgName}
+                                                                       </p>
+                                                               {/if}
+
+                                                               {#each group.items as { option } (group.isLoadedGroup ? `loaded-${option.id}` : group.isFavouritesGroup ? `fav-${option.id}` : option.id)}
+                                                                       {@const isSelected = currentModel === option.model || activeId === option.id}
+                                                                       {@const isFav = modelsStore.favouriteModelIds.has(option.model)}
+                                                                       <ModelsSelectorOption
+                                                                               {option}
+                                                                               {isSelected}
+                                                                               isHighlighted={false}
+                                                                               {isFav}
+                                                                               showOrgName={group.isFavouritesGroup || group.isLoadedGroup}
+                                                                               onSelect={handleSelect}
+                                                                               onMouseEnter={() => {}}
+                                                                               onKeyDown={() => {}}
+                                                                       />
+                                                               {/each}
+                                                       {/each}
+                                               </div>
+                                       </div>
+                               </Sheet.Content>
+                       </Sheet.Root>
+               {:else}
+                       <button
+                               class={cn(
+                                       `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
+                                       !isCurrentModelInCache
+                                               ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
+                                               : forceForegroundText
+                                                       ? 'text-foreground'
+                                                       : isHighlightedCurrentModelActive
+                                                               ? 'text-foreground'
+                                                               : 'text-muted-foreground'
+                               )}
+                               style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
+                               onclick={() => handleOpenChange(true)}
+                               disabled={disabled || updating}
+                       >
+                               <Package class="h-3.5 w-3.5" />
+
+                               <TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
+
+                               {#if updating}
+                                       <Loader2 class="h-3 w-3.5 animate-spin" />
+                               {/if}
+                       </button>
+               {/if}
+       {/if}
+</div>
+
+{#if showModelDialog && !isRouter}
+       <DialogModelInformation bind:open={showModelDialog} />
+{/if}
index aadc86cdad2a36a3bc2f5becdddf7dc00faeb2e8..4a32be1b9d67ad1460183b2fe5d911a1edd9f66c 100644 (file)
  */
 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
  *
index d29358c8e017703de14e7a6dce7837d232dde74d..5ced3eb9a0f8359b05448d5a4c40a9a669d22ea3 100644 (file)
                                        '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: {
index f16a0e0d978468e9e08a5357b2cd7de98b210029..1eaa6d99ff450f49adc9c1a05945bd518bfb6a57 100644 (file)
@@ -1,7 +1,7 @@
 <script lang="ts" module>
        import { tv, type VariantProps } from 'tailwind-variants';
        export const sheetVariants = tv({
-               base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
+               base: `border-border/30 dark:border-border/20 data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-sm transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 ${PANEL_CLASSES}`,
                variants: {
                        side: {
                                top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
@@ -26,6 +26,7 @@
        import type { Snippet } from 'svelte';
        import SheetOverlay from './sheet-overlay.svelte';
        import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+       import { PANEL_CLASSES } from '$lib/constants';
 
        let {
                ref = $bindable(null),
index 0d5baf6d6d4d07b578a17ff2f1fe64f82a254f26..b6ff373ffa554660548cd1d8c54b9e1417af0fcc 100644 (file)
        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);
index 4ae86bb9fdfacbdb73038f9defd464827f271fb6..7ff9e4e52134a4fee7234ea1e8a501c5035a3684 100644 (file)
@@ -1,3 +1,20 @@
+import type { AgenticConfig } from '$lib/types/agentic';
+
+export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/;
+
+export const NEWLINE_SEPARATOR = '\n';
+
+export const TURN_LIMIT_MESSAGE = '\n\n```\nTurn limit reached\n```\n';
+
+export const LLM_ERROR_BLOCK_START = '\n\n```\nUpstream LLM error:\n';
+export const LLM_ERROR_BLOCK_END = '\n```\n';
+
+export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
+       enabled: true,
+       maxTurns: 100,
+       maxToolPreviewLines: 25
+} as const;
+
 // Agentic tool call tag markers
 export const AGENTIC_TAGS = {
        TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>',
@@ -13,6 +30,9 @@ export const REASONING_TAGS = {
        END: '<<<reasoning_content_end>>>'
 } as const;
 
+// Regex for trimming leading/trailing newlines
+export const TRIM_NEWLINES_REGEX = /^\n+|\n+$/g;
+
 // Regex patterns for parsing agentic content
 export const AGENTIC_REGEX = {
        // Matches completed tool calls (with END marker)
@@ -32,6 +52,10 @@ export const AGENTIC_REGEX = {
        REASONING_BLOCK: /<<<reasoning_content_start>>>[\s\S]*?<<<reasoning_content_end>>>/g,
        // Matches an opening reasoning tag and any remaining content (unterminated)
        REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/,
+       // Matches a complete agentic tool call display block (start to end marker)
+       AGENTIC_TOOL_CALL_BLOCK: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*?<<<AGENTIC_TOOL_CALL_END>>>/g,
+       // Matches a pending/partial agentic tool call (start marker with no matching end)
+       AGENTIC_TOOL_CALL_OPEN: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*$/,
        // Matches tool name inside content
        TOOL_NAME_EXTRACT: /<<<TOOL_NAME:([^>]+)>>>/
 } as const;
index f044241932bcb7660d33ed58409205600e0c6d9c..aa7ca3a22a267e59a24f6a51395926a024cee4c3 100644 (file)
@@ -3,3 +3,6 @@ export const API_MODELS = {
        LOAD: '/models/load',
        UNLOAD: '/models/unload'
 };
+
+/** CORS proxy endpoint path */
+export const CORS_PROXY_ENDPOINT = '/cors-proxy';
index e03da4e70048b613a7b50d0c8cd12249a4f083a3..be9999c0f9eed66849bef18aa613e9414edafea5 100644 (file)
@@ -1,2 +1,4 @@
 export const ATTACHMENT_LABEL_FILE = 'File';
 export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File';
+export const ATTACHMENT_LABEL_MCP_PROMPT = 'MCP Prompt';
+export const ATTACHMENT_LABEL_MCP_RESOURCE = 'MCP Resource';
index dbd5dcbddebf3b9f30f585c79a4b14c2900fa72b..07fe8683414a7fa165c9d0a22cca511e36cf2c44 100644 (file)
@@ -27,6 +27,18 @@ export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000;
  */
 export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50;
 
+/**
+ * Maximum number of MCP resources to cache
+ * @default 50
+ */
+export const MCP_RESOURCE_CACHE_MAX_ENTRIES = 50;
+
+/**
+ * TTL for MCP resource cache entries in milliseconds
+ * @default 5 minutes
+ */
+export const MCP_RESOURCE_CACHE_TTL_MS = 5 * 60 * 1000;
+
 /**
  * Maximum number of inactive conversation states to keep in memory
  * States for conversations beyond this limit will be cleaned up
index c5e3dc3d1b6ac9d7fce9c97362c3a14fcefc6ec0..68b4d0b90512c0b3ecea774e0c2bba7106c506e0 100644 (file)
@@ -1,3 +1,5 @@
 export const INITIAL_FILE_SIZE = 0;
 export const PROMPT_CONTENT_SEPARATOR = '\n\n';
 export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
+export const PROMPT_TRIGGER_PREFIX = '/';
+export const RESOURCE_TRIGGER_PREFIX = '@';
index 46076e55f68bff166d693d6929b739de75d78b97..ca5386fcdf04251bba6918f7d28a837890f0f712 100644 (file)
@@ -8,3 +8,12 @@ export const INPUT_CLASSES = `
     outline-none
     text-foreground
 `;
+
+export const PANEL_CLASSES = `
+    bg-background
+    border border-border/30 dark:border-border/20
+    shadow-sm backdrop-blur-lg!
+    rounded-t-lg!
+`;
+
+export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
diff --git a/tools/server/webui/src/lib/constants/favicon.ts b/tools/server/webui/src/lib/constants/favicon.ts
new file mode 100644 (file)
index 0000000..880e1f7
--- /dev/null
@@ -0,0 +1,4 @@
+export const GOOGLE_FAVICON_BASE_URL = 'https://www.google.com/s2/favicons';
+export const DEFAULT_FAVICON_SIZE = 32;
+export const DOMAIN_SEPARATOR = '.';
+export const ROOT_DOMAIN_MIN_PARTS = 2;
index d80dc8f091a3147d9b8be4b338d7b38087dff6dd..41c117df543b997d70f59702a561b9486344db43 100644 (file)
@@ -11,14 +11,19 @@ export * from './chat-form';
 export * from './code-blocks';
 export * from './code';
 export * from './css-classes';
+export * from './favicon';
 export * from './floating-ui-constraints';
 export * from './formatters';
+export * from './key-value-pairs';
 export * from './icons';
 export * from './latex-protection';
 export * from './literal-html';
 export * from './localstorage-keys';
 export * from './markdown';
 export * from './max-bundle-size';
+export * from './mcp';
+export * from './mcp-form';
+export * from './mcp-resource';
 export * from './model-id';
 export * from './precision';
 export * from './processing-info';
@@ -30,4 +35,5 @@ export * from './supported-file-types';
 export * from './table-html-restorer';
 export * from './tooltip-config';
 export * from './ui';
+export * from './uri-template';
 export * from './viewport';
diff --git a/tools/server/webui/src/lib/constants/key-value-pairs.ts b/tools/server/webui/src/lib/constants/key-value-pairs.ts
new file mode 100644 (file)
index 0000000..48dadbe
--- /dev/null
@@ -0,0 +1,20 @@
+/**
+ * Key-value pair form constraints and sanitization patterns.
+ *
+ * Both regexes target characters dangerous in HTTP-header / env-var contexts:
+ *   \x00        – null byte (injection)
+ *   \x0A (\n)   – LF  (HTTP header injection / response splitting)
+ *   \x0D (\r)   – CR  (HTTP header injection / response splitting)
+ *   \x01–\x08, \x0B–\x0C, \x0E–\x1F, \x7F – other C0/DEL control chars
+ *
+ * KEY_UNSAFE_RE additionally strips TAB (\x09); values keep TAB because it is
+ * a valid header-value continuation character per RFC 7230.
+ */
+
+export const KEY_VALUE_PAIR_KEY_MAX_LENGTH = 256;
+export const KEY_VALUE_PAIR_VALUE_MAX_LENGTH = 8192;
+
+// eslint-disable-next-line no-control-regex
+export const KEY_VALUE_PAIR_UNSAFE_KEY_RE = /[\x00-\x1F\x7F]/g;
+// eslint-disable-next-line no-control-regex
+export const KEY_VALUE_PAIR_UNSAFE_VALUE_RE = /[\x00-\x08\x0A-\x0D\x0E-\x1F\x7F]/g;
diff --git a/tools/server/webui/src/lib/constants/mcp-form.ts b/tools/server/webui/src/lib/constants/mcp-form.ts
new file mode 100644 (file)
index 0000000..7a1ccff
--- /dev/null
@@ -0,0 +1,2 @@
+export const MCP_SERVER_URL_PLACEHOLDER = 'https://mcp.example.com/sse';
+export const MIN_AUTOCOMPLETE_INPUT_LENGTH = 1;
diff --git a/tools/server/webui/src/lib/constants/mcp-resource.ts b/tools/server/webui/src/lib/constants/mcp-resource.ts
new file mode 100644 (file)
index 0000000..4441901
--- /dev/null
@@ -0,0 +1,55 @@
+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;
diff --git a/tools/server/webui/src/lib/constants/mcp.ts b/tools/server/webui/src/lib/constants/mcp.ts
new file mode 100644 (file)
index 0000000..8f10b15
--- /dev/null
@@ -0,0 +1,63 @@
+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
+};
index 9f3f06527e734f61173fa6fb900950ac01ede262..e76fa89e9a3eff1dc569e1429744feef82b68615 100644 (file)
@@ -24,6 +24,12 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
        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,
@@ -119,6 +125,16 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
                '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:
index 0f531cb1e02f7ba15a02a6818cb701805e0112bd..12091035781556dc1992b72e7b5972190b5ad256 100644 (file)
@@ -47,6 +47,11 @@ export const SETTINGS_KEYS = {
        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',
index 9d8a4dba4d3e812f6396bce485b5a298f09cb819..a2d960d404040036f34ca0bd0e0f79dcd257683a 100644 (file)
@@ -1,5 +1,8 @@
 /**
  * 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',
@@ -7,8 +10,10 @@ export const SETTINGS_SECTION_TITLES = {
        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];
diff --git a/tools/server/webui/src/lib/constants/uri-template.ts b/tools/server/webui/src/lib/constants/uri-template.ts
new file mode 100644 (file)
index 0000000..dc834ac
--- /dev/null
@@ -0,0 +1,57 @@
+/**
+ * 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 = /^\/+/;
diff --git a/tools/server/webui/src/lib/enums/agentic.ts b/tools/server/webui/src/lib/enums/agentic.ts
new file mode 100644 (file)
index 0000000..b96d244
--- /dev/null
@@ -0,0 +1,18 @@
+/**
+ * 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'
+}
index 7c7d0da9946997cd280e16db562235f342cb44ce..28863d7efc6364cf8bbc454e1f9f24cf7051e09e 100644 (file)
@@ -4,6 +4,8 @@
 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
index 839720dd097429d8ee3a1b226b2f0bf14be90f13..7efe0c706b449ede07b8f969a8411f6845dd0615 100644 (file)
@@ -11,6 +11,13 @@ export enum FileTypeCategory {
        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',
index 8683f3c994ff3dfa3848fd610d57478907ca0824..1711dd763493dd8a1aa6905f4aaa221ae746cf68 100644 (file)
@@ -1,5 +1,7 @@
 export { AttachmentType } from './attachment';
 
+export { AgenticSectionType, ToolCallType } from './agentic';
+
 export {
        ChatMessageStatsView,
        ContentPartType,
@@ -25,15 +27,26 @@ export {
        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';
diff --git a/tools/server/webui/src/lib/enums/mcp.ts b/tools/server/webui/src/lib/enums/mcp.ts
new file mode 100644 (file)
index 0000000..d2c27e1
--- /dev/null
@@ -0,0 +1,66 @@
+/**
+ * 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'
+}
index 116fe911b032c1cf184295868685568e08e1cded..37d45896279431fddca9569532e4437cac41143c 100644 (file)
@@ -4,10 +4,18 @@ export enum ColorMode {
        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://',
index bc67ef9869b16bf69414a102cf6f0dc6fe306b18..36e7a3192bc1ac6ffed6ad81a96a14d80b26bf47 100644 (file)
@@ -1,11 +1,11 @@
 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) => {
@@ -13,15 +13,18 @@ export function rehypeResolveAttachmentImages(options: { attachments?: DatabaseM
                        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;
                                }
index ebddfe2e02e614335a10f42cf677692a9c6760dd..80dc1800c707fe15ab429b85ddc552177e4be99d 100644 (file)
@@ -1,13 +1,19 @@
 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 {
@@ -21,7 +27,9 @@ 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)) {
@@ -35,6 +43,8 @@ export class ChatService {
                                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, '')
                        };
                });
        }
@@ -404,7 +414,7 @@ export class ChatService {
                                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;
@@ -755,11 +765,47 @@ export class ChatService {
                        }
                }
 
+               const mcpPrompts = message.extra.filter(
+                       (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpPrompt =>
+                               extra.type === AttachmentType.MCP_PROMPT
+               );
+
+               for (const mcpPrompt of mcpPrompts) {
+                       contentParts.push({
+                               type: 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;
        }
 
index 2592794c92bb8c83d5fcdaf97ddcb5160921038b..53ec80c27ed76f0ff35f5edf67b1d29b24c53489 100644 (file)
@@ -1,5 +1,5 @@
 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>;
@@ -16,7 +16,6 @@ class LlamacppDatabase extends Dexie {
 }
 
 const db = new LlamacppDatabase();
-import { v4 as uuid } from 'uuid';
 import { MessageRole } from '$lib/enums';
 
 export class DatabaseService {
index b215bf5c543ea56b680df2d20542ae5e63253fb3..7c98383d1f27807fa75b4ef17ef965ebda4d3cf3 100644 (file)
@@ -212,3 +212,51 @@ export { PropsService } from './props.service';
  * @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';
diff --git a/tools/server/webui/src/lib/services/mcp.service.ts b/tools/server/webui/src/lib/services/mcp.service.ts
new file mode 100644 (file)
index 0000000..b38d186
--- /dev/null
@@ -0,0 +1,772 @@
+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;
+       }
+}
index 46cce5e7cbbab6081d5fd89138107f59ca0a91e3..ce91de7410d0b33e7f98b5f7d23ea4d9af553d97 100644 (file)
@@ -1,5 +1,6 @@
 import { describe, it, expect } from 'vitest';
 import { ParameterSyncService } from './parameter-sync.service';
+import { ColorMode } from '$lib/enums';
 
 describe('ParameterSyncService', () => {
        describe('roundFloatingPoint', () => {
@@ -136,7 +137,7 @@ describe('ParameterSyncService', () => {
                                pasteLongTextToFileLen: 0,
                                pdfAsImage: true,
                                renderUserContentAsMarkdown: false,
-                               theme: 'dark'
+                               theme: ColorMode.DARK
                        });
 
                        expect(result.pasteLongTextToFileLen).toBe(0);
diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts
new file mode 100644 (file)
index 0000000..a6dd858
--- /dev/null
@@ -0,0 +1,720 @@
+/**
+ * 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;
+}
index cad82b8472ba617b597acd5de54e66d1bb208d0b..c31dfc8cbf76bb2f5e261b1bb058701496d8c496 100644 (file)
@@ -15,6 +15,8 @@ import { SvelteMap } from 'svelte/reactivity';
 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,
@@ -468,6 +470,10 @@ class ChatStore {
                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();
@@ -499,7 +505,7 @@ class ChatStore {
                                content,
                                MessageType.TEXT,
                                parentIdForUserMessage ?? '-1',
-                               extras
+                               allExtras
                        );
                        if (isNewConversation && content)
                                await conversationsStore.updateConversationName(currentConv.id, content.trim());
@@ -626,6 +632,10 @@ class ChatStore {
                                );
                        },
                        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
@@ -706,6 +716,20 @@ class ChatStore {
                                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(),
index 9d71b67a80a42d50a7604f81d476b29a07deb0b9..ec1daa90d9612df43d9dcc638a081553e17495b9 100644 (file)
@@ -1,7 +1,7 @@
 /**
  * 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
@@ -11,6 +11,7 @@
  * **Key Responsibilities:**
  * - Conversation CRUD (create, load, delete)
  * - Message management and tree navigation
+ * - MCP server per-chat overrides
  * - Import/Export functionality
  * - Title management with confirmation
  *
@@ -23,6 +24,7 @@ import { toast } from 'svelte-sonner';
 import { DatabaseService } from '$lib/services/database.service';
 import { config } from '$lib/stores/settings.svelte';
 import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
+import type { McpServerOverride } from '$lib/types/database';
 import { MessageRole } from '$lib/enums';
 
 class ConversationsStore {
@@ -46,9 +48,20 @@ 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;
+
        /**
         *
         *
@@ -80,6 +93,16 @@ class ConversationsStore {
                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;
+       }
+
        /**
         *
         *
@@ -162,6 +185,19 @@ class ConversationsStore {
                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 = [];
@@ -184,6 +220,7 @@ class ConversationsStore {
                                return false;
                        }
 
+                       this.pendingMcpServerOverrides = [];
                        this.activeConversation = conversation;
 
                        if (conversation.currNode) {
@@ -432,6 +469,148 @@ class ConversationsStore {
                }
        }
 
+       /**
+        *
+        *
+        * 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 = [];
+       }
+
        /**
         *
         *
diff --git a/tools/server/webui/src/lib/stores/mcp-resources.svelte.ts b/tools/server/webui/src/lib/stores/mcp-resources.svelte.ts
new file mode 100644 (file)
index 0000000..18347fb
--- /dev/null
@@ -0,0 +1,608 @@
+/**
+ * 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;
diff --git a/tools/server/webui/src/lib/stores/mcp.svelte.ts b/tools/server/webui/src/lib/stores/mcp.svelte.ts
new file mode 100644 (file)
index 0000000..f87789d
--- /dev/null
@@ -0,0 +1,2085 @@
+/**
+ * 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();
diff --git a/tools/server/webui/src/lib/types/agentic.d.ts b/tools/server/webui/src/lib/types/agentic.d.ts
new file mode 100644 (file)
index 0000000..f9d256e
--- /dev/null
@@ -0,0 +1,121 @@
+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;
+}
index afcaf3856f02670c205e8e1d0cf247dfb7073583..3d2dd930cd69c0028c67b183b77f8a475d306293 100644 (file)
@@ -9,6 +9,11 @@ export interface ChatUploadedFile {
        file: File;
        preview?: string;
        textContent?: string;
+       mcpPrompt?: {
+               serverName: string;
+               promptName: string;
+               arguments?: Record<string, string>;
+       };
        isLoading?: boolean;
        loadError?: string;
 }
@@ -19,6 +24,8 @@ export interface ChatAttachmentDisplayItem {
        size?: number;
        preview?: string;
        isImage: boolean;
+       isMcpPrompt?: boolean;
+       isMcpResource?: boolean;
        isLoading?: boolean;
        loadError?: string;
        uploadedFile?: ChatUploadedFile;
@@ -56,6 +63,39 @@ export interface ChatMessageTimings {
        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;
 }
 
 /**
@@ -75,6 +115,7 @@ export interface ChatStreamCallbacks {
                toolCallContent?: string
        ) => void;
        onError?: (error: Error) => void;
+       onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void;
 }
 
 /**
index a8d9d360c45c1de32bf2fe74531923783e5afac0..453d0cd74688b893e5fbc1dab1344677aab1dd7e 100644 (file)
@@ -1,5 +1,9 @@
 import type { AttachmentType } from '$lib/enums';
 
+/**
+ * Common utility types used across the application
+ */
+
 /**
  * Common utility types used across the application
  */
@@ -35,11 +39,29 @@ export interface ClipboardTextAttachment {
 }
 
 /**
- * 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;
index e912641b1d8f41cf1f57639062102bce605a92b3..50f51ecf5dc5a62b61fb3828a3d70877f0a6662b 100644 (file)
@@ -1,11 +1,17 @@
 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 {
@@ -46,11 +52,31 @@ export interface DatabaseMessageExtraTextFile {
        content: string;
 }
 
+export interface DatabaseMessageExtraMcpPrompt {
+       type: AttachmentType.MCP_PROMPT;
+       name: string;
+       serverName: string;
+       promptName: string;
+       content: string;
+       arguments?: Record<string, string>;
+}
+
+export 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 {
index bb3affd17e54ce25b12c05730c1947d45f1efa6b..93a39f03dae3f88eb2d946ae26795eeb97269f43 100644 (file)
@@ -40,6 +40,9 @@ export type {
        ChatMessageSiblingInfo,
        ChatMessagePromptProgress,
        ChatMessageTimings,
+       ChatMessageAgenticTimings,
+       ChatMessageAgenticTurnStats,
+       ChatMessageToolCallTiming,
        ChatStreamCallbacks,
        ErrorDialogState,
        LiveProcessingStats,
@@ -50,10 +53,13 @@ export type {
 
 // Database types
 export type {
+       McpServerOverride,
        DatabaseConversation,
        DatabaseMessageExtraAudioFile,
        DatabaseMessageExtraImageFile,
        DatabaseMessageExtraLegacyContext,
+       DatabaseMessageExtraMcpPrompt,
+       DatabaseMessageExtraMcpResource,
        DatabaseMessageExtraPdfFile,
        DatabaseMessageExtraTextFile,
        DatabaseMessageExtra,
@@ -82,5 +88,66 @@ export type {
        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';
diff --git a/tools/server/webui/src/lib/types/mcp.d.ts b/tools/server/webui/src/lib/types/mcp.d.ts
new file mode 100644 (file)
index 0000000..2ce2f73
--- /dev/null
@@ -0,0 +1,421 @@
+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;
+}
index f9f5a7824ffad334de153bf785a57c5b74e70405..67194d12ec0174fb2d78ff57dbae4bdc4cb29525 100644 (file)
@@ -1,7 +1,9 @@
 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;
 
@@ -11,7 +13,7 @@ export interface SettingsFieldConfig {
        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 {
@@ -22,6 +24,7 @@ export interface SettingsChatServiceOptions {
        systemMessage?: string;
        // Disable reasoning parsing (use 'none' instead of 'auto')
        disableReasoningParsing?: boolean;
+       tools?: OpenAIToolDefinition[];
        // Generation parameters
        temperature?: number;
        max_tokens?: number;
diff --git a/tools/server/webui/src/lib/utils/agentic.ts b/tools/server/webui/src/lib/utils/agentic.ts
new file mode 100644 (file)
index 0000000..330b924
--- /dev/null
@@ -0,0 +1,284 @@
+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;
+}
index 7d12a3427617ab5e9859acac771f2d9e3b298666..80781b98e968c062faf9621d40b2858fc1ccd366 100644 (file)
@@ -1,6 +1,6 @@
 import { base } from '$app/paths';
 import { getJsonHeaders, getAuthHeaders } from './api-headers';
-import { UrlPrefix } from '$lib/enums';
+import { UrlProtocol } from '$lib/enums';
 
 /**
  * API Fetch Utilities
@@ -50,7 +50,9 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
        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,
index 750aaa38d73a10b08b94e99cf6bcccf161e98600..396ed6671dfbc5fb6b24fe6bc1b9d02e1d24f5ed 100644 (file)
@@ -1,9 +1,30 @@
-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;
 }
 
 /**
@@ -37,6 +58,9 @@ export function getAttachmentDisplayItems(
                        size: file.size,
                        preview: file.preview,
                        isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
+                       isMcpPrompt: isMcpPromptUpload(file),
+                       isLoading: file.isLoading,
+                       loadError: file.loadError,
                        uploadedFile: file,
                        textContent: file.textContent
                });
@@ -45,12 +69,16 @@ export function getAttachmentDisplayItems(
        // 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
index 7ea1fa33bea9aa33f743c9392550bc79a93d7296..8fcb554b1a9a66257523af1db10340ff2d552c4d 100644 (file)
@@ -4,7 +4,11 @@ import type {
        DatabaseMessageExtra,
        DatabaseMessageExtraTextFile,
        DatabaseMessageExtraLegacyContext,
+       DatabaseMessageExtraMcpPrompt,
+       DatabaseMessageExtraMcpResource,
        ClipboardTextAttachment,
+       ClipboardMcpPromptAttachment,
+       ClipboardAttachment,
        ParsedClipboardContent
 } from '$lib/types';
 
@@ -101,11 +105,20 @@ export function formatMessageForClipboard(
        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) {
@@ -120,11 +133,24 @@ export function formatMessageForClipboard(
                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)}`;
 }
@@ -139,7 +165,8 @@ export function formatMessageForClipboard(
 export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
        const defaultResult: ParsedClipboardContent = {
                message: clipboardText,
-               textAttachments: []
+               textAttachments: [],
+               mcpPromptAttachments: []
        };
 
        if (!clipboardText.startsWith('"')) {
@@ -181,17 +208,28 @@ export function parseClipboardContent(clipboardText: string): ParsedClipboardCon
                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
@@ -201,13 +239,42 @@ export function parseClipboardContent(clipboardText: string): ParsedClipboardCon
 
                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
@@ -240,5 +307,5 @@ export function hasClipboardAttachments(clipboardText: string): boolean {
        }
 
        const parsed = parseClipboardContent(clipboardText);
-       return parsed.textAttachments.length > 0;
+       return parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0;
 }
index 11d65a44012e65b254d59a6952cf539ca45a6da9..84992106bd49a1cd383d6b90a09a1b681d9fccb7 100644 (file)
@@ -1,7 +1,7 @@
 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';
@@ -34,6 +34,19 @@ export async function parseFilesToMessageExtras(
        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;
diff --git a/tools/server/webui/src/lib/utils/cors-proxy.ts b/tools/server/webui/src/lib/utils/cors-proxy.ts
new file mode 100644 (file)
index 0000000..39c368b
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * 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;
+}
diff --git a/tools/server/webui/src/lib/utils/favicon.ts b/tools/server/webui/src/lib/utils/favicon.ts
new file mode 100644 (file)
index 0000000..4c75299
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * 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;
+       }
+}
diff --git a/tools/server/webui/src/lib/utils/headers.ts b/tools/server/webui/src/lib/utils/headers.ts
new file mode 100644 (file)
index 0000000..0b907b8
--- /dev/null
@@ -0,0 +1,44 @@
+/**
+ * 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);
+}
index 7aa4ab9756c9ccba44beaa21ef1ab891db59ad01..2caaf9ac3b377d8df7a045e943a41b9c6c916a24 100644 (file)
@@ -31,9 +31,15 @@ export {
        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';
 
@@ -100,12 +106,51 @@ export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files
 // 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';
 
@@ -117,3 +162,7 @@ export {
        createTimeoutSignal,
        withAbortSignal
 } from './abort';
+
+// Cryptography utilities
+
+export { uuid } from './uuid';
diff --git a/tools/server/webui/src/lib/utils/mcp.ts b/tools/server/webui/src/lib/utils/mcp.ts
new file mode 100644 (file)
index 0000000..ee27798
--- /dev/null
@@ -0,0 +1,304 @@
+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);
+}
diff --git a/tools/server/webui/src/lib/utils/sanitize.ts b/tools/server/webui/src/lib/utils/sanitize.ts
new file mode 100644 (file)
index 0000000..6078ecd
--- /dev/null
@@ -0,0 +1,23 @@
+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);
+}
diff --git a/tools/server/webui/src/lib/utils/uri-template.ts b/tools/server/webui/src/lib/utils/uri-template.ts
new file mode 100644 (file)
index 0000000..7665c98
--- /dev/null
@@ -0,0 +1,198 @@
+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() !== '');
+}
diff --git a/tools/server/webui/src/lib/utils/uuid.ts b/tools/server/webui/src/lib/utils/uuid.ts
new file mode 100644 (file)
index 0000000..29c20b3
--- /dev/null
@@ -0,0 +1,3 @@
+export function uuid(): string {
+       return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).substring(2);
+}
index 4e9bf399000a902ac9dc2562563e9b29f30ebc4c..ef27276a3c8264cf68d1296b9e116274b037627d 100644 (file)
@@ -1,6 +1,7 @@
 <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';
@@ -14,6 +15,7 @@
        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;"
index 3f51e3ab3619a249563dac54e5afe0919789a485..11e4e31d3a41f06f80f28e5212d3d68823768dc5 100644 (file)
@@ -91,7 +91,7 @@
        <title>llama.cpp - AI Chat Interface</title>
 </svelte:head>
 
-<ChatScreen showCenteredEmpty={true} />
+<ChatScreen showCenteredEmpty />
 
 <DialogModelNotAvailable
        bind:open={showModelNotAvailable}
diff --git a/tools/server/webui/tests/unit/agentic-strip.test.ts b/tools/server/webui/tests/unit/agentic-strip.test.ts
new file mode 100644 (file)
index 0000000..436908b
--- /dev/null
@@ -0,0 +1,81 @@
+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('');
+       });
+});
diff --git a/tools/server/webui/tests/unit/uri-template.test.ts b/tools/server/webui/tests/unit/uri-template.test.ts
new file mode 100644 (file)
index 0000000..6221279
--- /dev/null
@@ -0,0 +1,168 @@
+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');
+       });
+});