webui: Agentic Loop + MCP Client with support for Tools, Resources and Prompts (#18655)

This commit is contained in:
Aleksander Grygier
2026-03-06 10:00:39 +01:00
committed by GitHub
parent 2850bc6a13
commit f6235a41ef
147 changed files with 15285 additions and 366 deletions
+8
View File
@@ -2827,6 +2827,14 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
params.webui_config_json = read_file(value); params.webui_config_json = read_file(value);
} }
).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_WEBUI_CONFIG_FILE")); ).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( add_opt(common_arg(
{"--webui"}, {"--webui"},
{"--no-webui"}, {"--no-webui"},
+1
View File
@@ -545,6 +545,7 @@ struct common_params {
// webui configs // webui configs
bool webui = true; bool webui = true;
bool webui_mcp_proxy = false;
std::string webui_config_json; std::string webui_config_json;
// "advanced" endpoints are disabled by default for better security // "advanced" endpoints are disabled by default for better security
Binary file not shown.
+56
View File
@@ -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");
};
+20 -3
View File
@@ -1089,11 +1089,20 @@ server_http_proxy::server_http_proxy(
int32_t timeout_write int32_t timeout_write
) { ) {
// shared between reader and writer threads // 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>>(); 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 // 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_write_timeout(timeout_read, 0); // reversed for cli (client) vs srv (server)
cli->set_read_timeout(timeout_write, 0); cli->set_read_timeout(timeout_write, 0);
this->status = 500; // to be overwritten upon response this->status = 500; // to be overwritten upon response
@@ -1142,7 +1151,15 @@ server_http_proxy::server_http_proxy(
req.method = method; req.method = method;
req.path = path; req.path = path;
for (const auto & [key, value] : headers) { 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.body = body;
req.response_handler = response_handler; req.response_handler = response_handler;
+10
View File
@@ -1,6 +1,7 @@
#include "server-context.h" #include "server-context.h"
#include "server-http.h" #include "server-http.h"
#include "server-models.h" #include "server-models.h"
#include "server-cors-proxy.h"
#include "arg.h" #include "arg.h"
#include "common.h" #include "common.h"
@@ -201,6 +202,15 @@ int main(int argc, char ** argv) {
// Save & load slots // Save & load slots
ctx_http.get ("/slots", ex_wrapper(routes.get_slots)); ctx_http.get ("/slots", ex_wrapper(routes.get_slots));
ctx_http.post("/slots/:id_slot", ex_wrapper(routes.post_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 // Start the server
@@ -12,9 +12,13 @@ flowchart TB
C_Form["ChatForm"] C_Form["ChatForm"]
C_Messages["ChatMessages"] C_Messages["ChatMessages"]
C_Message["ChatMessage"] C_Message["ChatMessage"]
C_ChatMessageAgenticContent["ChatMessageAgenticContent"]
C_MessageEditForm["ChatMessageEditForm"] C_MessageEditForm["ChatMessageEditForm"]
C_ModelsSelector["ModelsSelector"] C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"] C_Settings["ChatSettings"]
C_McpSettings["McpServersSettings"]
C_McpResourceBrowser["McpResourceBrowser"]
C_McpServersSelector["McpServersSelector"]
end end
subgraph Hooks["🪝 Hooks"] subgraph Hooks["🪝 Hooks"]
@@ -24,10 +28,13 @@ flowchart TB
subgraph Stores["🗄️ Stores"] subgraph Stores["🗄️ Stores"]
S1["chatStore<br/><i>Chat interactions & streaming</i>"] 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>"] S3["modelsStore<br/><i>Model selection & loading</i>"]
S4["serverStore<br/><i>Server props & role detection</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 end
subgraph Services["⚙️ Services"] subgraph Services["⚙️ Services"]
@@ -36,11 +43,12 @@ flowchart TB
SV3["PropsService"] SV3["PropsService"]
SV4["DatabaseService"] SV4["DatabaseService"]
SV5["ParameterSyncService"] SV5["ParameterSyncService"]
SV6["MCPService<br/><i>protocol operations</i>"]
end end
subgraph Storage["💾 Storage"] subgraph Storage["💾 Storage"]
ST1["IndexedDB<br/><i>conversations, messages</i>"] ST1["IndexedDB<br/><i>conversations, messages</i>"]
ST2["LocalStorage<br/><i>config, userOverrides</i>"] ST2["LocalStorage<br/><i>config, userOverrides, mcpServers</i>"]
end end
subgraph APIs["🌐 llama-server API"] subgraph APIs["🌐 llama-server API"]
@@ -50,15 +58,27 @@ flowchart TB
API4["/v1/models"] API4["/v1/models"]
end end
subgraph ExternalMCP["🔌 External MCP Servers"]
EXT1["MCP Server 1<br/><i>WebSocket/HTTP/SSE</i>"]
EXT2["MCP Server N"]
end
%% Routes → Components %% Routes → Components
R1 & R2 --> C_Screen R1 & R2 --> C_Screen
RL --> C_Sidebar RL --> C_Sidebar
%% Layout runs MCP health checks
RL --> S6
%% Component hierarchy %% Component hierarchy
C_Screen --> C_Form & C_Messages & C_Settings C_Screen --> C_Form & C_Messages & C_Settings
C_Messages --> C_Message C_Messages --> C_Message
C_Message --> C_ChatMessageAgenticContent
C_Message --> C_MessageEditForm C_Message --> C_MessageEditForm
C_Form & C_MessageEditForm --> C_ModelsSelector C_Form & C_MessageEditForm --> C_ModelsSelector
C_Form --> C_McpServersSelector
C_Settings --> C_McpSettings
C_McpSettings --> C_McpResourceBrowser
%% Components → Hooks → Stores %% Components → Hooks → Stores
C_Form & C_Messages --> H1 & H2 C_Form & C_Messages --> H1 & H2
@@ -70,6 +90,15 @@ flowchart TB
C_Sidebar --> S2 C_Sidebar --> S2
C_ModelsSelector --> S3 & S4 C_ModelsSelector --> S3 & S4
C_Settings --> S5 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 %% Stores → Services
S1 --> SV1 & SV4 S1 --> SV1 & SV4
@@ -77,6 +106,8 @@ flowchart TB
S3 --> SV2 & SV3 S3 --> SV2 & SV3
S4 --> SV3 S4 --> SV3
S5 --> SV5 S5 --> SV5
S6 --> SV6
S7 --> SV6
%% Services → Storage %% Services → Storage
SV4 --> ST1 SV4 --> ST1
@@ -87,6 +118,9 @@ flowchart TB
SV2 --> API3 & API4 SV2 --> API3 & API4
SV3 --> API2 SV3 --> API2
%% MCP → External Servers
SV6 --> EXT1 & EXT2
%% Styling %% Styling
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,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 serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,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 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 H1,H2 hookStyle
class S1,S2,S3,S4,S5 storeStyle class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
class SV1,SV2,SV3,SV4,SV5 serviceStyle class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
class ST1,ST2 storageStyle class ST1,ST2 storageStyle
class API1,API2,API3,API4 apiStyle class API1,API2,API3,API4 apiStyle
class EXT1,EXT2 externalStyle
``` ```
@@ -22,6 +22,13 @@ end
C_ModelsSelector["ModelsSelector"] C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"] C_Settings["ChatSettings"]
end end
subgraph MCPComponents["MCP UI"]
C_McpSettings["McpServersSettings"]
C_McpServerCard["McpServerCard"]
C_McpResourceBrowser["McpResourceBrowser"]
C_McpResourcePreview["McpResourcePreview"]
C_McpServersSelector["McpServersSelector"]
end
end end
subgraph Hooks["🪝 Hooks"] 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()"] 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()"] S1Utils["<b>Utilities:</b><br/>getApiOptions()<br/>parseTimingData()<br/>getOrCreateAbortController()<br/>getConversationModel()"]
end 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"] subgraph S2["conversationsStore"]
S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>usedModalities<br/>isInitialized<br/>titleUpdateConfirmationCallback"] S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>isInitialized<br/>pendingMcpServerOverrides<br/>titleUpdateConfirmationCallback"]
S2Modal["<b>Modalities:</b><br/>getModalitiesUpToMessage()<br/>calculateModalitiesFromMessages()"]
S2Lifecycle["<b>Lifecycle:</b><br/>initialize()<br/>loadConversations()<br/>clearActiveConversation()"] 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()"] 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()"] 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()"] S2Utils["<b>Utilities:</b><br/>setTitleUpdateConfirmationCallback()"]
end end
subgraph S3["modelsStore"] subgraph S3["modelsStore"]
@@ -77,6 +90,21 @@ end
S5Sync["<b>Server Sync:</b><br/>syncWithServerDefaults()<br/>forceSyncWithServerDefaults()"] 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()"] S5Utils["<b>Utilities:</b><br/>getConfig()<br/>getAllConfig()<br/>getParameterInfo()<br/>getParameterDiff()<br/>getServerDefaults()<br/>clearAllUserOverrides()"]
end 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"] subgraph ReactiveExports["⚡ Reactive Exports"]
direction LR direction LR
@@ -95,12 +123,19 @@ end
RE9c["setEditModeActive()"] RE9c["setEditModeActive()"]
RE9d["clearEditMode()"] RE9d["clearEditMode()"]
end end
subgraph AgenticExports["agenticStore"]
REA1["agenticIsRunning()"]
REA2["agenticCurrentTurn()"]
REA3["agenticTotalToolCalls()"]
REA4["agenticLastError()"]
REA5["agenticStreamingToolCall()"]
REA6["agenticIsAnyRunning()"]
end
subgraph ConvExports["conversationsStore"] subgraph ConvExports["conversationsStore"]
RE10["conversations()"] RE10["conversations()"]
RE11["activeConversation()"] RE11["activeConversation()"]
RE12["activeMessages()"] RE12["activeMessages()"]
RE13["isConversationsInitialized()"] RE13["isConversationsInitialized()"]
RE14["usedModalities()"]
end end
subgraph ModelsExports["modelsStore"] subgraph ModelsExports["modelsStore"]
RE15["modelOptions()"] RE15["modelOptions()"]
@@ -131,6 +166,13 @@ end
RE36["theme()"] RE36["theme()"]
RE37["isInitialized()"] RE37["isInitialized()"]
end end
subgraph MCPExports["mcpStore / mcpResourceStore"]
RE38["mcpResources()"]
RE39["mcpResourceAttachments()"]
RE40["mcpHasResourceAttachments()"]
RE41["mcpTotalResourceCount()"]
RE42["mcpResourcesLoading()"]
end
end end
end end
@@ -138,9 +180,9 @@ end
direction TB direction TB
subgraph SV1["ChatService"] subgraph SV1["ChatService"]
SV1Msg["<b>Messaging:</b><br/>sendMessage()"] SV1Msg["<b>Messaging:</b><br/>sendMessage()"]
SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>parseSSEChunk()"] SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>handleNonStreamResponse()"]
SV1Convert["<b>Conversion:</b><br/>convertMessageToChatData()<br/>convertExtraToApiFormat()"] SV1Convert["<b>Conversion:</b><br/>convertDbMessageToApiChatMessageData()<br/>mergeToolCallDeltas()"]
SV1Utils["<b>Utilities:</b><br/>extractReasoningContent()<br/>getServerProps()<br/>getModels()"] SV1Utils["<b>Utilities:</b><br/>stripReasoningContent()<br/>extractModelName()<br/>parseErrorResponse()"]
end end
subgraph SV2["ModelsService"] subgraph SV2["ModelsService"]
SV2List["<b>Listing:</b><br/>list()<br/>listRouter()"] SV2List["<b>Listing:</b><br/>list()<br/>listRouter()"]
@@ -152,7 +194,7 @@ end
end end
subgraph SV4["DatabaseService"] subgraph SV4["DatabaseService"]
SV4Conv["<b>Conversations:</b><br/>createConversation()<br/>getConversation()<br/>getAllConversations()<br/>updateConversation()<br/>deleteConversation()"] 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()"] SV4Node["<b>Navigation:</b><br/>updateCurrentNode()"]
SV4Import["<b>Import:</b><br/>importConversations()"] SV4Import["<b>Import:</b><br/>importConversations()"]
end end
@@ -162,6 +204,19 @@ end
SV5Info["<b>Info:</b><br/>getParameterInfo()<br/>canSyncParameter()<br/>getSyncableParameterKeys()<br/>validateServerParameter()"] SV5Info["<b>Info:</b><br/>getParameterInfo()<br/>canSyncParameter()<br/>getSyncableParameterKeys()<br/>validateServerParameter()"]
SV5Diff["<b>Diff:</b><br/>createParameterDiff()"] SV5Diff["<b>Diff:</b><br/>createParameterDiff()"]
end 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 end
subgraph Storage["💾 Storage"] subgraph Storage["💾 Storage"]
@@ -171,6 +226,7 @@ end
ST5["LocalStorage"] ST5["LocalStorage"]
ST6["config"] ST6["config"]
ST7["userOverrides"] ST7["userOverrides"]
ST8["mcpServers"]
end end
subgraph APIs["🌐 llama-server API"] subgraph APIs["🌐 llama-server API"]
@@ -185,6 +241,9 @@ end
R2 --> C_Screen R2 --> C_Screen
RL --> C_Sidebar RL --> C_Sidebar
%% Layout runs MCP health checks on startup
RL --> S6
%% Component hierarchy %% Component hierarchy
C_Screen --> C_Form & C_Messages & C_Settings C_Screen --> C_Form & C_Messages & C_Settings
C_Messages --> C_Message C_Messages --> C_Message
@@ -194,8 +253,15 @@ end
C_MessageEditForm --> C_Attach C_MessageEditForm --> C_Attach
C_Form --> C_ModelsSelector C_Form --> C_ModelsSelector
C_Form --> C_Attach C_Form --> C_Attach
C_Form --> C_McpServersSelector
C_Message --> C_Attach 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 %% Components use Hooks
C_Form --> H1 C_Form --> H1
C_Message --> H1 & H2 C_Message --> H1 & H2
@@ -210,17 +276,29 @@ end
C_Screen --> S1 & S2 C_Screen --> S1 & S2
C_Messages --> S2 C_Messages --> S2
C_Message --> S1 & S2 & S3 C_Message --> S1 & S2 & S3
C_Form --> S1 & S3 C_Form --> S1 & S3 & S6
C_Sidebar --> S2 C_Sidebar --> S2
C_ModelsSelector --> S3 & S4 C_ModelsSelector --> S3 & S4
C_Settings --> S5 C_Settings --> S5
C_McpSettings --> S6
C_McpServerCard --> S6
C_McpResourceBrowser --> S6 & S7
C_McpServersSelector --> S6
%% Stores export Reactive State %% Stores export Reactive State
S1 -. exports .-> ChatExports S1 -. exports .-> ChatExports
SA -. exports .-> AgenticExports
S2 -. exports .-> ConvExports S2 -. exports .-> ConvExports
S3 -. exports .-> ModelsExports S3 -. exports .-> ModelsExports
S4 -. exports .-> ServerExports S4 -. exports .-> ServerExports
S5 -. exports .-> SettingsExports S5 -. exports .-> SettingsExports
S6 -. exports .-> MCPExports
S7 -. exports .-> MCPExports
%% chatStore → agenticStore (agentic loop orchestration)
S1 --> SA
SA --> SV1
SA --> S6
%% Stores use Services %% Stores use Services
S1 --> SV1 & SV4 S1 --> SV1 & SV4
@@ -228,28 +306,35 @@ end
S3 --> SV2 & SV3 S3 --> SV2 & SV3
S4 --> SV3 S4 --> SV3
S5 --> SV5 S5 --> SV5
S6 --> SV6
S7 --> SV6
%% Services to Storage %% Services to Storage
SV4 --> ST1 SV4 --> ST1
ST1 --> ST2 & ST3 ST1 --> ST2 & ST3
SV5 --> ST5 SV5 --> ST5
ST5 --> ST6 & ST7 ST5 --> ST6 & ST7 & ST8
%% Services to APIs %% Services to APIs
SV1 --> API1 SV1 --> API1
SV2 --> API3 & API4 SV2 --> API3 & API4
SV3 --> API2 SV3 --> API2
%% MCP → External Servers
SV6 --> EXT1 & EXT2
%% Styling %% Styling
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
classDef componentGroupStyle fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px 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 storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef stateStyle fill:#ffe0b2,stroke:#e65100,stroke-width:1px classDef stateStyle fill:#ffe0b2,stroke:#e65100,stroke-width:1px
classDef methodStyle fill:#ffecb3,stroke:#e65100,stroke-width:1px classDef methodStyle fill:#ffecb3,stroke:#e65100,stroke-width:1px
classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef serviceMStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px 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 storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,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_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageUser,C_MessageEditForm componentStyle
class C_ModelsSelector,C_Settings componentStyle class C_ModelsSelector,C_Settings componentStyle
class C_Attach componentStyle class C_Attach componentStyle
class H1,H2,H3 methodStyle class C_McpSettings,C_McpServerCard,C_McpResourceBrowser,C_McpResourcePreview,C_McpServersSelector componentStyle
class LayoutComponents,ChatUIComponents componentGroupStyle class H1,H2,H3 hookStyle
class Hooks storeStyle class LayoutComponents,ChatUIComponents,MCPComponents componentGroupStyle
class S1,S2,S3,S4,S5 storeStyle class Hooks hookStyle
class S1State,S2State,S3State,S4State,S5State stateStyle 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 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 S3Getters,S3Modal,S3Status,S3Fetch,S3Select,S3LoadUnload,S3Utils methodStyle
class S4Getters,S4Data,S4Utils methodStyle class S4Getters,S4Data,S4Utils methodStyle
class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle
class ChatExports,ConvExports,ModelsExports,ServerExports,SettingsExports reactiveStyle class S6Lifecycle,S6Health,S6Servers,S6Tools,S6Prompts methodStyle
class SV1,SV2,SV3,SV4,SV5 serviceStyle 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 SV1Msg,SV1Stream,SV1Convert,SV1Utils serviceMStyle
class SV2List,SV2LoadUnload,SV2Status serviceMStyle class SV2List,SV2LoadUnload,SV2Status serviceMStyle
class SV3Fetch serviceMStyle class SV3Fetch serviceMStyle
class SV4Conv,SV4Msg,SV4Node,SV4Import serviceMStyle class SV4Conv,SV4Msg,SV4Node,SV4Import serviceMStyle
class SV5Extract,SV5Merge,SV5Info,SV5Diff 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 class API1,API2,API3,API4 apiStyle
``` ```
+59 -5
View File
@@ -2,8 +2,10 @@
sequenceDiagram sequenceDiagram
participant UI as 🧩 ChatForm / ChatMessage participant UI as 🧩 ChatForm / ChatMessage
participant chatStore as 🗄️ chatStore participant chatStore as 🗄️ chatStore
participant agenticStore as 🗄️ agenticStore
participant convStore as 🗄️ conversationsStore participant convStore as 🗄️ conversationsStore
participant settingsStore as 🗄️ settingsStore participant settingsStore as 🗄️ settingsStore
participant mcpStore as 🗄️ mcpStore
participant ChatSvc as ⚙️ ChatService participant ChatSvc as ⚙️ ChatService
participant DbSvc as ⚙️ DatabaseService participant DbSvc as ⚙️ DatabaseService
participant API as 🌐 /v1/chat/completions participant API as 🌐 /v1/chat/completions
@@ -25,6 +27,9 @@ sequenceDiagram
Note over convStore: → see conversations-flow.mmd Note over convStore: → see conversations-flow.mmd
end end
chatStore->>mcpStore: consumeResourceAttachmentsAsExtras()
Note right of mcpStore: Converts pending MCP resource<br/>attachments into message extras
chatStore->>chatStore: addMessage("user", content, extras) chatStore->>chatStore: addMessage("user", content, extras)
chatStore->>DbSvc: createMessageBranch(userMsg, parentId) chatStore->>DbSvc: createMessageBranch(userMsg, parentId)
chatStore->>convStore: addMessageToActive(userMsg) chatStore->>convStore: addMessageToActive(userMsg)
@@ -38,7 +43,7 @@ sequenceDiagram
deactivate chatStore deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════ %% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🌊 STREAMING Note over UI,API: 🌊 STREAMING (with agentic flow detection)
%% ═══════════════════════════════════════════════════════════════════════════ %% ═══════════════════════════════════════════════════════════════════════════
activate chatStore activate chatStore
@@ -52,10 +57,17 @@ sequenceDiagram
chatStore->>chatStore: getApiOptions() chatStore->>chatStore: getApiOptions()
Note right of chatStore: Merge from settingsStore.config:<br/>temperature, max_tokens, top_p, etc. 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 activate ChatSvc
ChatSvc->>ChatSvc: convertMessageToChatData(messages) ChatSvc->>ChatSvc: convertDbMessageToApiChatMessageData(messages)
Note right of ChatSvc: DatabaseMessage[] → ApiChatMessageData[]<br/>Process attachments (images, PDFs, audio) Note right of ChatSvc: DatabaseMessage[] → ApiChatMessageData[]<br/>Process attachments (images, PDFs, audio)
ChatSvc->>API: POST /v1/chat/completions ChatSvc->>API: POST /v1/chat/completions
@@ -63,7 +75,7 @@ sequenceDiagram
loop SSE chunks loop SSE chunks
API-->>ChatSvc: data: {"choices":[{"delta":{...}}]} API-->>ChatSvc: data: {"choices":[{"delta":{...}}]}
ChatSvc->>ChatSvc: parseSSEChunk(line) ChatSvc->>ChatSvc: handleStreamResponse(response)
alt content chunk alt content chunk
ChatSvc-->>chatStore: onChunk(content) ChatSvc-->>chatStore: onChunk(content)
@@ -154,12 +166,15 @@ sequenceDiagram
Note over UI,API: ✏️ EDIT USER MESSAGE Note over UI,API: ✏️ EDIT USER MESSAGE
%% ═══════════════════════════════════════════════════════════════════════════ %% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: editUserMessagePreserveResponses(msgId, newContent) UI->>chatStore: editMessageWithBranching(msgId, newContent, extras)
activate chatStore activate chatStore
chatStore->>chatStore: Get parent of target message chatStore->>chatStore: Get parent of target message
chatStore->>DbSvc: createMessageBranch(editedMsg, parentId) chatStore->>DbSvc: createMessageBranch(editedMsg, parentId)
chatStore->>convStore: refreshActiveMessages() chatStore->>convStore: refreshActiveMessages()
Note right of chatStore: Creates new branch, original preserved 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 deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════ %% ═══════════════════════════════════════════════════════════════════════════
@@ -171,4 +186,43 @@ sequenceDiagram
Note right of chatStore: errorDialogState = {type: 'timeout'|'server', message} Note right of chatStore: errorDialogState = {type: 'timeout'|'server', message}
chatStore->>convStore: removeMessageAtIndex(failedMsgIdx) chatStore->>convStore: removeMessageAtIndex(failedMsgIdx)
chatStore->>DbSvc: deleteMessage(failedMsgId) chatStore->>DbSvc: deleteMessage(failedMsgId)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🤖 AGENTIC LOOP (when agenticConfig.enabled)
%% ═══════════════════════════════════════════════════════════════════════════
Note over agenticStore: agenticStore.runAgenticFlow(convId, messages, assistantMsg, options, signal)
activate agenticStore
agenticStore->>agenticStore: getSession(convId) or create new
agenticStore->>agenticStore: updateSession(turn: 0, running: true)
loop executeAgenticLoop (until no tool_calls or maxTurns)
agenticStore->>agenticStore: turn++
agenticStore->>ChatSvc: sendMessage(messages, options, signal)
ChatSvc->>API: POST /v1/chat/completions
API-->>ChatSvc: response with potential tool_calls
ChatSvc-->>agenticStore: onComplete(content, reasoning, timings, toolCalls)
alt response has tool_calls
agenticStore->>agenticStore: normalizeToolCalls(toolCalls)
loop for each tool_call
agenticStore->>agenticStore: updateSession(streamingToolCall)
agenticStore->>mcpStore: executeTool(mcpCall, signal)
mcpStore-->>agenticStore: tool result
agenticStore->>agenticStore: extractBase64Attachments(result)
agenticStore->>agenticStore: emitToolCallResult(convId, ...)
agenticStore->>convStore: addMessageToActive(toolResultMsg)
agenticStore->>DbSvc: createMessageBranch(toolResultMsg)
end
agenticStore->>agenticStore: Create new assistantMsg for next turn
Note right of agenticStore: Continue loop with updated messages
else no tool_calls (final response)
agenticStore->>agenticStore: buildFinalTimings(allTurns)
Note right of agenticStore: Break loop, return final response
end
end
agenticStore->>agenticStore: updateSession(running: false)
agenticStore-->>chatStore: final content, timings, model
deactivate agenticStore
``` ```
@@ -6,7 +6,7 @@ sequenceDiagram
participant DbSvc as ⚙️ DatabaseService participant DbSvc as ⚙️ DatabaseService
participant IDB as 💾 IndexedDB 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 Note over UI,IDB: 🚀 INITIALIZATION
@@ -37,6 +37,13 @@ sequenceDiagram
convStore->>convStore: conversations.unshift(conversation) convStore->>convStore: conversations.unshift(conversation)
convStore->>convStore: activeConversation = $state(conversation) convStore->>convStore: activeConversation = $state(conversation)
convStore->>convStore: activeMessages = $state([]) 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 deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════ %% ═══════════════════════════════════════════════════════════════════════════
@@ -58,8 +65,7 @@ sequenceDiagram
Note right of convStore: Filter to show only current branch path Note right of convStore: Filter to show only current branch path
convStore->>convStore: activeMessages = $state(filtered) convStore->>convStore: activeMessages = $state(filtered)
convStore->>chatStore: syncLoadingStateForChat(convId) Note right of convStore: Route (+page.svelte) then calls:<br/>chatStore.syncLoadingStateForChat(convId)
Note right of chatStore: Sync isLoading/currentResponse if streaming
deactivate convStore deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════ %% ═══════════════════════════════════════════════════════════════════════════
@@ -121,16 +127,36 @@ sequenceDiagram
end end
deactivate convStore 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.
Note over convStore: Scans activeMessages for attachments:<br/>- IMAGE → vision: true<br/>- PDF (processedAsImages) → vision: true<br/>- AUDIO → audio: true UI->>convStore: setMcpServerOverride(convId, serverName, override)
Note right of convStore: override = {enabled: boolean}
UI->>convStore: getModalitiesUpToMessage(msgId) UI->>convStore: toggleMcpServerForChat(convId, serverName, enabled)
Note right of convStore: Used for regeneration validation<br/>Only checks messages BEFORE target activate convStore
convStore->>convStore: setMcpServerOverride(convId, serverName, {enabled})
deactivate convStore
UI->>convStore: isMcpServerEnabledForChat(convId, serverName)
Note right of convStore: Check override → fall back to global MCP config
UI->>convStore: getAllMcpServerOverrides(convId)
Note right of convStore: Returns all overrides for a conversation
UI->>convStore: removeMcpServerOverride(convId, serverName)
UI->>convStore: getMcpServerOverride(convId, serverName)
%% ═══════════════════════════════════════════════════════════════════════════ %% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 📤 EXPORT / 📥 IMPORT Note over UI,IDB: 📤 EXPORT / 📥 IMPORT
@@ -148,8 +174,10 @@ sequenceDiagram
UI->>convStore: importConversations(file) UI->>convStore: importConversations(file)
activate convStore activate convStore
convStore->>convStore: Parse JSON file convStore->>convStore: Parse JSON file
convStore->>convStore: importConversationsData(parsed)
convStore->>DbSvc: importConversations(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() convStore->>convStore: loadConversations()
deactivate convStore deactivate convStore
``` ```
+25 -6
View File
@@ -66,6 +66,14 @@ sequenceDiagram
DbSvc-->>Store: rootMessageId DbSvc-->>Store: rootMessageId
deactivate DbSvc 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) Store->>DbSvc: createMessageBranch(message, parentId)
activate DbSvc activate DbSvc
DbSvc->>DbSvc: Generate UUID for new message DbSvc->>DbSvc: Generate UUID for new message
@@ -116,6 +124,13 @@ sequenceDiagram
end end
DbSvc->>Dexie: db.messages.delete(msgId) DbSvc->>Dexie: db.messages.delete(msgId)
Dexie->>IDB: DELETE target message 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 deactivate DbSvc
%% ═══════════════════════════════════════════════════════════════════════════ %% ═══════════════════════════════════════════════════════════════════════════
@@ -125,12 +140,16 @@ sequenceDiagram
Store->>DbSvc: importConversations(data) Store->>DbSvc: importConversations(data)
activate DbSvc activate DbSvc
loop each conversation in data loop each conversation in data
DbSvc->>DbSvc: Generate new UUIDs (avoid conflicts) DbSvc->>Dexie: db.conversations.get(conv.id)
DbSvc->>Dexie: db.conversations.add(conversation) alt conversation already exists
Dexie->>IDB: INSERT conversation Note right of DbSvc: Skip duplicate (keep existing)
loop each message else conversation is new
DbSvc->>Dexie: db.messages.add(message) DbSvc->>Dexie: db.conversations.add(conversation)
Dexie->>IDB: INSERT message Dexie->>IDB: INSERT conversation
loop each message
DbSvc->>Dexie: db.messages.add(message)
Dexie->>IDB: INSERT message
end
end end
end end
deactivate DbSvc deactivate DbSvc
+226
View File
@@ -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
```
+985 -47
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -79,6 +79,7 @@
"vitest-browser-svelte": "^0.1.0" "vitest-browser-svelte": "^0.1.0"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"pdfjs-dist": "^5.4.54", "pdfjs-dist": "^5.4.54",
@@ -90,6 +91,7 @@
"remark-html": "^16.0.1", "remark-html": "^16.0.1",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"svelte-sonner": "^1.0.5", "svelte-sonner": "^1.0.5",
"unist-util-visit": "^5.0.0" "unist-util-visit": "^5.0.0",
"zod": "^4.2.1"
} }
} }
@@ -6,21 +6,22 @@
id: string; id: string;
onRemove?: (id: string) => void; onRemove?: (id: string) => void;
class?: string; class?: string;
iconSize?: number;
} }
let { id, onRemove, class: className = '' }: Props = $props(); let { id, onRemove, class: className = '', iconSize = 3 }: Props = $props();
</script> </script>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="icon-sm"
class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}" class="bg-white/20 p-0 hover:bg-white/30 {className}"
onclick={(e: MouseEvent) => { onclick={(e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onRemove?.(id); onRemove?.(id);
}} }}
aria-label="Remove file" aria-label="Remove file"
> >
<X class="h-3 w-3" /> <X class="h-{iconSize} w-{iconSize}" />
</Button> </Button>
@@ -0,0 +1,40 @@
<script lang="ts">
import { ChatMessageMcpPromptContent, ActionIconRemove } from '$lib/components/app';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { McpPromptVariant } from '$lib/enums';
interface Props {
class?: string;
prompt: DatabaseMessageExtraMcpPrompt;
readonly?: boolean;
isLoading?: boolean;
loadError?: string;
onRemove?: () => void;
}
let {
class: className = '',
prompt,
readonly = false,
isLoading = false,
loadError,
onRemove
}: Props = $props();
</script>
<div class="group relative {className}">
<ChatMessageMcpPromptContent
{prompt}
variant={McpPromptVariant.ATTACHMENT}
{isLoading}
{loadError}
/>
{#if !readonly && onRemove}
<div
class="absolute top-10 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
>
<ActionIconRemove id={prompt.name} onRemove={() => onRemove?.()} />
</div>
{/if}
</div>
@@ -0,0 +1,86 @@
<script lang="ts">
import { Loader2, AlertCircle } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceAttachment } from '$lib/types';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIconRemove } from '$lib/components/app';
import { getResourceIcon, getResourceDisplayName } from '$lib/utils';
interface Props {
attachment: MCPResourceAttachment;
onRemove?: (attachmentId: string) => void;
onClick?: () => void;
class?: string;
}
let { attachment, onRemove, onClick, class: className }: Props = $props();
function getStatusClass(attachment: MCPResourceAttachment): string {
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
if (attachment.loading) return 'border-border/50 bg-muted/30';
return 'border-border/50 bg-muted/30';
}
const ResourceIcon = $derived(
getResourceIcon(attachment.resource.mimeType, attachment.resource.uri)
);
const serverName = $derived(mcpStore.getServerDisplayName(attachment.resource.serverName));
const favicon = $derived(mcpStore.getServerFavicon(attachment.resource.serverName));
</script>
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class={cn(
'flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.75 text-sm transition-colors',
getStatusClass(attachment),
onClick && 'cursor-pointer hover:bg-muted/50',
className
)}
onclick={onClick}
disabled={!onClick}
>
{#if attachment.loading}
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
{:else if attachment.error}
<AlertCircle class="h-3 w-3 text-red-500" />
{:else}
<ResourceIcon class="h-3 w-3 text-muted-foreground" />
{/if}
<span class="max-w-[150px] truncate text-xs">
{getResourceDisplayName(attachment.resource)}
</span>
{#if onRemove}
<ActionIconRemove
class="-my-2 -mr-1.5 bg-transparent"
iconSize={2}
id={attachment.id}
{onRemove}
/>
{/if}
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<div class="flex items-center gap-1 text-xs">
{#if favicon}
<img
src={favicon}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate">
{serverName}
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
@@ -0,0 +1,41 @@
<script lang="ts">
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
mcpResourceAttachments,
mcpHasResourceAttachments
} from '$lib/stores/mcp-resources.svelte';
import { ChatAttachmentMcpResource, HorizontalScrollCarousel } from '$lib/components/app';
interface Props {
class?: string;
onResourceClick?: (uri: string) => void;
}
let { class: className, onResourceClick }: Props = $props();
const attachments = $derived(mcpResourceAttachments());
const hasAttachments = $derived(mcpHasResourceAttachments());
function handleRemove(attachmentId: string) {
mcpStore.removeResourceAttachment(attachmentId);
}
function handleResourceClick(uri: string) {
onResourceClick?.(uri);
}
</script>
{#if hasAttachments}
<div class={className}>
<HorizontalScrollCarousel gapSize="2">
{#each attachments as attachment, i (attachment.id)}
<ChatAttachmentMcpResource
class={i === 0 ? 'ml-3' : ''}
{attachment}
onRemove={handleRemove}
onClick={() => handleResourceClick(attachment.resource.uri)}
/>
{/each}
</HorizontalScrollCarousel>
</div>
{/if}
@@ -1,12 +1,21 @@
<script lang="ts"> <script lang="ts">
import { import {
ChatAttachmentMcpPrompt,
ChatAttachmentMcpResource,
ChatAttachmentThumbnailImage, ChatAttachmentThumbnailImage,
ChatAttachmentThumbnailFile, ChatAttachmentThumbnailFile,
HorizontalScrollCarousel, HorizontalScrollCarousel,
DialogChatAttachmentPreview, DialogChatAttachmentPreview,
DialogChatAttachmentsViewAll DialogChatAttachmentsViewAll,
DialogMcpResourcePreview
} from '$lib/components/app'; } from '$lib/components/app';
import { Button } from '$lib/components/ui/button'; 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'; import { getAttachmentDisplayItems } from '$lib/utils';
interface Props { interface Props {
@@ -49,6 +58,8 @@
let isScrollable = $state(false); let isScrollable = $state(false);
let previewDialogOpen = $state(false); let previewDialogOpen = $state(false);
let previewItem = $state<ChatAttachmentPreviewItem | null>(null); 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 showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
let viewAllDialogOpen = $state(false); let viewAllDialogOpen = $state(false);
@@ -67,6 +78,26 @@
previewDialogOpen = true; 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(() => { $effect(() => {
if (carouselRef && displayItems.length) { if (carouselRef && displayItems.length) {
carouselRef.resetScroll(); carouselRef.resetScroll();
@@ -82,7 +113,41 @@
onScrollableChange={(scrollable) => (isScrollable = scrollable)} onScrollableChange={(scrollable) => (isScrollable = scrollable)}
> >
{#each displayItems as item (item.id)} {#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 <ChatAttachmentThumbnailImage
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}" class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
id={item.id} id={item.id}
@@ -128,7 +193,39 @@
{:else} {:else}
<div class="flex flex-wrap items-start justify-end gap-3"> <div class="flex flex-wrap items-start justify-end gap-3">
{#each displayItems as item (item.id)} {#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 <ChatAttachmentThumbnailImage
class="cursor-pointer" class="cursor-pointer"
id={item.id} id={item.id}
@@ -184,3 +281,7 @@
{imageClass} {imageClass}
{activeModelId} {activeModelId}
/> />
{#if mcpResourcePreviewExtra}
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
{/if}
@@ -1,22 +1,39 @@
<script lang="ts"> <script lang="ts">
import { import {
ChatAttachmentsList, ChatAttachmentsList,
ChatAttachmentMcpResources,
ChatFormActions, ChatFormActions,
ChatFormFileInputInvisible, ChatFormFileInputInvisible,
ChatFormPromptPicker,
ChatFormResourcePicker,
ChatFormTextarea ChatFormTextarea
} from '$lib/components/app'; } from '$lib/components/app';
import { DialogMcpResources } from '$lib/components/app/dialogs';
import { import {
CLIPBOARD_CONTENT_QUOTE_PREFIX, CLIPBOARD_CONTENT_QUOTE_PREFIX,
INPUT_CLASSES, INPUT_CLASSES,
SETTING_CONFIG_DEFAULT SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE,
PROMPT_CONTENT_SEPARATOR,
PROMPT_TRIGGER_PREFIX,
RESOURCE_TRIGGER_PREFIX
} from '$lib/constants'; } 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 { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte'; import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte'; import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte';
import { isIMEComposing, parseClipboardContent } from '$lib/utils'; 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 { import {
AudioRecorder, AudioRecorder,
convertToWav, convertToWav,
@@ -36,6 +53,7 @@
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
placeholder?: string; placeholder?: string;
showMcpPromptButton?: boolean;
// Event Handlers // Event Handlers
onAttachmentRemove?: (index: number) => void; onAttachmentRemove?: (index: number) => void;
@@ -44,6 +62,7 @@
onSubmit?: () => void; onSubmit?: () => void;
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void; onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
onUploadedFileRemove?: (fileId: string) => void; onUploadedFileRemove?: (fileId: string) => void;
onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
} }
@@ -53,6 +72,7 @@
disabled = false, disabled = false,
isLoading = false, isLoading = false,
placeholder = 'Type a message...', placeholder = 'Type a message...',
showMcpPromptButton = false,
uploadedFiles = $bindable([]), uploadedFiles = $bindable([]),
value = $bindable(''), value = $bindable(''),
onAttachmentRemove, onAttachmentRemove,
@@ -61,6 +81,7 @@
onSubmit, onSubmit,
onSystemPromptClick, onSystemPromptClick,
onUploadedFileRemove, onUploadedFileRemove,
onUploadedFilesChange,
onValueChange onValueChange
}: Props = $props(); }: Props = $props();
@@ -76,12 +97,26 @@
let audioRecorder: AudioRecorder | undefined; let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined); let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let fileInputRef: ChatFormFileInputInvisible | 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); let textareaRef: ChatFormTextarea | undefined = $state(undefined);
// Audio Recording State // Audio Recording State
let isRecording = $state(false); let isRecording = $state(false);
let recordingSupported = $state(false); let recordingSupported = $state(false);
// Prompt Picker State
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
// Inline Resource Picker State (triggered by @)
let isInlineResourcePickerOpen = $state(false);
let resourceSearchQuery = $state('');
// Resource Dialog State
let isResourceDialogOpen = $state(false);
let preSelectedResourceUri = $state<string | undefined>(undefined);
/** /**
* *
* *
@@ -211,7 +246,53 @@
* *
*/ */
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
isPromptPickerOpen = true;
promptSearchQuery = value.slice(1);
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
} else if (
value.startsWith(RESOURCE_TRIGGER_PREFIX) &&
hasServers &&
mcpStore.hasResourcesCapability(perChatOverrides)
) {
isInlineResourcePickerOpen = true;
resourceSearchQuery = value.slice(1);
isPromptPickerOpen = false;
promptSearchQuery = '';
} else {
isPromptPickerOpen = false;
promptSearchQuery = '';
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
}
}
function handleKeydown(event: KeyboardEvent) { 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)) { if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault(); event.preventDefault();
@@ -240,7 +321,7 @@
if (text.startsWith(CLIPBOARD_CONTENT_QUOTE_PREFIX)) { if (text.startsWith(CLIPBOARD_CONTENT_QUOTE_PREFIX)) {
const parsed = parseClipboardContent(text); const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) { if (parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0) {
event.preventDefault(); event.preventDefault();
value = parsed.message; value = parsed.message;
onValueChange?.(parsed.message); onValueChange?.(parsed.message);
@@ -256,6 +337,29 @@
onFilesAdd?.(attachmentFiles); 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(() => { setTimeout(() => {
textareaRef?.focus(); textareaRef?.focus();
}, 10); }, 10);
@@ -279,6 +383,130 @@
} }
} }
/**
*
*
* EVENT HANDLERS - Prompt Picker
*
*
*/
function handlePromptLoadStart(
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) {
// Only clear the value if the prompt was triggered by typing '/'
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isPromptPickerOpen = false;
promptSearchQuery = '';
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
id: placeholderId,
name: promptName,
size: INITIAL_FILE_SIZE,
type: SpecialFileType.MCP_PROMPT,
file: new File([], 'loading'),
isLoading: true,
mcpPrompt: {
serverName: promptInfo.serverName,
promptName: promptInfo.name,
arguments: args ? { ...args } : undefined
}
};
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
textareaRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
const promptText = result.messages
?.map((msg: PromptMessage) => {
if (typeof msg.content === 'string') {
return msg.content;
}
if (msg.content.type === ContentPartType.TEXT) {
return msg.content.text;
}
return '';
})
.filter(Boolean)
.join(PROMPT_CONTENT_SEPARATOR);
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId
? {
...f,
isLoading: false,
textContent: promptText,
size: promptText.length,
file: new File([promptText], `${f.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
})
}
: f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptLoadError(placeholderId: string, error: string) {
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId ? { ...f, isLoading: false, loadError: error } : f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
textareaRef?.focus();
}
/**
*
*
* EVENT HANDLERS - Inline Resource Picker
*
*
*/
function handleInlineResourcePickerClose() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
}
function handleInlineResourceSelect() {
// Clear the @query from input after resource is attached
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
}
function handleBrowseResources() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isResourceDialogOpen = true;
}
/** /**
* *
* *
@@ -326,6 +554,25 @@
onSubmit?.(); 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 <div
class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60' ? 'cursor-not-allowed opacity-60'
@@ -352,12 +599,23 @@
bind:value bind:value
onKeydown={handleKeydown} onKeydown={handleKeydown}
onInput={() => { onInput={() => {
handleInput();
onValueChange?.(value); onValueChange?.(value);
}} }}
{disabled} {disabled}
{placeholder} {placeholder}
/> />
{#if mcpHasResourceAttachments()}
<ChatAttachmentMcpResources
class="mb-3"
onResourceClick={(uri) => {
preSelectedResourceUri = uri;
isResourceDialogOpen = true;
}}
/>
{/if}
<ChatFormActions <ChatFormActions
class="px-3" class="px-3"
bind:this={chatFormActionsRef} bind:this={chatFormActionsRef}
@@ -371,7 +629,22 @@
onMicClick={handleMicClick} onMicClick={handleMicClick}
{onStop} {onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })} onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
/> />
</div> </div>
</div> </div>
</form> </form>
<DialogMcpResources
bind:open={isResourceDialogOpen}
preSelectedUri={preSelectedResourceUri}
onAttach={(resource: MCPResourceInfo) => {
mcpStore.attachResource(resource.uri);
}}
onOpenChange={(newOpen: boolean) => {
if (!newOpen) {
preSelectedResourceUri = undefined;
}
}}
/>
@@ -1,18 +1,30 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; 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 { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip'; 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 { 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 { interface Props {
class?: string; class?: string;
disabled?: boolean; disabled?: boolean;
hasAudioModality?: boolean; hasAudioModality?: boolean;
hasVisionModality?: boolean; hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void; onFileUpload?: () => void;
onSystemPromptClick?: () => void; onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
} }
let { let {
@@ -20,8 +32,13 @@
disabled = false, disabled = false,
hasAudioModality = false, hasAudioModality = false,
hasVisionModality = false, hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload, onFileUpload,
onSystemPromptClick onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props(); }: Props = $props();
let isNewChat = $derived(!page.params.id); let isNewChat = $derived(!page.params.id);
@@ -34,6 +51,53 @@
let dropdownOpen = $state(false); 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'; const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
</script> </script>
@@ -167,6 +231,103 @@
<p>{systemMessageTooltip}</p> <p>{systemMessageTooltip}</p>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </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.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
</div> </div>
@@ -0,0 +1,170 @@
<script lang="ts">
import { Plus, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Sheet from '$lib/components/ui/sheet';
import { FILE_TYPE_ICONS } from '$lib/constants';
import { McpLogo } from '$lib/components/app';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props();
let sheetOpen = $state(false);
function handleMcpPromptClick() {
sheetOpen = false;
onMcpPromptClick?.();
}
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
}
function handleMcpResourcesClick() {
sheetOpen = false;
onMcpResourcesClick?.();
}
function handleSheetFileUpload() {
sheetOpen = false;
onFileUpload?.();
}
function handleSheetSystemPromptClick() {
sheetOpen = false;
onSystemPromptClick?.();
}
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
const sheetItemClass =
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
</script>
<div class="flex items-center gap-1 {className}">
<Sheet.Root bind:open={sheetOpen}>
<Button
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
variant="secondary"
type="button"
onclick={() => (sheetOpen = true)}
>
<span class="sr-only">{fileUploadTooltipText}</span>
<Plus class="h-4 w-4" />
</Button>
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0">
<Sheet.Header>
<Sheet.Title>Add to chat</Sheet.Title>
<Sheet.Description class="sr-only">
Add files, system prompt or configure MCP servers
</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-1 overflow-y-auto px-1.5 pb-2">
<!-- Images -->
<button
type="button"
class={sheetItemClass}
disabled={!hasVisionModality}
onclick={handleSheetFileUpload}
>
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
<span>Images</span>
{#if !hasVisionModality}
<span class="ml-auto text-xs text-muted-foreground">Requires vision model</span>
{/if}
</button>
<!-- Audio -->
<button
type="button"
class={sheetItemClass}
disabled={!hasAudioModality}
onclick={handleSheetFileUpload}
>
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
<span>Audio Files</span>
{#if !hasAudioModality}
<span class="ml-auto text-xs text-muted-foreground">Requires audio model</span>
{/if}
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.text class="h-4 w-4 shrink-0" />
<span>Text Files</span>
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
<span>PDF Files</span>
{#if !hasVisionModality}
<span class="ml-auto text-xs text-muted-foreground">Text-only</span>
{/if}
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
<MessageSquare class="h-4 w-4 shrink-0" />
<span>System Message</span>
</button>
<button type="button" class={sheetItemClass} onclick={handleMcpSettingsClick}>
<McpLogo class="h-4 w-4 shrink-0" />
<span>MCP Servers</span>
</button>
{#if hasMcpPromptsSupport}
<button type="button" class={sheetItemClass} onclick={handleMcpPromptClick}>
<Zap class="h-4 w-4 shrink-0" />
<span>MCP Prompt</span>
</button>
{/if}
{#if hasMcpResourcesSupport}
<button type="button" class={sheetItemClass} onclick={handleMcpResourcesClick}>
<FolderOpen class="h-4 w-4 shrink-0" />
<span>MCP Resources</span>
</button>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
</div>
@@ -3,17 +3,24 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { import {
ChatFormActionAttachmentsDropdown, ChatFormActionAttachmentsDropdown,
ChatFormActionAttachmentsSheet,
ChatFormActionRecord, ChatFormActionRecord,
ChatFormActionSubmit, ChatFormActionSubmit,
ModelsSelector McpServersSelector,
ModelsSelector,
ModelsSelectorSheet
} from '$lib/components/app'; } 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 { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils'; import { getFileTypeCategory } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte'; import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte'; import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.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 { interface Props {
canSend?: boolean; canSend?: boolean;
@@ -27,6 +34,8 @@
onMicClick?: () => void; onMicClick?: () => void;
onStop?: () => void; onStop?: () => void;
onSystemPromptClick?: () => void; onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
} }
let { let {
@@ -40,7 +49,9 @@
onFileUpload, onFileUpload,
onMicClick, onMicClick,
onStop, onStop,
onSystemPromptClick onSystemPromptClick,
onMcpPromptClick,
onMcpResourcesClick
}: Props = $props(); }: Props = $props();
let currentConfig = $derived(config()); let currentConfig = $derived(config());
@@ -152,32 +163,83 @@
return ''; return '';
}); });
let selectorModelRef: ModelsSelector | undefined = $state(undefined); let selectorModelRef: ModelsSelector | ModelsSelectorSheet | undefined = $state(undefined);
let isMobile = new IsMobile();
export function openModelSelector() { export function openModelSelector() {
selectorModelRef?.open(); 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> </script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size"> <div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
<div class="mr-auto flex items-center gap-2"> <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} {disabled}
{hasAudioModality} onSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
{hasVisionModality}
{onFileUpload}
{onSystemPromptClick}
/> />
</div> </div>
<div class="ml-auto flex items-center gap-1.5"> <div class="ml-auto flex items-center gap-1.5">
<ModelsSelector {#if isMobile.current}
bind:this={selectorModelRef} <ModelsSelectorSheet
currentModel={conversationModel} disabled={disabled || isOffline}
disabled={disabled || isOffline} bind:this={selectorModelRef}
forceForegroundText={true} currentModel={conversationModel}
useGlobalSelection={true} forceForegroundText
/> useGlobalSelection
/>
{:else}
<ModelsSelector
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={conversationModel}
forceForegroundText
useGlobalSelection
/>
{/if}
</div> </div>
{#if isLoading} {#if isLoading}
@@ -205,3 +267,9 @@
/> />
{/if} {/if}
</div> </div>
<DialogChatSettings
open={showChatSettingsDialogWithMcpSection}
onOpenChange={(open) => (showChatSettingsDialogWithMcpSection = open)}
initialSection={SETTINGS_SECTION_TITLES.MCP}
/>
@@ -8,7 +8,7 @@
</script> </script>
{#if show} {#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"> <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, 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 <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Shift + Enter</kbd> for new line
@@ -0,0 +1,55 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { MCPServerSettingsEntry } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte';
interface Props {
server: MCPServerSettingsEntry | undefined;
serverLabel: string;
title: string;
description?: string;
titleExtra?: Snippet;
subtitle?: Snippet;
}
let { server, serverLabel, title, description, titleExtra, subtitle }: Props = $props();
let faviconUrl = $derived(server ? mcpStore.getServerFavicon(server.id) : null);
</script>
<div class="min-w-0 flex-1">
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span>{serverLabel}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">
{title}
</span>
{#if titleExtra}
{@render titleExtra()}
{/if}
</div>
{#if description}
<p class="mt-0.5 truncate text-sm text-muted-foreground">
{description}
</p>
{/if}
{#if subtitle}
{@render subtitle()}
{/if}
</div>
@@ -0,0 +1,82 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
interface Props {
items: T[];
isLoading: boolean;
selectedIndex: number;
searchQuery: string;
showSearchInput: boolean;
searchPlaceholder?: string;
emptyMessage?: string;
itemKey: (item: T, index: number) => string;
item: Snippet<[T, number, boolean]>;
skeleton?: Snippet;
footer?: Snippet;
}
let {
items,
isLoading,
selectedIndex,
searchQuery = $bindable(),
showSearchInput,
searchPlaceholder = 'Search...',
emptyMessage = 'No items available',
itemKey,
item,
skeleton,
footer
}: Props = $props();
let listContainer = $state<HTMLDivElement | null>(null);
$effect(() => {
if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
const selectedElement = listContainer.querySelector(
`[data-picker-index="${selectedIndex}"]`
) as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}
});
</script>
<ScrollArea>
{#if showSearchInput}
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
<SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
</div>
{/if}
<div
bind:this={listContainer}
class="{CHAT_FORM_POPOVER_MAX_HEIGHT} p-2"
class:pt-13={showSearchInput}
>
{#if isLoading}
{#if skeleton}
{@render skeleton()}
{/if}
{:else if items.length === 0}
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{:else}
{#each items as itemData, index (itemKey(itemData, index))}
{@render item(itemData, index, index === selectedIndex)}
{/each}
{/if}
</div>
{#if footer}
{@render footer()}
{/if}
</ScrollArea>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
isSelected?: boolean;
onClick: () => void;
dataIndex?: number;
children: Snippet;
}
let { isSelected = false, onClick, dataIndex, children }: Props = $props();
</script>
<button
type="button"
data-picker-index={dataIndex}
onclick={onClick}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
? 'bg-accent/50'
: ''}"
>
{@render children()}
</button>
@@ -0,0 +1,30 @@
<script lang="ts">
interface Props {
titleWidth?: string;
showBadge?: boolean;
}
let { titleWidth = 'w-48', showBadge = false }: Props = $props();
</script>
<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
<div class="min-w-0 flex-1 space-y-2">
<!-- Server label skeleton -->
<div class="mb-2 flex items-center gap-1.5">
<div class="h-3 w-3 shrink-0 animate-pulse rounded-sm bg-muted"></div>
<div class="h-3 w-24 animate-pulse rounded bg-muted"></div>
</div>
<!-- Title skeleton -->
<div class="flex items-center gap-2">
<div class="h-4 {titleWidth} animate-pulse rounded bg-muted"></div>
{#if showBadge}
<div class="h-4 w-12 animate-pulse rounded-full bg-muted"></div>
{/if}
</div>
<!-- Description skeleton -->
<div class="h-3 w-full animate-pulse rounded bg-muted"></div>
</div>
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Popover from '$lib/components/ui/popover';
interface Props {
class?: string;
isOpen?: boolean;
srLabel?: string;
onClose?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
children: Snippet;
}
let {
class: className = '',
isOpen = $bindable(false),
srLabel = 'Open picker',
onClose,
onKeydown,
children
}: Props = $props();
</script>
<Popover.Root
bind:open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose?.();
}
}}
>
<Popover.Trigger class="pointer-events-none absolute inset-0 opacity-0">
<span class="sr-only">{srLabel}</span>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={12}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
onkeydown={onKeydown}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{@render children()}
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,435 @@
<script lang="ts">
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { debounce, uuid } from '$lib/utils';
import { KeyboardKey } from '$lib/enums';
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import Badge from '$lib/components/ui/badge/badge.svelte';
import {
ChatFormPickerPopover,
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton,
ChatFormPromptPickerArgumentForm
} from '$lib/components/app/chat';
interface Props {
class?: string;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
onPromptLoadStart?: (
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) => void;
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
onPromptLoadError?: (placeholderId: string, error: string) => void;
}
let {
class: className = '',
isOpen = false,
searchQuery = '',
onClose,
onPromptLoadStart,
onPromptLoadComplete,
onPromptLoadError
}: Props = $props();
let prompts = $state<MCPPromptInfo[]>([]);
let isLoading = $state(false);
let selectedPrompt = $state<MCPPromptInfo | null>(null);
let promptArgs = $state<Record<string, string>>({});
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
let promptError = $state<string | null>(null);
let selectedIndexBeforeArgumentForm = $state<number | null>(null);
let suggestions = $state<Record<string, string[]>>({});
let loadingSuggestions = $state<Record<string, boolean>>({});
let activeAutocomplete = $state<string | null>(null);
let autocompleteIndex = $state(0);
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
$effect(() => {
if (isOpen) {
loadPrompts();
selectedIndex = 0;
} else {
selectedPrompt = null;
promptArgs = {};
promptError = null;
}
});
$effect(() => {
if (filteredPrompts.length > 0 && selectedIndex >= filteredPrompts.length) {
selectedIndex = 0;
}
});
async function loadPrompts() {
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
prompts = [];
return;
}
prompts = await mcpStore.getAllPrompts();
} catch (error) {
console.error('[ChatFormPromptPicker] Failed to load prompts:', error);
prompts = [];
} finally {
isLoading = false;
}
}
function handlePromptClick(prompt: MCPPromptInfo) {
const args = prompt.arguments ?? [];
if (args.length > 0) {
selectedIndexBeforeArgumentForm = selectedIndex;
selectedPrompt = prompt;
promptArgs = {};
promptError = null;
requestAnimationFrame(() => {
const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
if (firstInput) {
firstInput.focus();
}
});
} else {
executePrompt(prompt, {});
}
}
async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
promptError = null;
const placeholderId = uuid();
const nonEmptyArgs = Object.fromEntries(
Object.entries(args).filter(([, value]) => value.trim() !== '')
);
const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
onPromptLoadStart?.(placeholderId, prompt, argsToPass);
onClose?.();
try {
const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
onPromptLoadComplete?.(placeholderId, result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error executing prompt';
onPromptLoadError?.(placeholderId, errorMessage);
}
}
function handleArgumentSubmit(event: SubmitEvent) {
event.preventDefault();
if (selectedPrompt) {
executePrompt(selectedPrompt, promptArgs);
}
}
const fetchCompletions = debounce(async (argName: string, value: string) => {
if (!selectedPrompt || value.length < 1) {
suggestions[argName] = [];
return;
}
if (import.meta.env.DEV) {
console.log('[ChatFormPromptPicker] Fetching completions for:', {
serverName: selectedPrompt.serverName,
promptName: selectedPrompt.name,
argName,
value
});
}
loadingSuggestions[argName] = true;
try {
const result = await mcpStore.getPromptCompletions(
selectedPrompt.serverName,
selectedPrompt.name,
argName,
value
);
if (import.meta.env.DEV) {
console.log('[ChatFormPromptPicker] Autocomplete result:', {
argName,
value,
result,
suggestionsCount: result?.values.length ?? 0
});
}
if (result && result.values.length > 0) {
// Filter out empty strings from suggestions
const filteredValues = result.values.filter((v) => v.trim() !== '');
if (filteredValues.length > 0) {
suggestions[argName] = filteredValues;
activeAutocomplete = argName;
autocompleteIndex = 0;
} else {
suggestions[argName] = [];
}
} else {
suggestions[argName] = [];
}
} catch (error) {
console.error('[ChatFormPromptPicker] Failed to fetch completions:', error);
suggestions[argName] = [];
} finally {
loadingSuggestions[argName] = false;
}
}, 200);
function handleArgInput(argName: string, value: string) {
promptArgs[argName] = value;
fetchCompletions(argName, value);
}
function selectSuggestion(argName: string, value: string) {
promptArgs[argName] = value;
suggestions[argName] = [];
activeAutocomplete = null;
}
function handleArgKeydown(event: KeyboardEvent, argName: string) {
const argSuggestions = suggestions[argName] ?? [];
// Handle Escape - return to prompt selection list
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
event.stopPropagation();
handleCancelArgumentForm();
return;
}
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
} else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
event.preventDefault();
event.stopPropagation();
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
}
}
function handleArgBlur(argName: string) {
// Delay to allow click on suggestion
setTimeout(() => {
if (activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
}
}, 150);
}
function handleArgFocus(argName: string) {
if ((suggestions[argName]?.length ?? 0) > 0) {
activeAutocomplete = argName;
}
}
function handleCancelArgumentForm() {
// Restore the previously selected prompt index
if (selectedIndexBeforeArgumentForm !== null) {
selectedIndex = selectedIndexBeforeArgumentForm;
selectedIndexBeforeArgumentForm = null;
}
selectedPrompt = null;
promptArgs = {};
promptError = null;
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
if (selectedPrompt) {
// Return to prompt selection list, keeping the selected prompt active
handleCancelArgumentForm();
} else {
onClose?.();
}
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
}
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
}
return true;
}
if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
event.preventDefault();
if (filteredPrompts[selectedIndex]) {
handlePromptClick(filteredPrompts[selectedIndex]);
}
return true;
}
return false;
}
let filteredPrompts = $derived.by(() => {
const sortedServers = mcpStore.getServersSorted();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedPrompts = [...prompts].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedPrompts;
return sortedPrompts.filter(
(prompt) =>
prompt.name.toLowerCase().includes(query) ||
prompt.title?.toLowerCase().includes(query) ||
prompt.description?.toLowerCase().includes(query)
);
});
let showSearchInput = $derived(prompts.length > 3);
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open prompt picker"
{onClose}
onKeydown={handleKeydown}
>
{#if selectedPrompt}
{@const prompt = selectedPrompt}
{@const server = serverSettingsMap.get(prompt.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
<div class="p-4">
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={prompt.title || prompt.name}
description={prompt.description}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
<Badge variant="secondary">
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
</Badge>
{/if}
{/snippet}
</ChatFormPickerItemHeader>
<ChatFormPromptPickerArgumentForm
prompt={selectedPrompt}
{promptArgs}
{suggestions}
{loadingSuggestions}
{activeAutocomplete}
{autocompleteIndex}
{promptError}
onArgInput={handleArgInput}
onArgKeydown={handleArgKeydown}
onArgBlur={handleArgBlur}
onArgFocus={handleArgFocus}
onSelectSuggestion={selectSuggestion}
onSubmit={handleArgumentSubmit}
onCancel={handleCancelArgumentForm}
/>
</div>
{:else}
<ChatFormPickerList
items={filteredPrompts}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
searchPlaceholder="Search prompts..."
emptyMessage="No MCP prompts available"
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
>
{#snippet item(prompt, index, isSelected)}
{@const server = serverSettingsMap.get(prompt.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onClick={() => handlePromptClick(prompt)}
>
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={prompt.title || prompt.name}
description={prompt.description}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
<Badge variant="secondary">
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
</Badge>
{/if}
{/snippet}
</ChatFormPickerItemHeader>
</ChatFormPickerListItem>
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
{/snippet}
</ChatFormPickerList>
{/if}
</ChatFormPickerPopover>
@@ -0,0 +1,74 @@
<script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
prompt: MCPPromptInfo;
promptArgs: Record<string, string>;
suggestions: Record<string, string[]>;
loadingSuggestions: Record<string, boolean>;
activeAutocomplete: string | null;
autocompleteIndex: number;
promptError: string | null;
onArgInput: (argName: string, value: string) => void;
onArgKeydown: (event: KeyboardEvent, argName: string) => void;
onArgBlur: (argName: string) => void;
onArgFocus: (argName: string) => void;
onSelectSuggestion: (argName: string, value: string) => void;
onSubmit: (event: SubmitEvent) => void;
onCancel: () => void;
}
let {
prompt,
promptArgs,
suggestions,
loadingSuggestions,
activeAutocomplete,
autocompleteIndex,
promptError,
onArgInput,
onArgKeydown,
onArgBlur,
onArgFocus,
onSelectSuggestion,
onSubmit,
onCancel
}: Props = $props();
</script>
<form onsubmit={onSubmit} class="space-y-3 pt-4">
{#each prompt.arguments ?? [] as arg (arg.name)}
<ChatFormPromptPickerArgumentInput
argument={arg}
value={promptArgs[arg.name] ?? ''}
suggestions={suggestions[arg.name] ?? []}
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
isAutocompleteActive={activeAutocomplete === arg.name}
autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
onInput={(value) => onArgInput(arg.name, value)}
onKeydown={(e) => onArgKeydown(e, arg.name)}
onBlur={() => onArgBlur(arg.name)}
onFocus={() => onArgFocus(arg.name)}
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
/>
{/each}
{#if promptError}
<div
class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
role="alert"
>
<span class="shrink-0"></span>
<span>{promptError}</span>
</div>
{/if}
<div class="mt-8 flex justify-end gap-2">
<Button type="button" size="sm" onclick={onCancel} variant="secondary">Cancel</Button>
<Button size="sm" type="submit">Use Prompt</Button>
</div>
</form>
@@ -0,0 +1,84 @@
<script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import { fly } from 'svelte/transition';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
interface Props {
argument: PromptArgument;
value: string;
suggestions?: string[];
isLoadingSuggestions?: boolean;
isAutocompleteActive?: boolean;
autocompleteIndex?: number;
onInput: (value: string) => void;
onKeydown: (event: KeyboardEvent) => void;
onBlur: () => void;
onFocus: () => void;
onSelectSuggestion: (value: string) => void;
}
let {
argument,
value = '',
suggestions = [],
isLoadingSuggestions = false,
isAutocompleteActive = false,
autocompleteIndex = 0,
onInput,
onKeydown,
onBlur,
onFocus,
onSelectSuggestion
}: Props = $props();
</script>
<div class="relative grid gap-1">
<Label for="arg-{argument.name}" class="mb-1 text-muted-foreground">
<span>
{argument.name}
{#if argument.required}
<span class="text-destructive">*</span>
{/if}
</span>
{#if isLoadingSuggestions}
<span class="text-xs text-muted-foreground/50">...</span>
{/if}
</Label>
<Input
id="arg-{argument.name}"
type="text"
{value}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={onFocus}
placeholder={argument.description || argument.name}
required={argument.required}
autocomplete="off"
/>
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ y: -5, duration: 100 }}
>
{#each suggestions as suggestion, i (suggestion)}
<button
type="button"
onmousedown={() => onSelectSuggestion(suggestion)}
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
? 'bg-accent'
: ''}"
>
{suggestion}
</button>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,236 @@
<script lang="ts">
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
import { KeyboardKey } from '$lib/enums';
import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import { FolderOpen } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import {
ChatFormPickerPopover,
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton
} from '$lib/components/app/chat';
interface Props {
class?: string;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
onResourceSelect?: (resource: MCPResourceInfo) => void;
onBrowse?: () => void;
}
let {
class: className = '',
isOpen = false,
searchQuery = '',
onClose,
onResourceSelect,
onBrowse
}: Props = $props();
let resources = $state<MCPResourceInfo[]>([]);
let isLoading = $state(false);
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
$effect(() => {
if (isOpen) {
loadResources();
selectedIndex = 0;
}
});
$effect(() => {
if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
selectedIndex = 0;
}
});
async function loadResources() {
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
resources = [];
return;
}
await mcpStore.fetchAllResources();
resources = mcpResourceStore.getAllResourceInfos();
} catch (error) {
console.error('[ChatFormResourcePicker] Failed to load resources:', error);
resources = [];
} finally {
isLoading = false;
}
}
function handleResourceClick(resource: MCPResourceInfo) {
mcpStore.attachResource(resource.uri);
onResourceSelect?.(resource);
onClose?.();
}
function isResourceAttached(uri: string): boolean {
return mcpResourceStore.isAttached(uri);
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
onClose?.();
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredResources.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredResources.length;
}
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredResources.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
}
return true;
}
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (filteredResources[selectedIndex]) {
handleResourceClick(filteredResources[selectedIndex]);
}
return true;
}
return false;
}
let filteredResources = $derived.by(() => {
const sortedServers = mcpStore.getServersSorted();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedResources = [...resources].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedResources;
return sortedResources.filter(
(resource) =>
resource.name.toLowerCase().includes(query) ||
resource.title?.toLowerCase().includes(query) ||
resource.description?.toLowerCase().includes(query) ||
resource.uri.toLowerCase().includes(query)
);
});
let showSearchInput = $derived(resources.length > 3);
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open resource picker"
{onClose}
onKeydown={handleKeydown}
>
<ChatFormPickerList
items={filteredResources}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
searchPlaceholder="Search resources..."
emptyMessage="No MCP resources available"
itemKey={(resource) => resource.serverName + ':' + resource.uri}
>
{#snippet item(resource, index, isSelected)}
{@const server = serverSettingsMap.get(resource.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onClick={() => handleResourceClick(resource)}
>
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={resource.title || resource.name}
description={resource.description}
>
{#snippet titleExtra()}
{#if isResourceAttached(resource.uri)}
<span
class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
>
attached
</span>
{/if}
{/snippet}
{#snippet subtitle()}
<p class="mt-0.5 truncate text-xs text-muted-foreground/60">
{resource.uri}
</p>
{/snippet}
</ChatFormPickerItemHeader>
</ChatFormPickerListItem>
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton />
{/snippet}
{#snippet footer()}
{#if onBrowse && resources.length > 3}
<Button
class="fixed right-3 bottom-3"
type="button"
onclick={onBrowse}
variant="secondary"
size="sm"
>
<FolderOpen class="h-3 w-3" />
Browse all
</Button>
{/if}
{/snippet}
</ChatFormPickerList>
</ChatFormPickerPopover>
@@ -6,13 +6,15 @@
import { conversationsStore } from '$lib/stores/conversations.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services'; import { DatabaseService } from '$lib/services';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants'; import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { MessageRole } from '$lib/enums'; import { MessageRole, AttachmentType } from '$lib/enums';
import { import {
ChatMessageAssistant, ChatMessageAssistant,
ChatMessageUser, ChatMessageUser,
ChatMessageSystem ChatMessageSystem,
ChatMessageMcpPrompt
} from '$lib/components/app/chat'; } from '$lib/components/app/chat';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only'; import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props { interface Props {
class?: string; class?: string;
@@ -83,6 +85,20 @@
startEdit: handleEdit 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(() => { $effect(() => {
const pendingId = pendingEditMessageId(); const pendingId = pendingEditMessageId();
@@ -245,6 +261,21 @@
{showDeleteDialog} {showDeleteDialog}
{siblingInfo} {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} {:else if message.role === MessageRole.USER}
<ChatMessageUser <ChatMessageUser
class={className} class={className}
@@ -0,0 +1,354 @@
<script lang="ts">
import {
ChatMessageStatistics,
CollapsibleContentBlock,
MarkdownContent,
SyntaxHighlightedCode
} from '$lib/components/app';
import { config } from '$lib/stores/settings.svelte';
import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte';
import { AgenticSectionType, AttachmentType, FileTypeText } from '$lib/enums';
import { formatJsonPretty } from '$lib/utils';
import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
import { parseAgenticContent, type AgenticSection } from '$lib/utils';
import type { DatabaseMessage, DatabaseMessageExtraImageFile } from '$lib/types/database';
import type { ChatMessageAgenticTimings, ChatMessageAgenticTurnStats } from '$lib/types/chat';
import { ChatMessageStatsView } from '$lib/enums';
interface Props {
message?: DatabaseMessage;
content: string;
isStreaming?: boolean;
highlightTurns?: boolean;
}
type ToolResultLine = {
text: string;
image?: DatabaseMessageExtraImageFile;
};
let { content, message, isStreaming = false, highlightTurns = false }: Props = $props();
let expandedStates: Record<number, boolean> = $state({});
const sections = $derived(parseAgenticContent(content));
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
// Parse toolResults with images only when sections or message.extra change
const sectionsParsed = $derived(
sections.map((section) => ({
...section,
parsedLines: section.toolResult
? parseToolResultWithImages(section.toolResult, message?.extra)
: []
}))
);
// Group flat sections into agentic turns
// A new turn starts when a non-tool section follows a tool section
const turnGroups = $derived.by(() => {
const turns: { sections: (typeof sectionsParsed)[number][]; flatIndices: number[] }[] = [];
let currentTurn: (typeof sectionsParsed)[number][] = [];
let currentIndices: number[] = [];
let prevWasTool = false;
for (let i = 0; i < sectionsParsed.length; i++) {
const section = sectionsParsed[i];
const isTool =
section.type === AgenticSectionType.TOOL_CALL ||
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
section.type === AgenticSectionType.TOOL_CALL_STREAMING;
if (!isTool && prevWasTool && currentTurn.length > 0) {
turns.push({ sections: currentTurn, flatIndices: currentIndices });
currentTurn = [];
currentIndices = [];
}
currentTurn.push(section);
currentIndices.push(i);
prevWasTool = isTool;
}
if (currentTurn.length > 0) {
turns.push({ sections: currentTurn, flatIndices: currentIndices });
}
return turns;
});
function getDefaultExpanded(section: AgenticSection): boolean {
if (
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
section.type === AgenticSectionType.TOOL_CALL_STREAMING
) {
return showToolCallInProgress;
}
if (section.type === AgenticSectionType.REASONING_PENDING) {
return showThoughtInProgress;
}
return false;
}
function isExpanded(index: number, section: AgenticSection): boolean {
if (expandedStates[index] !== undefined) {
return expandedStates[index];
}
return getDefaultExpanded(section);
}
function toggleExpanded(index: number, section: AgenticSection) {
const currentState = isExpanded(index, section);
expandedStates[index] = !currentState;
}
function parseToolResultWithImages(
toolResult: string,
extras?: DatabaseMessage['extra']
): ToolResultLine[] {
const lines = toolResult.split(NEWLINE_SEPARATOR);
return lines.map((line) => {
const match = line.match(ATTACHMENT_SAVED_REGEX);
if (!match || !extras) return { text: line };
const attachmentName = match[1];
const image = extras.find(
(e): e is DatabaseMessageExtraImageFile =>
e.type === AttachmentType.IMAGE && e.name === attachmentName
);
return { text: line, image };
});
}
function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
return {
turns: 1,
toolCallsCount: stats.toolCalls.length,
toolsMs: stats.toolsMs,
toolCalls: stats.toolCalls,
llm: stats.llm
};
}
</script>
{#snippet renderSection(section: (typeof sectionsParsed)[number], index: number)}
{#if section.type === AgenticSectionType.TEXT}
<div class="agentic-text">
<MarkdownContent content={section.content} attachments={message?.extra} />
</div>
{:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING}
{@const streamingIcon = isStreaming ? Loader2 : AlertTriangle}
{@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4 text-yellow-500'}
{@const streamingSubtitle = isStreaming ? '' : 'incomplete'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={streamingIcon}
iconClass={streamingIconClass}
title={section.toolName || 'Tool call'}
subtitle={streamingSubtitle}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Arguments:</span>
{#if isStreaming}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if section.toolArgs}
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
language={FileTypeText.JSON}
maxHeight="20rem"
class="text-xs"
/>
{:else if isStreaming}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Receiving arguments...
</div>
{:else}
<div
class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
>
Response was truncated
</div>
{/if}
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING}
{@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
{@const toolIcon = isPending ? Loader2 : Wrench}
{@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={toolIcon}
iconClass={toolIconClass}
title={section.toolName || ''}
subtitle={isPending ? 'executing...' : undefined}
isStreaming={isPending}
onToggle={() => toggleExpanded(index, section)}
>
{#if section.toolArgs && section.toolArgs !== '{}'}
<div class="pt-3">
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
language={FileTypeText.JSON}
maxHeight="20rem"
class="text-xs"
/>
</div>
{/if}
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Result:</span>
{#if isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if section.toolResult}
<div class="overflow-auto rounded-lg border border-border bg-muted p-4">
{#each section.parsedLines as line, i (i)}
<div class="font-mono text-xs leading-relaxed whitespace-pre-wrap">{line.text}</div>
{#if line.image}
<img
src={line.image.base64Url}
alt={line.image.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
/>
{/if}
{/each}
</div>
{:else if isPending}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Waiting for result...
</div>
{/if}
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={Brain}
title="Reasoning"
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING_PENDING}
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
{@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={Brain}
title={reasoningTitle}
subtitle={reasoningSubtitle}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
</div>
</CollapsibleContentBlock>
{/if}
{/snippet}
<div class="agentic-content">
{#if highlightTurns && turnGroups.length > 1}
{#each turnGroups as turn, turnIndex (turnIndex)}
{@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
<div class="agentic-turn my-2 hover:bg-muted/80 dark:hover:bg-muted/30">
<span class="agentic-turn-label">Turn {turnIndex + 1}</span>
{#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
{@render renderSection(section, turn.flatIndices[sIdx])}
{/each}
{#if turnStats}
<div class="turn-stats">
<ChatMessageStatistics
promptTokens={turnStats.llm.prompt_n}
promptMs={turnStats.llm.prompt_ms}
predictedTokens={turnStats.llm.predicted_n}
predictedMs={turnStats.llm.predicted_ms}
agenticTimings={turnStats.toolCalls.length > 0
? buildTurnAgenticTimings(turnStats)
: undefined}
initialView={ChatMessageStatsView.GENERATION}
hideSummary
/>
</div>
{/if}
</div>
{/each}
{:else}
{#each sectionsParsed as section, index (index)}
{@render renderSection(section, index)}
{/each}
{/if}
</div>
<style>
.agentic-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 48rem;
}
.agentic-text {
width: 100%;
}
.agentic-turn {
position: relative;
border: 1.5px dashed var(--muted-foreground);
border-radius: 0.75rem;
padding: 1rem;
transition: background 0.1s;
}
.agentic-turn-label {
position: absolute;
top: -1rem;
left: 0.75rem;
padding: 0 0.375rem;
background: var(--background);
font-size: 0.7rem;
font-weight: 500;
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.turn-stats {
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid hsl(var(--muted) / 0.5);
}
</style>
@@ -1,23 +1,24 @@
<script lang="ts"> <script lang="ts">
import { import {
ChatMessageAgenticContent,
ChatMessageActions, ChatMessageActions,
ChatMessageStatistics, ChatMessageStatistics,
MarkdownContent, MarkdownContent,
ModelBadge, ModelBadge,
ModelsSelector ModelsSelector
} from '$lib/components/app'; } from '$lib/components/app';
import ChatMessageThinkingBlock from './ChatMessageThinkingBlock.svelte';
import { getMessageEditContext } from '$lib/contexts'; import { getMessageEditContext } from '$lib/contexts';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte'; import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils'; import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { Check, X } from '@lucide/svelte'; import { Check, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { INPUT_CLASSES, REASONING_TAGS } from '$lib/constants'; import { AGENTIC_TAGS, INPUT_CLASSES, REASONING_TAGS } from '$lib/constants';
import { MessageRole, KeyboardKey } from '$lib/enums'; import { MessageRole, KeyboardKey, ChatMessageStatsView } from '$lib/enums';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { isRouterMode } from '$lib/stores/server.svelte'; import { isRouterMode } from '$lib/stores/server.svelte';
@@ -48,58 +49,6 @@
textareaElement?: HTMLTextAreaElement; 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 { let {
class: className = '', class: className = '',
deletionInfo, deletionInfo,
@@ -135,15 +84,22 @@
} }
} }
const parsedMessageContent = $derived.by(() => parseReasoningContent(messageContent)); const hasAgenticMarkers = $derived(
const visibleMessageContent = $derived(parsedMessageContent.content); messageContent?.includes(AGENTIC_TAGS.TOOL_CALL_START) ?? false
const thinkingContent = $derived(parsedMessageContent.reasoningContent); );
const hasReasoningMarkers = $derived(parsedMessageContent.hasReasoningMarkers); 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(); const processingState = useProcessingState();
let currentConfig = $derived(config()); let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode()); let isRouter = $derived(isRouterMode());
let showRawOutput = $state(false); let showRawOutput = $state(false);
let activeStatsView = $state<ChatMessageStatsView>(ChatMessageStatsView.GENERATION);
let statsContainerEl: HTMLDivElement | undefined = $state(); let statsContainerEl: HTMLDivElement | undefined = $state();
function getScrollParent(el: HTMLElement): HTMLElement | null { function getScrollParent(el: HTMLElement): HTMLElement | null {
@@ -158,19 +114,25 @@
return null; return null;
} }
async function handleStatsViewChange() { async function handleStatsViewChange(view: ChatMessageStatsView) {
const el = statsContainerEl; const el = statsContainerEl;
if (!el) { if (!el) {
activeStatsView = view;
return; return;
} }
const scrollParent = getScrollParent(el); const scrollParent = getScrollParent(el);
if (!scrollParent) { if (!scrollParent) {
activeStatsView = view;
return; return;
} }
const yBefore = el.getBoundingClientRect().top; const yBefore = el.getBoundingClientRect().top;
activeStatsView = view;
await tick(); await tick();
const delta = el.getBoundingClientRect().top - yBefore; const delta = el.getBoundingClientRect().top - yBefore;
@@ -188,11 +150,16 @@
}); });
} }
let highlightAgenticTurns = $derived(
hasAgenticMarkers &&
(currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
);
let displayedModel = $derived(message.model ?? null); let displayedModel = $derived(message.model ?? null);
let isCurrentlyLoading = $derived(isLoading()); let isCurrentlyLoading = $derived(isLoading());
let isStreaming = $derived(isChatStreaming()); let isStreaming = $derived(isChatStreaming());
let hasNoContent = $derived(!visibleMessageContent?.trim()); let hasNoContent = $derived(!message?.content?.trim());
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming); let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
let showProcessingInfoTop = $derived( let showProcessingInfoTop = $derived(
@@ -231,14 +198,6 @@
role="group" role="group"
aria-label="Assistant message with actions" aria-label="Assistant message with actions"
> >
{#if !editCtx.isEditing && thinkingContent}
<ChatMessageThinkingBlock
reasoningContent={thinkingContent}
isStreaming={!message.timestamp}
hasRegularContent={!!visibleMessageContent?.trim()}
/>
{/if}
{#if showProcessingInfoTop} {#if showProcessingInfoTop}
<div class="mt-6 w-full max-w-[48rem]" in:fade> <div class="mt-6 w-full max-w-[48rem]" in:fade>
<div class="processing-container"> <div class="processing-container">
@@ -297,8 +256,15 @@
{:else if message.role === MessageRole.ASSISTANT} {:else if message.role === MessageRole.ASSISTANT}
{#if showRawOutput} {#if showRawOutput}
<pre class="raw-output">{messageContent || ''}</pre> <pre class="raw-output">{messageContent || ''}</pre>
{:else if isStructuredContent}
<ChatMessageAgenticContent
content={messageContent || ''}
isStreaming={isChatStreaming()}
highlightTurns={highlightAgenticTurns}
{message}
/>
{:else} {:else}
<MarkdownContent content={visibleMessageContent || ''} attachments={message.extra} /> <MarkdownContent content={messageContent || ''} attachments={message.extra} />
{/if} {/if}
{:else} {:else}
<div class="text-sm whitespace-pre-wrap"> <div class="text-sm whitespace-pre-wrap">
@@ -344,11 +310,13 @@
{/if} {/if}
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms} {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
{@const agentic = message.timings.agentic}
<ChatMessageStatistics <ChatMessageStatistics
promptTokens={message.timings.prompt_n} promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
promptMs={message.timings.prompt_ms} promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
predictedTokens={message.timings.predicted_n} predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
predictedMs={message.timings.predicted_ms} predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
agenticTimings={agentic}
onActiveViewChange={handleStatsViewChange} onActiveViewChange={handleStatsViewChange}
/> />
{:else if isLoading() && currentConfig.showMessageStats} {:else if isLoading() && currentConfig.showMessageStats}
@@ -360,7 +328,7 @@
{#if liveStats || genStats} {#if liveStats || genStats}
<ChatMessageStatistics <ChatMessageStatistics
isLive={true} isLive
isProcessingPrompt={!!isStillProcessingPrompt} isProcessingPrompt={!!isStillProcessingPrompt}
promptTokens={liveStats?.tokensProcessed} promptTokens={liveStats?.tokensProcessed}
promptMs={liveStats?.timeMs} promptMs={liveStats?.timeMs}
@@ -77,6 +77,10 @@
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]); editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
} }
function handleUploadedFilesChange(files: ChatUploadedFile[]) {
editCtx.setUploadedFiles(files);
}
$effect(() => { $effect(() => {
chatStore.setEditModeActive(handleFilesAdd); chatStore.setEditModeActive(handleFilesAdd);
@@ -95,9 +99,11 @@
attachments={editCtx.editedExtras} attachments={editCtx.editedExtras}
uploadedFiles={editCtx.editedUploadedFiles} uploadedFiles={editCtx.editedUploadedFiles}
placeholder="Edit your message..." placeholder="Edit your message..."
showMcpPromptButton
onValueChange={editCtx.setContent} onValueChange={editCtx.setContent}
onAttachmentRemove={handleAttachmentRemove} onAttachmentRemove={handleAttachmentRemove}
onUploadedFileRemove={handleUploadedFileRemove} onUploadedFileRemove={handleUploadedFileRemove}
onUploadedFilesChange={handleUploadedFilesChange}
onFilesAdd={handleFilesAdd} onFilesAdd={handleFilesAdd}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
@@ -0,0 +1,83 @@
<script lang="ts">
import {
ChatMessageActions,
ChatMessageEditForm,
ChatMessageMcpPromptContent
} from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { MessageRole, McpPromptVariant } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
message: DatabaseMessage;
mcpPrompt: DatabaseMessageExtraMcpPrompt;
siblingInfo?: ChatMessageSiblingInfo | null;
showDeleteDialog: boolean;
deletionInfo: {
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
} | null;
onCopy: () => void;
onEdit: () => void;
onDelete: () => void;
onConfirmDelete: () => void;
onNavigateToSibling?: (siblingId: string) => void;
onShowDeleteDialogChange: (show: boolean) => void;
}
let {
class: className = '',
message,
mcpPrompt,
siblingInfo = null,
showDeleteDialog,
deletionInfo,
onCopy,
onEdit,
onDelete,
onConfirmDelete,
onNavigateToSibling,
onShowDeleteDialogChange
}: Props = $props();
// Get edit context
const editCtx = getMessageEditContext();
</script>
<div
aria-label="MCP Prompt message with actions"
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group"
>
{#if editCtx.isEditing}
<ChatMessageEditForm />
{:else}
<ChatMessageMcpPromptContent
prompt={mcpPrompt}
variant={McpPromptVariant.MESSAGE}
class="w-full max-w-[80%]"
/>
{#if message.timestamp}
<div class="max-w-[80%]">
<ChatMessageActions
actionsPosition="right"
{deletionInfo}
justify="end"
{onConfirmDelete}
{onCopy}
{onDelete}
{onEdit}
{onNavigateToSibling}
{onShowDeleteDialogChange}
{siblingInfo}
{showDeleteDialog}
role={MessageRole.USER}
/>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,197 @@
<script lang="ts">
import { Card } from '$lib/components/ui/card';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { SvelteMap } from 'svelte/reactivity';
import { McpPromptVariant } from '$lib/enums';
import { TruncatedText } from '$lib/components/app/misc';
import * as Tooltip from '$lib/components/ui/tooltip';
interface ContentPart {
text: string;
argKey: string | null;
}
interface Props {
class?: string;
prompt: DatabaseMessageExtraMcpPrompt;
variant?: McpPromptVariant;
isLoading?: boolean;
loadError?: string;
}
let {
class: className = '',
prompt,
variant = McpPromptVariant.MESSAGE,
isLoading = false,
loadError
}: Props = $props();
let hoveredArgKey = $state<string | null>(null);
let argumentEntries = $derived(Object.entries(prompt.arguments ?? {}));
let hasArguments = $derived(prompt.arguments && Object.keys(prompt.arguments).length > 0);
let hasContent = $derived(prompt.content && prompt.content.trim().length > 0);
let contentParts = $derived.by((): ContentPart[] => {
if (!prompt.content || !hasArguments) {
return [{ text: prompt.content || '', argKey: null }];
}
const parts: ContentPart[] = [];
let remaining = prompt.content;
const valueToKey = new SvelteMap<string, string>();
for (const [key, value] of argumentEntries) {
if (value && value.trim()) {
valueToKey.set(value, key);
}
}
const sortedValues = [...valueToKey.keys()].sort((a, b) => b.length - a.length);
while (remaining.length > 0) {
let earliestMatch: { index: number; value: string; key: string } | null = null;
for (const value of sortedValues) {
const index = remaining.indexOf(value);
if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
earliestMatch = { index, value, key: valueToKey.get(value)! };
}
}
if (earliestMatch) {
if (earliestMatch.index > 0) {
parts.push({ text: remaining.slice(0, earliestMatch.index), argKey: null });
}
parts.push({ text: earliestMatch.value, argKey: earliestMatch.key });
remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
} else {
parts.push({ text: remaining, argKey: null });
break;
}
}
return parts;
});
let showArgBadges = $derived(hasArguments && !isLoading && !loadError);
let isAttachment = $derived(variant === McpPromptVariant.ATTACHMENT);
let textSizeClass = $derived(isAttachment ? 'text-xs' : 'text-md');
let paddingClass = $derived(isAttachment ? 'px-3 py-2' : 'px-3.75 py-2.5');
let maxHeightStyle = $derived(
isAttachment ? 'max-height: 6rem;' : 'max-height: var(--max-message-height);'
);
const serverFavicon = $derived(mcpStore.getServerFavicon(prompt.serverName));
const serverDisplayName = $derived(mcpStore.getServerDisplayName(prompt.serverName));
</script>
<div class="flex flex-col gap-2 {className}">
<div class="flex items-center justify-between gap-2">
<div class="inline-flex flex-wrap items-center gap-1.25 text-xs text-muted-foreground">
<Tooltip.Root>
<Tooltip.Trigger>
{#if serverFavicon}
<img
src={serverFavicon}
alt=""
class="h-3.5 w-3.5 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<span>{serverDisplayName}</span>
</Tooltip.Content>
</Tooltip.Root>
<TruncatedText text={prompt.name} />
</div>
{#if showArgBadges}
<div class="flex flex-wrap justify-end gap-1">
{#each argumentEntries as [key, value] (key)}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="rounded-sm bg-purple-200/60 px-1.5 py-0.5 text-[10px] leading-none text-purple-700 transition-opacity dark:bg-purple-800/40 dark:text-purple-300 {hoveredArgKey &&
hoveredArgKey !== key
? 'opacity-30'
: ''}"
onmouseenter={() => (hoveredArgKey = key)}
onmouseleave={() => (hoveredArgKey = null)}
>
{key}
</span>
</Tooltip.Trigger>
<Tooltip.Content>
<span class="max-w-xs break-all">{value}</span>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
{/if}
</div>
{#if loadError}
<Card
class="relative overflow-hidden rounded-[1.125rem] border border-destructive/50 bg-destructive/10 backdrop-blur-md"
>
<div
class="overflow-y-auto {paddingClass}"
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
<span class="{textSizeClass} text-destructive">{loadError}</span>
</div>
</Card>
{:else if isLoading}
<Card
class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 px-1 py-2 backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
>
<div
class="overflow-y-auto {paddingClass}"
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
<div class="space-y-2">
<div class="h-3 w-3/4 animate-pulse rounded bg-foreground/20"></div>
<div class="h-3 w-full animate-pulse rounded bg-foreground/20"></div>
<div class="h-3 w-5/6 animate-pulse rounded bg-foreground/20"></div>
</div>
</div>
</Card>
{:else if hasContent}
<Card
class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 py-0 text-foreground backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
>
<div
class="overflow-y-auto {paddingClass}"
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
<span class="{textSizeClass} whitespace-pre-wrap">
<!-- This formatting is needed to keep the text in proper shape -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each contentParts as part, i (i)}{#if part.argKey}<span
class="rounded-sm bg-purple-300/50 px-0.5 text-purple-900 transition-opacity dark:bg-purple-700/50 dark:text-purple-100 {hoveredArgKey &&
hoveredArgKey !== part.argKey
? 'opacity-30'
: ''}"
onmouseenter={() => (hoveredArgKey = part.argKey)}
onmouseleave={() => (hoveredArgKey = null)}>{part.text}</span
>{:else}<span class="transition-opacity {hoveredArgKey ? 'opacity-30' : ''}"
>{part.text}</span
>{/if}{/each}</span
>
</div>
</Card>
{/if}
</div>
@@ -1,8 +1,9 @@
<script lang="ts"> <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 { BadgeChatStatistic } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums'; import { ChatMessageStatsView } from '$lib/enums';
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
import { formatPerformanceTime } from '$lib/utils'; import { formatPerformanceTime } from '$lib/utils';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants'; import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
@@ -14,7 +15,9 @@
isLive?: boolean; isLive?: boolean;
isProcessingPrompt?: boolean; isProcessingPrompt?: boolean;
initialView?: ChatMessageStatsView; initialView?: ChatMessageStatsView;
agenticTimings?: ChatMessageAgenticTimings;
onActiveViewChange?: (view: ChatMessageStatsView) => void; onActiveViewChange?: (view: ChatMessageStatsView) => void;
hideSummary?: boolean;
} }
let { let {
@@ -25,7 +28,9 @@
isLive = false, isLive = false,
isProcessingPrompt = false, isProcessingPrompt = false,
initialView = ChatMessageStatsView.GENERATION, initialView = ChatMessageStatsView.GENERATION,
onActiveViewChange agenticTimings,
onActiveViewChange,
hideSummary = false
}: Props = $props(); }: Props = $props();
let activeView: ChatMessageStatsView = $derived(initialView); let activeView: ChatMessageStatsView = $derived(initialView);
@@ -87,6 +92,26 @@
// In live mode, generation tab is disabled until we have generation stats // In live mode, generation tab is disabled until we have generation stats
let isGenerationDisabled = $derived(isLive && !hasGenerationStats); 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> </script>
<div class="inline-flex items-center text-xs text-muted-foreground"> <div class="inline-flex items-center text-xs text-muted-foreground">
@@ -140,6 +165,52 @@
</p> </p>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </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>
<div class="flex items-center gap-1 px-2"> <div class="flex items-center gap-1 px-2">
@@ -164,6 +235,48 @@
value="{tokensPerSecond.toFixed(2)} t/s" value="{tokensPerSecond.toFixed(2)} t/s"
tooltipLabel="Generation speed" 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} {:else if hasPromptStats}
<BadgeChatStatistic <BadgeChatStatistic
class="bg-transparent" class="bg-transparent"
@@ -146,7 +146,7 @@
<Card <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" 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} 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 <div
class="relative transition-all duration-300 {isExpanded class="relative transition-all duration-300 {isExpanded
@@ -157,9 +157,9 @@
: 'max-height: none;'} : 'max-height: none;'}
> >
{#if currentConfig.renderUserContentAsMarkdown} {#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}"> <div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
<MarkdownContent <MarkdownContent
class="markdown-system-content overflow-auto" class="markdown-system-content -my-4"
content={message.content} content={message.content}
/> />
</div> </div>
@@ -1,68 +0,0 @@
<script lang="ts">
import { Brain } from '@lucide/svelte';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card';
import { config } from '$lib/stores/settings.svelte';
interface Props {
class?: string;
hasRegularContent?: boolean;
isStreaming?: boolean;
reasoningContent: string | null;
}
let {
class: className = '',
hasRegularContent = false,
isStreaming = false,
reasoningContent
}: Props = $props();
const currentConfig = config();
let isExpanded = $state(currentConfig.showThoughtInProgress);
$effect(() => {
if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) {
isExpanded = false;
}
});
</script>
<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
<Card class="gap-0 border-muted bg-muted/30 py-0">
<Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
<div class="flex items-center gap-2 text-muted-foreground">
<Brain class="h-4 w-4" />
<span class="text-sm font-medium">
{isStreaming ? 'Reasoning...' : 'Reasoning'}
</span>
</div>
<div
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
})}
>
<ChevronsUpDownIcon class="h-4 w-4" />
<span class="sr-only">Toggle reasoning content</span>
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="border-t border-muted px-3 pb-3">
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{reasoningContent ?? ''}
</div>
</div>
</div>
</Collapsible.Content>
</Card>
</Collapsible.Root>
@@ -82,7 +82,7 @@
{:else} {:else}
{#if message.extra && message.extra.length > 0} {#if message.extra && message.extra.length > 0}
<div class="mb-2 max-w-[80%]"> <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> </div>
{/if} {/if}
@@ -428,9 +428,9 @@
> >
<div class="w-full max-w-[48rem] px-4"> <div class="w-full max-w-[48rem] px-4">
<div class="mb-10 text-center" in:fade={{ duration: 300 }}> <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 {serverStore.props?.modalities?.audio
? 'Record audio, type a message ' ? 'Record audio, type a message '
: 'Type a message'} or upload files to get started : 'Type a message'} or upload files to get started
@@ -441,8 +441,10 @@
<div class="mb-4" in:fly={{ y: 10, duration: 250 }}> <div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
<Alert.Root variant="destructive"> <Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" /> <AlertTriangle class="h-4 w-4" />
<Alert.Title class="flex items-center justify-between"> <Alert.Title class="flex items-center justify-between">
<span>Server unavailable</span> <span>Server unavailable</span>
<button <button
onclick={() => serverStore.fetch()} onclick={() => serverStore.fetch()}
disabled={isServerLoading} disabled={isServerLoading}
@@ -452,6 +454,7 @@
{isServerLoading ? 'Retrying...' : 'Retry'} {isServerLoading ? 'Retrying...' : 'Retry'}
</button> </button>
</Alert.Title> </Alert.Title>
<Alert.Description>{serverError()}</Alert.Description> <Alert.Description>{serverError()}</Alert.Description>
</Alert.Root> </Alert.Root>
</div> </div>
@@ -467,7 +470,7 @@
onSend={handleSendMessage} onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()} onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd} onSystemPromptAdd={handleSystemPromptAdd}
showHelperText={true} showHelperText
bind:uploadedFiles bind:uploadedFiles
/> />
</div> </div>
@@ -110,6 +110,7 @@
class={className} class={className}
{disabled} {disabled}
{isLoading} {isLoading}
showMcpPromptButton
onFilesAdd={handleFilesAdd} onFilesAdd={handleFilesAdd}
{onStop} {onStop}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -14,14 +14,14 @@
</script> </script>
<header <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)]' ? 'md:left-[var(--sidebar-width)]'
: ''}" : ''}"
> >
<div class="pointer-events-auto flex items-center space-x-2"> <div class="pointer-events-auto flex items-center space-x-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon-lg"
onclick={toggleSettings} onclick={toggleSettings}
class="rounded-full backdrop-blur-lg" class="rounded-full backdrop-blur-lg"
> >
@@ -12,7 +12,9 @@
import { import {
ChatSettingsFooter, ChatSettingsFooter,
ChatSettingsImportExportTab, ChatSettingsImportExportTab,
ChatSettingsFields ChatSettingsFields,
McpLogo,
McpServersSettings
} from '$lib/components/app'; } from '$lib/components/app';
import { ScrollArea } from '$lib/components/ui/scroll-area'; import { ScrollArea } from '$lib/components/ui/scroll-area';
import { config, settingsStore } from '$lib/stores/settings.svelte'; import { config, settingsStore } from '$lib/stores/settings.svelte';
@@ -259,6 +261,32 @@
icon: Database, icon: Database,
fields: [] 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, title: SETTINGS_SECTION_TITLES.DEVELOPER,
icon: Code, icon: Code,
@@ -431,7 +459,7 @@
<!-- Mobile Header with Horizontal Scrollable Menu --> <!-- Mobile Header with Horizontal Scrollable Menu -->
<div class="flex flex-col pt-6 md:hidden"> <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 --> <!-- Horizontal Scrollable Category Menu with Navigation -->
<div class="relative flex items-center" style="scroll-padding: 1rem;"> <div class="relative flex items-center" style="scroll-padding: 1rem;">
<button <button
@@ -492,6 +520,19 @@
{#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT} {#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
<ChatSettingsImportExportTab /> <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} {:else}
<div class="space-y-6"> <div class="space-y-6">
<ChatSettingsFields <ChatSettingsFields
@@ -29,6 +29,7 @@
* - Horizontal scroll with smooth navigation arrows * - Horizontal scroll with smooth navigation arrows
* - Image thumbnails with lazy loading and error fallback * - Image thumbnails with lazy loading and error fallback
* - File type icons for non-image files (PDF, text, audio, etc.) * - 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 * - Click-to-preview with full-size dialog and download option
* - "View All" button when `limitToSingleRow` is enabled and content overflows * - "View All" button when `limitToSingleRow` is enabled and content overflows
* - Vision modality validation to warn about unsupported image uploads * - Vision modality validation to warn about unsupported image uploads
@@ -50,6 +51,33 @@
*/ */
export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachmentsList.svelte'; 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, * Thumbnail for non-image file attachments. Displays file type icon based on extension,
* file name (truncated), and file size. * 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'; 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 * FORM
* *
* Components for the chat input area. The form handles user input, file attachments, * 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 * - `chatStore` for message submission and generation control
* - `modelsStore` for model selection and validation * - `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 * The form exposes a public API for programmatic control from parent components
* (focus, height reset, model selector, validation). * (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 * **ChatForm** - Main chat input component with rich features
* *
* The primary input interface for composing and sending chat messages. * 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. * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
* *
* **Architecture:** * **Architecture:**
@@ -108,11 +131,14 @@ export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachme
* - IME-safe Enter key handling (waits for composition end) * - IME-safe Enter key handling (waits for composition end)
* - Shift+Enter for newline, Enter for submit * - Shift+Enter for newline, Enter for submit
* - Paste handler for files and long text (> {pasteLongTextToFileLen} chars file conversion) * - Paste handler for files and long text (> {pasteLongTextToFileLen} chars file conversion)
* - Keyboard shortcut `/` triggers MCP prompt picker
* *
* **Features:** * **Features:**
* - Auto-resizing textarea with placeholder * - Auto-resizing textarea with placeholder
* - File upload via button dropdown (images/text/PDF), drag-drop, or paste * - File upload via button dropdown (images/text/PDF), drag-drop, or paste
* - Audio recording with WAV conversion (when model supports audio) * - 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) * - Model selector integration (router mode)
* - Loading state with stop button, disabled state for errors * - 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'; 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 * Audio recording button with real-time recording indicator. Records audio
* and converts to WAV format for upload. Only visible when the active model * 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'; 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 * MESSAGES
@@ -248,6 +393,7 @@ export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
* *
* **User Messages:** * **User Messages:**
* - Shows attachments via ChatAttachmentsList * - Shows attachments via ChatAttachmentsList
* - Displays MCP prompts if present
* - Edit creates new branch or preserves responses * - Edit creates new branch or preserves responses
* *
* **Assistant Messages:** * **Assistant Messages:**
@@ -276,6 +422,46 @@ export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
*/ */
export { default as ChatMessage } from './ChatMessages/ChatMessage.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 * Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate
* buttons based on message role. Includes branching controls when message has siblings. * 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'; 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. * System message display component. Renders system messages with distinct styling.
* Visibility controlled by `showSystemMessage` config setting. * 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. * 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. * Supports inline editing mode with ChatMessageEditForm integration.
*/ */
export { default as ChatMessageUser } from './ChatMessages/ChatMessageUser.svelte'; export { default as ChatMessageUser } from './ChatMessages/ChatMessageUser.svelte';
@@ -385,7 +585,7 @@ export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditFo
* @example * @example
* ```svelte * ```svelte
* <!-- In chat route --> * <!-- In chat route -->
* <ChatScreen showCenteredEmpty={true} /> * <ChatScreen showCenteredEmpty />
* *
* <!-- In conversation route --> * <!-- In conversation route -->
* <ChatScreen showCenteredEmpty={false} /> * <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. * - **Sampling**: Temperature, top_p, top_k, min_p, repeat_penalty, etc.
* - **Penalties**: Frequency penalty, presence penalty, repeat last N * - **Penalties**: Frequency penalty, presence penalty, repeat last N
* - **Import/Export**: Conversation backup and restore * - **Import/Export**: Conversation backup and restore
* - **MCP**: MCP server management (opens DialogChatSettings with MCP tab)
* - **Developer**: Debug options, disable auto-scroll * - **Developer**: Debug options, disable auto-scroll
* *
* **Parameter Sync:** * **Parameter Sync:**
@@ -25,13 +25,9 @@
BOOL_TRUE_STRING, BOOL_TRUE_STRING,
SETTINGS_KEYS SETTINGS_KEYS
} from '$lib/constants'; } from '$lib/constants';
import { UrlPrefix } from '$lib/enums'; import { ColorMode, UrlProtocol } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files'; import { FileTypeText } from '$lib/enums/files';
import { import { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from '$lib/utils';
highlightCode,
detectIncompleteCodeBlock,
type IncompleteCodeBlock
} from '$lib/utils/code';
import '$styles/katex-custom.scss'; import '$styles/katex-custom.scss';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline'; import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline'; import githubLightCss from 'highlight.js/styles/github.css?inline';
@@ -505,7 +501,7 @@
// Don't handle data URLs or already-handled images // Don't handle data URLs or already-handled images
if ( if (
img.src.startsWith(UrlPrefix.DATA) || img.src.startsWith(UrlProtocol.DATA) ||
img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
) )
return; return;
@@ -560,7 +556,7 @@
$effect(() => { $effect(() => {
const currentMode = mode.current; const currentMode = mode.current;
const isDark = currentMode === 'dark'; const isDark = currentMode === ColorMode.DARK;
loadHighlightTheme(isDark); loadHighlightTheme(isDark);
}); });
@@ -622,7 +618,7 @@
<ActionIconsCodeBlock <ActionIconsCodeBlock
code={incompleteCodeBlock.code} code={incompleteCodeBlock.code}
language={incompleteCodeBlock.language || 'text'} language={incompleteCodeBlock.language || 'text'}
disabled={true} disabled
onPreview={(code, lang) => { onPreview={(code, lang) => {
previewCode = code; previewCode = code;
previewLanguage = lang; previewLanguage = lang;
@@ -5,6 +5,7 @@
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline'; import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline'; import githubLightCss from 'highlight.js/styles/github.css?inline';
import { ColorMode } from '$lib/enums';
interface Props { interface Props {
code: string; code: string;
@@ -39,7 +40,7 @@
$effect(() => { $effect(() => {
const currentMode = mode.current; const currentMode = mode.current;
const isDark = currentMode === 'dark'; const isDark = currentMode === ColorMode.DARK;
loadHighlightTheme(isDark); loadHighlightTheme(isDark);
}); });
@@ -70,7 +70,7 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte
* bind:open * bind:open
* icon={BrainIcon} * icon={BrainIcon}
* title="Thinking..." * title="Thinking..."
* isStreaming={true} * isStreaming
* > * >
* {reasoningContent} * {reasoningContent}
* </CollapsibleContentBlock> * </CollapsibleContentBlock>
@@ -0,0 +1,119 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Download } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { SyntaxHighlightedCode, ActionIconCopyToClipboard } from '$lib/components/app';
import {
getLanguageFromFilename,
isCodeResource,
isImageResource,
downloadResourceContent
} from '$lib/utils';
import { MimeTypeIncludes, MimeTypeText } from '$lib/enums';
import { DEFAULT_RESOURCE_FILENAME } from '$lib/constants';
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
extra: DatabaseMessageExtraMcpResource;
}
let { open = $bindable(), onOpenChange, extra }: Props = $props();
const serverName = $derived(mcpStore.getServerDisplayName(extra.serverName));
const favicon = $derived(mcpStore.getServerFavicon(extra.serverName));
function getLanguage(): string {
if (extra.mimeType?.includes(MimeTypeIncludes.JSON)) return MimeTypeIncludes.JSON;
if (extra.mimeType?.includes(MimeTypeIncludes.JAVASCRIPT)) return MimeTypeIncludes.JAVASCRIPT;
if (extra.mimeType?.includes(MimeTypeIncludes.TYPESCRIPT)) return MimeTypeIncludes.TYPESCRIPT;
const name = extra.name || extra.uri || '';
return getLanguageFromFilename(name) || 'plaintext';
}
function handleDownload() {
if (!extra.content) return;
downloadResourceContent(
extra.content,
extra.mimeType || MimeTypeText.PLAIN,
extra.name || DEFAULT_RESOURCE_FILENAME
);
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
<Dialog.Header>
<Dialog.Title class="pr-8">{extra.name}</Dialog.Title>
<Dialog.Description>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{extra.uri}</span>
{#if serverName}
<span class="flex items-center gap-1 text-xs text-muted-foreground">
·
{#if favicon}
<img
src={favicon}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
{serverName}
</span>
{/if}
{#if extra.mimeType}
<span class="rounded bg-muted px-1.5 py-0.5 text-xs">{extra.mimeType}</span>
{/if}
</div>
</Dialog.Description>
</Dialog.Header>
<div class="flex items-center justify-end gap-1">
<ActionIconCopyToClipboard
text={extra.content}
canCopy={!!extra.content}
ariaLabel="Copy content"
/>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
onclick={handleDownload}
disabled={!extra.content}
title="Download content"
>
<Download class="h-3.5 w-3.5" />
</Button>
</div>
<div class="overflow-auto">
{#if isImageResource(extra.mimeType, extra.uri) && extra.content}
<div class="flex items-center justify-center">
<img
src={extra.content.startsWith('data:')
? extra.content
: `data:${extra.mimeType || 'image/png'};base64,${extra.content}`}
alt={extra.name}
class="max-h-[70vh] max-w-full rounded object-contain"
/>
</div>
{:else if isCodeResource(extra.mimeType, extra.uri) && extra.content}
<SyntaxHighlightedCode code={extra.content} language={getLanguage()} maxHeight="70vh" />
{:else if extra.content}
<pre
class="max-h-[70vh] overflow-auto rounded-md border bg-muted/30 p-4 font-mono text-sm break-words whitespace-pre-wrap">{extra.content}</pre>
{:else}
<div class="py-8 text-center text-sm text-muted-foreground">No content available</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,394 @@
<script lang="ts">
import { FolderOpen, Plus, Loader2, Braces } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import {
mcpResources,
mcpTotalResourceCount,
mcpResourceStore
} from '$lib/stores/mcp-resources.svelte';
import {
McpResourceBrowser,
McpResourcePreview,
McpResourceTemplateForm
} from '$lib/components/app';
import { getResourceDisplayName } from '$lib/utils';
import type { MCPResourceInfo, MCPResourceContent, MCPResourceTemplateInfo } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onAttach?: (resource: MCPResourceInfo) => void;
preSelectedUri?: string;
}
let { open = $bindable(false), onOpenChange, onAttach, preSelectedUri }: Props = $props();
let selectedResources = new SvelteSet<string>();
let lastSelectedUri = $state<string | null>(null);
let isAttaching = $state(false);
let selectedTemplate = $state<MCPResourceTemplateInfo | null>(null);
let templatePreviewUri = $state<string | null>(null);
let templatePreviewContent = $state<MCPResourceContent[] | null>(null);
let templatePreviewLoading = $state(false);
let templatePreviewError = $state<string | null>(null);
const totalCount = $derived(mcpTotalResourceCount());
$effect(() => {
if (open) {
loadResources();
if (preSelectedUri) {
selectedResources.clear();
selectedResources.add(preSelectedUri);
lastSelectedUri = preSelectedUri;
}
}
});
async function loadResources() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (initialized) {
await mcpStore.fetchAllResources();
}
}
function handleOpenChange(newOpen: boolean) {
open = newOpen;
onOpenChange?.(newOpen);
if (!newOpen) {
selectedResources.clear();
lastSelectedUri = null;
clearTemplateState();
}
}
function clearTemplateState() {
selectedTemplate = null;
templatePreviewUri = null;
templatePreviewContent = null;
templatePreviewLoading = false;
templatePreviewError = null;
}
function handleTemplateSelect(template: MCPResourceTemplateInfo) {
selectedResources.clear();
lastSelectedUri = null;
if (
selectedTemplate?.uriTemplate === template.uriTemplate &&
selectedTemplate?.serverName === template.serverName
) {
clearTemplateState();
return;
}
selectedTemplate = template;
templatePreviewUri = null;
templatePreviewContent = null;
templatePreviewLoading = false;
templatePreviewError = null;
}
async function handleTemplateResolve(uri: string, serverName: string) {
templatePreviewUri = uri;
templatePreviewContent = null;
templatePreviewLoading = true;
templatePreviewError = null;
try {
const content = await mcpStore.readResourceByUri(serverName, uri);
if (content) {
templatePreviewContent = content;
} else {
templatePreviewError = 'Failed to read resource';
}
} catch (error) {
templatePreviewError = error instanceof Error ? error.message : 'Unknown error';
} finally {
templatePreviewLoading = false;
}
}
function handleTemplateCancelForm() {
clearTemplateState();
}
async function handleAttachTemplateResource() {
if (!templatePreviewUri || !selectedTemplate || !templatePreviewContent) return;
isAttaching = true;
try {
const knownResource = mcpResourceStore.findResourceByUri(templatePreviewUri);
if (knownResource) {
if (!mcpResourceStore.isAttached(knownResource.uri)) {
await mcpStore.attachResource(knownResource.uri);
}
toast.success(`Resource attached: ${knownResource.title || knownResource.name}`);
} else {
if (mcpResourceStore.isAttached(templatePreviewUri)) {
toast.info('Resource already attached');
handleOpenChange(false);
return;
}
const resourceInfo: MCPResourceInfo = {
uri: templatePreviewUri,
name: templatePreviewUri.split('/').pop() || templatePreviewUri,
serverName: selectedTemplate.serverName
};
const attachment = mcpResourceStore.addAttachment(resourceInfo);
mcpResourceStore.updateAttachmentContent(attachment.id, templatePreviewContent);
toast.success(`Resource attached: ${resourceInfo.name}`);
}
handleOpenChange(false);
} catch (error) {
console.error('Failed to attach template resource:', error);
} finally {
isAttaching = false;
}
}
function handleResourceSelect(resource: MCPResourceInfo, shiftKey: boolean = false) {
clearTemplateState();
if (shiftKey && lastSelectedUri) {
const allResources = getAllResourcesFlatInTreeOrder();
const lastIndex = allResources.findIndex((r) => r.uri === lastSelectedUri);
const currentIndex = allResources.findIndex((r) => r.uri === resource.uri);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
for (let i = start; i <= end; i++) {
selectedResources.add(allResources[i].uri);
}
}
} else {
selectedResources.clear();
selectedResources.add(resource.uri);
lastSelectedUri = resource.uri;
}
}
function handleResourceToggle(resource: MCPResourceInfo, checked: boolean) {
clearTemplateState();
if (checked) {
selectedResources.add(resource.uri);
} else {
selectedResources.delete(resource.uri);
}
lastSelectedUri = resource.uri;
}
function getAllResourcesFlatInTreeOrder(): MCPResourceInfo[] {
const allResources: MCPResourceInfo[] = [];
const resourcesMap = mcpResources();
for (const [serverName, serverRes] of resourcesMap.entries()) {
for (const resource of serverRes.resources) {
allResources.push({ ...resource, serverName });
}
}
return allResources.sort((a, b) => {
const aName = getResourceDisplayName(a);
const bName = getResourceDisplayName(b);
return aName.localeCompare(bName);
});
}
async function handleAttach() {
if (selectedResources.size === 0) return;
isAttaching = true;
try {
const allResources = getAllResourcesFlatInTreeOrder();
const resourcesToAttach = allResources.filter((r) => selectedResources.has(r.uri));
for (const resource of resourcesToAttach) {
await mcpStore.attachResource(resource.uri);
onAttach?.(resource);
}
const count = resourcesToAttach.length;
toast.success(
count === 1
? `Resource attached: ${resourcesToAttach[0].name}`
: `${count} resources attached`
);
handleOpenChange(false);
} catch (error) {
console.error('Failed to attach resources:', error);
} finally {
isAttaching = false;
}
}
const selectedTemplateUri = $derived(selectedTemplate?.uriTemplate ?? null);
const hasTemplateResult = $derived(
!!selectedTemplate && !!templatePreviewContent && !!templatePreviewUri
);
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content class="max-h-[80vh] !max-w-4xl overflow-hidden p-0">
<Dialog.Header class="border-b border-border/30 px-6 py-4">
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="h-5 w-5" />
<span>MCP Resources</span>
{#if totalCount > 0}
<span class="text-sm font-normal text-muted-foreground">({totalCount})</span>
{/if}
</Dialog.Title>
<Dialog.Description>
Browse and attach resources from connected MCP servers to your chat context.
</Dialog.Description>
</Dialog.Header>
<div class="flex h-[500px] min-w-0">
<div class="w-72 shrink-0 overflow-y-auto border-r border-border/30 p-4">
<McpResourceBrowser
onSelect={handleResourceSelect}
onToggle={handleResourceToggle}
onTemplateSelect={handleTemplateSelect}
selectedUris={selectedResources}
{selectedTemplateUri}
expandToUri={preSelectedUri}
/>
</div>
<div class="min-w-0 flex-1 overflow-auto p-4">
{#if selectedTemplate && !templatePreviewContent}
<div class="flex h-full flex-col">
<div class="mb-3 flex items-center gap-2">
<Braces class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium">
{selectedTemplate.title || selectedTemplate.name}
</span>
</div>
{#if selectedTemplate.description}
<p class="mb-4 text-xs text-muted-foreground">
{selectedTemplate.description}
</p>
{/if}
<div class="mb-4 rounded-md border border-border/50 bg-muted/30 px-3 py-2">
<p class="font-mono text-xs break-all text-muted-foreground">
{selectedTemplate.uriTemplate}
</p>
</div>
{#if templatePreviewLoading}
<div class="flex flex-1 items-center justify-center">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if templatePreviewError}
<div class="flex flex-1 flex-col items-center justify-center gap-2 text-red-500">
<span class="text-sm">{templatePreviewError}</span>
<Button
size="sm"
variant="outline"
onclick={() => {
templatePreviewError = null;
}}
>
Try again
</Button>
</div>
{:else}
<McpResourceTemplateForm
template={selectedTemplate}
onResolve={handleTemplateResolve}
onCancel={handleTemplateCancelForm}
/>
{/if}
</div>
{:else if hasTemplateResult}
<!-- Template resolved: show preview -->
<McpResourcePreview
resource={{
uri: templatePreviewUri ?? '',
name: templatePreviewUri?.split('/').pop() || (templatePreviewUri ?? ''),
serverName: selectedTemplate?.serverName || ''
}}
preloadedContent={templatePreviewContent}
/>
{:else if selectedResources.size === 1}
{@const allResources = getAllResourcesFlatInTreeOrder()}
{@const selectedResource = allResources.find((r) => selectedResources.has(r.uri))}
<McpResourcePreview resource={selectedResource ?? null} />
{:else if selectedResources.size > 1}
<div class="flex flex-col gap-10">
{#each getAllResourcesFlatInTreeOrder() as resource (resource.uri)}
{#if selectedResources.has(resource.uri)}
<McpResourcePreview {resource} />
{/if}
{/each}
</div>
{:else}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a resource to preview
</div>
{/if}
</div>
</div>
<Dialog.Footer class="border-t border-border/30 px-6 py-4">
<Button variant="outline" onclick={() => handleOpenChange(false)}>Cancel</Button>
{#if hasTemplateResult}
<Button onclick={handleAttachTemplateResource} disabled={isAttaching}>
{#if isAttaching}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Attach Resource
</Button>
{:else}
<Button onclick={handleAttach} disabled={selectedResources.size === 0 || isAttaching}>
{#if isAttaching}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Attach {selectedResources.size > 0 ? `(${selectedResources.size})` : 'Resource'}
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,39 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { McpLogo, McpServersSettings } from '$lib/components/app';
interface Props {
onOpenChange?: (open: boolean) => void;
open?: boolean;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
function handleClose() {
onOpenChange?.(false);
}
</script>
<Dialog.Root {open} onOpenChange={handleClose}>
<Dialog.Content
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
md:h-[80dvh] md:h-auto md:max-h-[80dvh] md:min-h-0 md:rounded-lg"
style="max-width: 56rem;"
>
<div class="grid gap-2 border-b border-border/30 p-4 md:p-6">
<Dialog.Title class="inline-flex items-center text-lg font-semibold">
<McpLogo class="mr-2 inline h-4 w-4" />
MCP Servers
</Dialog.Title>
<Dialog.Description class="text-sm text-muted-foreground">
Add and configure MCP servers to enable agentic tool execution capabilities.
</Dialog.Description>
</div>
<div class="flex-1 overflow-y-auto px-4 py-6">
<McpServersSettings />
</div>
</Dialog.Content>
</Dialog.Root>
@@ -414,3 +414,57 @@ export { default as DialogConversationSelection } from './DialogConversationSele
* ``` * ```
*/ */
export { default as DialogModelInformation } from './DialogModelInformation.svelte'; export { default as DialogModelInformation } from './DialogModelInformation.svelte';
/**
* **DialogMcpResources** - MCP resources browser dialog
*
* Dialog for browsing and attaching MCP resources to chat context.
* Displays resources from connected MCP servers in a tree structure
* with preview panel and multi-select support.
*
* **Architecture:**
* - Uses ShadCN Dialog with two-panel layout
* - Left panel: McpResourceBrowser with tree navigation
* - Right panel: McpResourcePreview for selected resource
* - Integrates with mcpStore for resource fetching and attachment
*
* **Features:**
* - Tree-based resource navigation by server and path
* - Single and multi-select with shift+click
* - Resource preview with content display
* - Quick attach button per resource
* - Batch attach for multiple selections
*
* @example
* ```svelte
* <DialogMcpResources
* bind:open={showResources}
* onAttach={handleResourceAttach}
* />
* ```
*/
export { default as DialogMcpResources } from './DialogMcpResources.svelte';
/**
* **DialogMcpResourcePreview** - MCP resource content preview
*
* Dialog for previewing the content of a stored MCP resource attachment.
* Displays the resource content with syntax highlighting for code,
* image rendering for images, and plain text for other content.
*
* **Features:**
* - Syntax highlighted code preview
* - Image rendering for image resources
* - Copy to clipboard and download actions
* - Server name and favicon display
* - MIME type badge
*
* @example
* ```svelte
* <DialogMcpResourcePreview
* bind:open={previewOpen}
* extra={mcpResourceExtra}
* />
* ```
*/
export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.svelte';
@@ -0,0 +1,78 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
interface Props {
name: string;
value: string;
suggestions?: string[];
isLoadingSuggestions?: boolean;
isAutocompleteActive?: boolean;
autocompleteIndex?: number;
onInput: (value: string) => void;
onKeydown: (event: KeyboardEvent) => void;
onBlur: () => void;
onFocus: () => void;
onSelectSuggestion: (value: string) => void;
}
let {
name,
value = '',
suggestions = [],
isLoadingSuggestions = false,
isAutocompleteActive = false,
autocompleteIndex = 0,
onInput,
onKeydown,
onBlur,
onFocus,
onSelectSuggestion
}: Props = $props();
</script>
<div class="relative grid gap-1">
<Label for="tpl-arg-{name}" class="mb-1 text-muted-foreground">
<span>
{name}
<span class="text-destructive">*</span>
</span>
{#if isLoadingSuggestions}
<span class="text-xs text-muted-foreground/50">...</span>
{/if}
</Label>
<Input
id="tpl-arg-{name}"
type="text"
{value}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={onFocus}
placeholder="Enter {name}"
autocomplete="off"
/>
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ y: -5, duration: 100 }}
>
{#each suggestions as suggestion, i (suggestion)}
<button
type="button"
onmousedown={() => onSelectSuggestion(suggestion)}
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
? 'bg-accent'
: ''}"
>
{suggestion}
</button>
{/each}
</div>
{/if}
</div>
@@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Plus, Trash2 } from '@lucide/svelte'; import { Plus, Trash2 } from '@lucide/svelte';
import { Input } from '$lib/components/ui/input'; 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'; import type { KeyValuePair } from '$lib/types';
interface Props { interface Props {
@@ -36,17 +41,41 @@
onPairsChange(pairs.filter((_, i) => i !== index)); 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]; const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], key }; newPairs[index] = { ...newPairs[index], key };
onPairsChange(newPairs); 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]; 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 }; newPairs[index] = { ...newPairs[index], value };
onPairsChange(newPairs); 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> </script>
<div class={className}> <div class={className}>
@@ -77,7 +106,9 @@
type="text" type="text"
placeholder={keyPlaceholder} placeholder={keyPlaceholder}
value={pair.key} value={pair.key}
maxlength={KEY_VALUE_PAIR_KEY_MAX_LENGTH}
oninput={(e) => updatePairKey(index, e.currentTarget.value)} oninput={(e) => updatePairKey(index, e.currentTarget.value)}
onblur={(e) => trimPairKey(index, e.currentTarget.value)}
class="flex-1" class="flex-1"
/> />
@@ -85,10 +116,12 @@
use:autoResizeTextarea use:autoResizeTextarea
placeholder={valuePlaceholder} placeholder={valuePlaceholder}
value={pair.value} value={pair.value}
maxlength={KEY_VALUE_PAIR_VALUE_MAX_LENGTH}
oninput={(e) => { oninput={(e) => {
updatePairValue(index, e.currentTarget.value); updatePairValue(index, e.currentTarget.value);
autoResizeTextarea(e.currentTarget); 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" 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" rows="1"
></textarea> ></textarea>
@@ -7,12 +7,18 @@
*/ */
/** /**
* **SearchInput** - Search field with clear button * **InputWithSuggestions** - Input field with autocomplete suggestions
* *
* Input field optimized for search with clear button and keyboard handling. * Text input with dropdown suggestions and keyboard navigation.
* Supports placeholder, autofocus, and change callbacks. * 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 * **KeyValuePairs** - Editable key-value list
@@ -28,3 +34,11 @@ export { default as SearchInput } from './SearchInput.svelte';
* - Auto-resize value textarea * - Auto-resize value textarea
*/ */
export { default as KeyValuePairs } from './KeyValuePairs.svelte'; export { default as KeyValuePairs } from './KeyValuePairs.svelte';
/**
* **SearchInput** - Search field with clear button
*
* Input field optimized for search with clear button and keyboard handling.
* Supports placeholder, autofocus, and change callbacks.
*/
export { default as SearchInput } from './SearchInput.svelte';
@@ -4,6 +4,7 @@ export * from './chat';
export * from './content'; export * from './content';
export * from './dialogs'; export * from './dialogs';
export * from './forms'; export * from './forms';
export * from './mcp';
export * from './misc'; export * from './misc';
export * from './models'; export * from './models';
export * from './navigation'; export * from './navigation';
@@ -0,0 +1,57 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import { MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
);
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpStore.getHealthCheckState(s.id);
return healthState.status !== HealthCheckStatus.ERROR;
})
);
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let extraServersCount = $derived(
Math.max(0, healthyEnabledMcpServers.length - MAX_DISPLAYED_MCP_AVATARS)
);
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, MAX_DISPLAYED_MCP_AVATARS)
.map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
.filter((f) => f.url !== null)
);
</script>
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<div class={cn('inline-flex items-center gap-1.5', className)}>
<div class="flex -space-x-1">
{#each mcpFavicons as favicon (favicon.id)}
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
<img
src={favicon.url}
alt=""
class="h-4 w-4"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
{/each}
</div>
{#if extraServersCount > 0}
<span class="text-xs text-muted-foreground">+{extraServersCount}</span>
{/if}
</div>
{/if}
@@ -0,0 +1,61 @@
<script lang="ts">
import { Wrench, Database, MessageSquare, FileText, Sparkles, ListChecks } from '@lucide/svelte';
import type { MCPCapabilitiesInfo } from '$lib/types';
import { Badge } from '$lib/components/ui/badge';
interface Props {
capabilities?: MCPCapabilitiesInfo;
}
let { capabilities }: Props = $props();
</script>
{#if capabilities}
{#if capabilities.server.tools}
<Badge variant="outline" class="h-5 gap-1 bg-green-50 px-1.5 text-[10px] dark:bg-green-950">
<Wrench class="h-3 w-3 text-green-600 dark:text-green-400" />
Tools
</Badge>
{/if}
{#if capabilities.server.resources}
<Badge variant="outline" class="h-5 gap-1 bg-blue-50 px-1.5 text-[10px] dark:bg-blue-950">
<Database class="h-3 w-3 text-blue-600 dark:text-blue-400" />
Resources
</Badge>
{/if}
{#if capabilities.server.prompts}
<Badge variant="outline" class="h-5 gap-1 bg-purple-50 px-1.5 text-[10px] dark:bg-purple-950">
<MessageSquare class="h-3 w-3 text-purple-600 dark:text-purple-400" />
Prompts
</Badge>
{/if}
{#if capabilities.server.logging}
<Badge variant="outline" class="h-5 gap-1 bg-orange-50 px-1.5 text-[10px] dark:bg-orange-950">
<FileText class="h-3 w-3 text-orange-600 dark:text-orange-400" />
Logging
</Badge>
{/if}
{#if capabilities.server.completions}
<Badge variant="outline" class="h-5 gap-1 bg-cyan-50 px-1.5 text-[10px] dark:bg-cyan-950">
<Sparkles class="h-3 w-3 text-cyan-600 dark:text-cyan-400" />
Completions
</Badge>
{/if}
{#if capabilities.server.tasks}
<Badge variant="outline" class="h-5 gap-1 bg-pink-50 px-1.5 text-[10px] dark:bg-pink-950">
<ListChecks class="h-3 w-3 text-pink-600 dark:text-pink-400" />
Tasks
</Badge>
{/if}
{/if}
@@ -0,0 +1,60 @@
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { cn } from '$lib/components/ui/utils';
import type { MCPConnectionLog } from '$lib/types';
import { formatTime, getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils';
interface Props {
logs: MCPConnectionLog[];
connectionTimeMs?: number;
defaultExpanded?: boolean;
class?: string;
}
let { logs, connectionTimeMs, defaultExpanded = false, class: className }: Props = $props();
let isExpanded = $derived(defaultExpanded);
</script>
{#if logs.length > 0}
<Collapsible.Root bind:open={isExpanded} class={className}>
<div class="space-y-2">
<Collapsible.Trigger
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span>Connection Log ({logs.length})</span>
{#if connectionTimeMs !== undefined}
<span class="ml-1">· Connected in {connectionTimeMs}ms</span>
{/if}
</Collapsible.Trigger>
</div>
<Collapsible.Content class="mt-2">
<div
class="max-h-64 space-y-0.5 overflow-y-auto rounded bg-muted/50 p-2 font-mono text-[10px]"
>
{#each logs as log (log.timestamp.getTime() + log.message)}
{@const Icon = getMcpLogLevelIcon(log.level)}
<div class={cn('flex items-start gap-1.5', getMcpLogLevelClass(log.level))}>
<span class="shrink-0 text-muted-foreground">
{formatTime(log.timestamp)}
</span>
<Icon class="mt-0.5 h-3 w-3 shrink-0" />
<span class="break-all">{log.message}</span>
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
@@ -0,0 +1,111 @@
<script>
let { class: className = '', style = '' } = $props();
</script>
<svg
class={className}
{style}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 174 174"
xmlns:xlink="http://www.w3.org/1999/xlink"
fill="none"
version="1.1"
><g id="shape-320b5b95-d08d-8089-8007-585a8e498184"
><defs
><clipPath
id="frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1"
class="frame-clip frame-clip-def"
><rect
rx="0"
ry="0"
x="0"
y="0"
width="174.00000000000045"
height="174"
transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
/></clipPath
></defs
><g class="frame-container-wrapper"
><g class="frame-container-blur"
><g class="frame-container-shadows"
><g clip-path="url(#frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1)" fill="none"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a8e498184"
><rect
rx="0"
ry="0"
x="0"
y="0"
width="174.00000000000045"
height="174"
transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
class="frame-background"
/></g
><g class="frame-children"
><g id="shape-320b5b95-d08d-8089-8007-585a974337b1"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b1"
><path
d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
fill="none"
stroke-linecap="round"
style="fill: none;"
/></g
><g
fill="none"
stroke-linecap="round"
id="strokes-b954dcef-3e3e-8015-8007-585acd4382b6-320b5b95-d08d-8089-8007-585a974337b1"
class="strokes"
><g class="stroke-shape"
><path
d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
/></g
></g
></g
><g id="shape-320b5b95-d08d-8089-8007-585a974337b2"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b2"
><path
d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
fill="none"
stroke-linecap="round"
style="fill: none;"
/></g
><g
fill="none"
stroke-linecap="round"
id="strokes-b954dcef-3e3e-8015-8007-585acd447743-320b5b95-d08d-8089-8007-585a974337b2"
class="strokes"
><g class="stroke-shape"
><path
d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
/></g
></g
></g
><g id="shape-320b5b95-d08d-8089-8007-585a974337b3"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b3"
><path
d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
fill="none"
stroke-linecap="round"
style="fill: none;"
/></g
><g
fill="none"
stroke-linecap="round"
id="strokes-b954dcef-3e3e-8015-8007-585acd44c5c9-320b5b95-d08d-8089-8007-585a974337b3"
class="strokes"
><g class="stroke-shape"
><path
d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
/></g
></g
></g
></g
></g
></g
></g
></g
></g
></svg
>
@@ -0,0 +1,154 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
import type { MCPServerResources, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { parseResourcePath } from '$lib/utils';
import McpResourceBrowserHeader from './McpResourceBrowserHeader.svelte';
import McpResourceBrowserEmptyState from './McpResourceBrowserEmptyState.svelte';
import McpResourceBrowserServerItem from './McpResourceBrowserServerItem.svelte';
interface Props {
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
selectedUris?: Set<string>;
selectedTemplateUri?: string | null;
expandToUri?: string;
class?: string;
}
let {
onSelect,
onToggle,
onTemplateSelect,
selectedUris = new Set(),
selectedTemplateUri,
expandToUri,
class: className
}: Props = $props();
let expandedServers = new SvelteSet<string>();
let expandedFolders = new SvelteSet<string>();
let searchQuery = $state('');
const resources = $derived(mcpResources());
const isLoading = $derived(mcpResourcesLoading());
const filteredResources = $derived.by(() => {
if (!searchQuery.trim()) {
return resources;
}
const query = searchQuery.toLowerCase();
const filtered = new SvelteMap();
for (const [serverName, serverRes] of resources.entries()) {
const filteredResources = serverRes.resources.filter((r) => {
return (
r.title?.toLowerCase().includes(query) ||
r.uri.toLowerCase().includes(query) ||
serverName.toLowerCase().includes(query)
);
});
const filteredTemplates = serverRes.templates.filter((t) => {
return (
t.name?.toLowerCase().includes(query) ||
t.title?.toLowerCase().includes(query) ||
t.uriTemplate.toLowerCase().includes(query) ||
serverName.toLowerCase().includes(query)
);
});
if (filteredResources.length > 0 || filteredTemplates.length > 0 || query.trim()) {
filtered.set(serverName, {
...serverRes,
resources: filteredResources,
templates: filteredTemplates
});
}
}
return filtered;
});
$effect(() => {
if (expandToUri && resources.size > 0) {
autoExpandToResource(expandToUri);
}
});
function autoExpandToResource(uri: string) {
for (const [serverName, serverRes] of resources.entries()) {
const resource = serverRes.resources.find((r) => r.uri === uri);
if (resource) {
expandedServers.add(serverName);
const pathParts = parseResourcePath(uri);
if (pathParts.length > 1) {
let currentPath = '';
for (let i = 0; i < pathParts.length - 1; i++) {
currentPath = `${currentPath}/${pathParts[i]}`;
const folderId = `${serverName}:${currentPath}`;
expandedFolders.add(folderId);
}
}
break;
}
}
}
function toggleServer(serverName: string) {
if (expandedServers.has(serverName)) {
expandedServers.delete(serverName);
} else {
expandedServers.add(serverName);
}
}
function toggleFolder(folderId: string) {
if (expandedFolders.has(folderId)) {
expandedFolders.delete(folderId);
} else {
expandedFolders.add(folderId);
}
}
function handleRefresh() {
mcpStore.fetchAllResources();
}
</script>
<div class={cn('flex flex-col gap-2', className)}>
<McpResourceBrowserHeader
{isLoading}
onRefresh={handleRefresh}
onSearch={(q) => (searchQuery = q)}
{searchQuery}
/>
<div class="flex flex-col gap-1">
{#if filteredResources.size === 0}
<McpResourceBrowserEmptyState {isLoading} />
{:else}
{#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)}
<McpResourceBrowserServerItem
serverName={serverName as string}
serverRes={serverRes as MCPServerResources}
isExpanded={expandedServers.has(serverName as string)}
{selectedUris}
{selectedTemplateUri}
{expandedFolders}
onToggleServer={() => toggleServer(serverName as string)}
onToggleFolder={toggleFolder}
{onSelect}
{onToggle}
{onTemplateSelect}
{searchQuery}
/>
{/each}
{/if}
</div>
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
interface Props {
isLoading: boolean;
}
let { isLoading }: Props = $props();
</script>
<div class="py-4 text-center text-sm text-muted-foreground">
{#if isLoading}
Loading resources...
{:else}
No resources available
{/if}
</div>
@@ -0,0 +1,41 @@
<script lang="ts">
import { RefreshCw, Loader2 } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { SearchInput } from '$lib/components/app/forms';
interface Props {
isLoading: boolean;
onRefresh: () => void;
onSearch?: (query: string) => void;
searchQuery?: string;
}
let { isLoading, onRefresh, onSearch, searchQuery = '' }: Props = $props();
</script>
<div class="flex flex-col gap-2">
<div class="mb-2 flex items-center gap-4">
<SearchInput
placeholder="Search resources..."
value={searchQuery}
onInput={(value) => onSearch?.(value)}
/>
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
onclick={onRefresh}
disabled={isLoading}
title="Refresh resources"
>
{#if isLoading}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<RefreshCw class="h-4 w-4" />
{/if}
</Button>
</div>
<h3 class="text-sm font-medium">Available resources</h3>
</div>
@@ -0,0 +1,235 @@
<script lang="ts">
import { FolderOpen, ChevronDown, ChevronRight, Loader2, Braces } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
import {
type ResourceTreeNode,
buildResourceTree,
countTreeResources,
sortTreeChildren
} from './mcp-resource-browser';
import { getDisplayName, getResourceIcon } from '$lib/utils';
interface Props {
serverName: string;
serverRes: MCPServerResources;
isExpanded: boolean;
selectedUris: Set<string>;
selectedTemplateUri?: string | null;
expandedFolders: SvelteSet<string>;
onToggleServer: () => void;
onToggleFolder: (folderId: string) => void;
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
searchQuery?: string;
}
let {
serverName,
serverRes,
isExpanded,
selectedUris,
selectedTemplateUri,
expandedFolders,
onToggleServer,
onToggleFolder,
onSelect,
onToggle,
onTemplateSelect,
searchQuery = ''
}: Props = $props();
const hasResources = $derived(serverRes.resources.length > 0);
const hasTemplates = $derived(serverRes.templates.length > 0);
const hasContent = $derived(hasResources || hasTemplates);
const displayName = $derived(mcpStore.getServerDisplayName(serverName));
const favicon = $derived(mcpStore.getServerFavicon(serverName));
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
const templateInfos = $derived<MCPResourceTemplateInfo[]>(
serverRes.templates.map((t) => ({
uriTemplate: t.uriTemplate,
name: t.name,
title: t.title,
description: t.description,
mimeType: t.mimeType,
serverName,
annotations: t.annotations,
icons: t.icons
}))
);
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
onSelect?.(resource, event.shiftKey);
}
function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
onToggle?.(resource, checked);
}
function isResourceSelected(resource: MCPResourceInfo): boolean {
return selectedUris.has(resource.uri);
}
</script>
{#snippet renderTreeNode(node: ResourceTreeNode, depth: number, parentPath: string)}
{@const isFolder = !node.resource && node.children.size > 0}
{@const folderId = `${serverName}:${parentPath}/${node.name}`}
{@const isFolderExpanded = expandedFolders.has(folderId)}
{#if isFolder}
{@const folderCount = countTreeResources(node)}
<Collapsible.Root open={isFolderExpanded} onOpenChange={() => onToggleFolder(folderId)}>
<Collapsible.Trigger
class="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50"
>
{#if isFolderExpanded}
<ChevronDown class="h-3 w-3" />
{:else}
<ChevronRight class="h-3 w-3" />
{/if}
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground" />
<span class="font-medium">{node.name}</span>
<span class="text-xs text-muted-foreground">({folderCount})</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each sortTreeChildren( [...node.children.values()] ) as child (child.resource?.uri || `${serverName}:${parentPath}/${node.name}/${child.name}`)}
{@render renderTreeNode(child, depth + 1, `${parentPath}/${node.name}`)}
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{:else if node.resource}
{@const resource = node.resource}
{@const ResourceIcon = getResourceIcon(resource.mimeType, resource.uri)}
{@const isSelected = isResourceSelected(resource)}
{@const resourceDisplayName = resource.title || getDisplayName(node.name)}
<div class="group flex w-full items-center gap-2">
{#if onToggle}
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean | 'indeterminate') =>
handleCheckboxChange(resource, checked === true)}
class="h-4 w-4"
/>
{/if}
<button
class={cn(
'flex flex-1 items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
'hover:bg-muted/50',
isSelected && 'bg-muted'
)}
onclick={(e: MouseEvent) => handleResourceClick(resource, e)}
title={resourceDisplayName}
>
<ResourceIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-left">
{resourceDisplayName}
</span>
</button>
</div>
{/if}
{/snippet}
<Collapsible.Root open={isExpanded} onOpenChange={onToggleServer}>
<Collapsible.Trigger
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span class="inline-flex flex-col items-start text-left">
<span class="inline-flex items-center justify-start gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
{displayName}
</span>
<span class="text-xs text-muted-foreground">
({serverRes.resources.length} resource{serverRes.resources.length !== 1
? 's'
: ''}{#if hasTemplates}, {serverRes.templates.length} template{serverRes.templates
.length !== 1
? 's'
: ''}{/if})
</span>
</span>
{#if serverRes.loading}
<Loader2 class="ml-auto h-3 w-3 animate-spin text-muted-foreground" />
{/if}
</Collapsible.Trigger>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#if serverRes.error}
<div class="py-1 text-xs text-red-500">
Error: {serverRes.error}
</div>
{:else if !hasContent}
<div class="py-1 text-xs text-muted-foreground">No resources</div>
{:else}
{#if hasResources}
{#each sortTreeChildren( [...resourceTree.children.values()] ) as child (child.resource?.uri || `${serverName}:${child.name}`)}
{@render renderTreeNode(child, 1, '')}
{/each}
{/if}
{#if hasTemplates && onTemplateSelect}
{#if hasResources}
<div class="my-1 border-t border-border/30"></div>
{/if}
<div
class="py-0.5 text-[11px] font-medium tracking-wide text-muted-foreground/70 uppercase"
>
Templates
</div>
{#each templateInfos as template (template.uriTemplate)}
<button
class={cn(
'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
'hover:bg-muted/50',
selectedTemplateUri === template.uriTemplate && 'bg-muted'
)}
onclick={() => onTemplateSelect(template)}
title={template.uriTemplate}
>
<Braces class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-left">
{template.title || template.name}
</span>
</button>
{/each}
{/if}
{/if}
</div>
</Collapsible.Content>
</Collapsible.Root>
@@ -0,0 +1,118 @@
import type { MCPResource, MCPResourceInfo } from '$lib/types';
import { parseResourcePath } from '$lib/utils';
export interface ResourceTreeNode {
name: string;
resource?: MCPResourceInfo;
children: Map<string, ResourceTreeNode>;
isFiltered?: boolean;
}
function resourceMatchesSearch(resource: MCPResource, query: string): boolean {
return (
resource.title?.toLowerCase().includes(query) || resource.uri.toLowerCase().includes(query)
);
}
export function buildResourceTree(
resourceList: MCPResource[],
serverName: string,
searchQuery?: string
): ResourceTreeNode {
const root: ResourceTreeNode = { name: 'root', children: new Map() };
if (!searchQuery || !searchQuery.trim()) {
for (const resource of resourceList) {
const pathParts = parseResourcePath(resource.uri);
let current = root;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (!current.children.has(part)) {
current.children.set(part, { name: part, children: new Map() });
}
current = current.children.get(part)!;
}
const fileName = pathParts[pathParts.length - 1] || resource.name;
current.children.set(resource.uri, {
name: fileName,
resource: { ...resource, serverName },
children: new Map()
});
}
return root;
}
const query = searchQuery.toLowerCase();
// Build tree with filtering
for (const resource of resourceList) {
if (!resourceMatchesSearch(resource, query)) continue;
const pathParts = parseResourcePath(resource.uri);
let current = root;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (!current.children.has(part)) {
current.children.set(part, { name: part, children: new Map(), isFiltered: true });
}
current = current.children.get(part)!;
}
const fileName = pathParts[pathParts.length - 1] || resource.name;
current.children.set(resource.uri, {
name: fileName,
resource: { ...resource, serverName },
children: new Map(),
isFiltered: true
});
}
function cleanupEmptyFolders(node: ResourceTreeNode): boolean {
if (node.resource) return true;
const toDelete: string[] = [];
for (const [name, child] of node.children.entries()) {
if (!cleanupEmptyFolders(child)) {
toDelete.push(name);
}
}
for (const name of toDelete) {
node.children.delete(name);
}
return node.children.size > 0;
}
cleanupEmptyFolders(root);
return root;
}
export function countTreeResources(node: ResourceTreeNode): number {
if (node.resource) return 1;
let count = 0;
for (const child of node.children.values()) {
count += countTreeResources(child);
}
return count;
}
export function sortTreeChildren(children: ResourceTreeNode[]): ResourceTreeNode[] {
return children.sort((a, b) => {
const aIsFolder = !a.resource && a.children.size > 0;
const bIsFolder = !b.resource && b.children.size > 0;
if (aIsFolder && !bIsFolder) return -1;
if (!aIsFolder && bIsFolder) return 1;
return a.name.localeCompare(b.name);
});
}
@@ -0,0 +1,175 @@
<script lang="ts">
import { FileText, Loader2, AlertCircle, Download } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
isImageMimeType,
createBase64DataUrl,
getResourceTextContent,
getResourceBlobContent,
downloadResourceContent
} from '$lib/utils';
import { MimeTypeApplication, MimeTypeText } from '$lib/enums';
import { ActionIconCopyToClipboard } from '$lib/components/app';
import type { MCPResourceInfo, MCPResourceContent } from '$lib/types';
interface Props {
resource: MCPResourceInfo | null;
/** Pre-loaded content (e.g., from template resolution). Skips store fetch when provided. */
preloadedContent?: MCPResourceContent[] | null;
class?: string;
}
let { resource, preloadedContent, class: className }: Props = $props();
let content = $state<MCPResourceContent[] | null>(null);
let isLoading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (resource) {
if (preloadedContent) {
content = preloadedContent;
isLoading = false;
error = null;
} else {
loadContent(resource.uri);
}
} else {
content = null;
error = null;
}
});
async function loadContent(uri: string) {
isLoading = true;
error = null;
try {
const result = await mcpStore.readResource(uri);
if (result) {
content = result;
} else {
error = 'Failed to load resource content';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
isLoading = false;
}
}
function handleDownload() {
const text = getResourceTextContent(content);
if (!text || !resource) return;
downloadResourceContent(
text,
resource.mimeType || MimeTypeText.PLAIN,
resource.name || 'resource.txt'
);
}
</script>
<div class={cn('flex flex-col gap-3', className)}>
{#if !resource}
<div class="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<FileText class="h-8 w-8 opacity-50" />
<span class="text-sm">Select a resource to preview</span>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="truncate font-medium">{resource.title || resource.name}</h3>
<p class="truncate text-xs text-muted-foreground">{resource.uri}</p>
{#if resource.description}
<p class="mt-1 text-sm text-muted-foreground">{resource.description}</p>
{/if}
</div>
<div class="flex items-center gap-1">
<ActionIconCopyToClipboard
text={getResourceTextContent(content)}
canCopy={!isLoading && !!getResourceTextContent(content)}
ariaLabel="Copy content"
/>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
onclick={handleDownload}
disabled={isLoading || !getResourceTextContent(content)}
title="Download content"
>
<Download class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="min-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3 break-all">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if error}
<div class="flex flex-col items-center justify-center gap-2 py-8 text-red-500">
<AlertCircle class="h-6 w-6" />
<span class="text-sm">{error}</span>
</div>
{:else if content}
{@const textContent = getResourceTextContent(content)}
{@const blobContent = getResourceBlobContent(content)}
{#if textContent}
<pre class="font-mono text-xs break-words whitespace-pre-wrap">{textContent}</pre>
{/if}
{#each blobContent as blob (blob.uri)}
{#if isImageMimeType(blob.mimeType ?? MimeTypeApplication.OCTET_STREAM)}
<img
src={createBase64DataUrl(
blob.mimeType ?? MimeTypeApplication.OCTET_STREAM,
blob.blob
)}
alt="Resource content"
class="max-w-full rounded"
/>
{:else}
<div class="flex items-center gap-2 rounded bg-muted p-2 text-sm text-muted-foreground">
<FileText class="h-4 w-4" />
<span>Binary content ({blob.mimeType || 'unknown type'})</span>
</div>
{/if}
{/each}
{#if !textContent && blobContent.length === 0}
<div class="py-4 text-center text-sm text-muted-foreground">No content available</div>
{/if}
{/if}
</div>
{#if resource.mimeType || resource.annotations}
<div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
{#if resource.mimeType}
<span class="rounded bg-muted px-1.5 py-0.5">{resource.mimeType}</span>
{/if}
{#if resource.annotations?.priority !== undefined}
<span class="rounded bg-muted px-1.5 py-0.5">
Priority: {resource.annotations.priority}
</span>
{/if}
<span class="rounded bg-muted px-1.5 py-0.5">
Server: {resource.serverName}
</span>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,171 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { InputWithSuggestions } from '$lib/components/app';
import { KeyboardKey } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { MIN_AUTOCOMPLETE_INPUT_LENGTH } from '$lib/constants';
import type { MCPResourceTemplateInfo } from '$lib/types';
import {
debounce,
extractTemplateVariables,
expandTemplate,
isTemplateComplete
} from '$lib/utils';
interface Props {
template: MCPResourceTemplateInfo;
onResolve: (uri: string, serverName: string) => void;
onCancel: () => void;
}
let { template, onResolve, onCancel }: Props = $props();
const variables = $derived(extractTemplateVariables(template.uriTemplate));
let values = $state<Record<string, string>>({});
let suggestions = $state<Record<string, string[]>>({});
let loadingSuggestions = $state<Record<string, boolean>>({});
let activeAutocomplete = $state<string | null>(null);
let autocompleteIndex = $state(0);
const expandedUri = $derived(expandTemplate(template.uriTemplate, values));
const isComplete = $derived(isTemplateComplete(template.uriTemplate, values));
const fetchCompletions = debounce(async (argName: string, value: string) => {
if (value.length < 1) {
suggestions[argName] = [];
return;
}
loadingSuggestions[argName] = true;
try {
const result = await mcpStore.getResourceCompletions(
template.serverName,
template.uriTemplate,
argName,
value
);
if (result && result.values.length > 0) {
const filteredValues = result.values.filter((v) => v.trim() !== '');
if (filteredValues.length > 0) {
suggestions[argName] = filteredValues;
activeAutocomplete = argName;
autocompleteIndex = 0;
} else {
suggestions[argName] = [];
}
} else {
suggestions[argName] = [];
}
} catch (error) {
console.error('[McpResourceTemplateForm] Failed to fetch completions:', error);
suggestions[argName] = [];
} finally {
loadingSuggestions[argName] = false;
}
}, 200);
function handleArgInput(argName: string, value: string) {
values[argName] = value;
fetchCompletions(argName, value);
}
function selectSuggestion(argName: string, value: string) {
values[argName] = value;
suggestions[argName] = [];
activeAutocomplete = null;
}
function handleArgKeydown(event: KeyboardEvent, argName: string) {
const argSuggestions = suggestions[argName] ?? [];
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
event.stopPropagation();
if (argSuggestions.length > 0 && activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
} else {
onCancel();
}
return;
}
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
} else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
event.preventDefault();
event.stopPropagation();
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
}
}
function handleArgBlur(argName: string) {
setTimeout(() => {
if (activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
}
}, 150);
}
function handleArgFocus(argName: string) {
const value = values[argName] ?? '';
if (value.length >= MIN_AUTOCOMPLETE_INPUT_LENGTH) {
fetchCompletions(argName, value);
}
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (isComplete) {
onResolve(expandedUri, template.serverName);
}
}
</script>
<form onsubmit={handleSubmit} class="space-y-3">
{#each variables as variable (variable.name)}
<InputWithSuggestions
name={variable.name}
value={values[variable.name] ?? ''}
suggestions={suggestions[variable.name] ?? []}
isLoadingSuggestions={loadingSuggestions[variable.name] ?? false}
isAutocompleteActive={activeAutocomplete === variable.name}
autocompleteIndex={activeAutocomplete === variable.name ? autocompleteIndex : 0}
onInput={(value) => handleArgInput(variable.name, value)}
onKeydown={(e) => handleArgKeydown(e, variable.name)}
onBlur={() => handleArgBlur(variable.name)}
onFocus={() => handleArgFocus(variable.name)}
onSelectSuggestion={(value) => selectSuggestion(variable.name, value)}
/>
{/each}
{#if isComplete}
<div class="rounded-md bg-muted/50 px-3 py-2">
<p class="text-xs text-muted-foreground">Resolved URI:</p>
<p class="mt-0.5 font-mono text-xs break-all">{expandedUri}</p>
</div>
{/if}
<div class="flex justify-end gap-2 pt-1">
<Button type="button" size="sm" variant="secondary" onclick={onCancel}>Cancel</Button>
<Button size="sm" type="submit" disabled={!isComplete}>Read Resource</Button>
</div>
</form>
@@ -0,0 +1,192 @@
<script lang="ts">
import { tick } from 'svelte';
import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
import { HealthCheckStatus } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
McpServerCardActions,
McpServerCardDeleteDialog,
McpServerCardEditForm,
McpServerCardHeader,
McpServerCardToolsList,
McpConnectionLogs,
McpServerInfo
} from '$lib/components/app/mcp';
interface Props {
server: MCPServerSettingsEntry;
faviconUrl: string | null;
enabled?: boolean;
onToggle: (enabled: boolean) => void;
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
onDelete: () => void;
}
let { server, faviconUrl, enabled, onToggle, onUpdate, onDelete }: Props = $props();
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
let displayName = $derived(mcpStore.getServerLabel(server));
let isIdle = $derived(healthState.status === HealthCheckStatus.IDLE);
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.CONNECTING);
let isConnected = $derived(healthState.status === HealthCheckStatus.SUCCESS);
let isError = $derived(healthState.status === HealthCheckStatus.ERROR);
let showSkeleton = $derived(isIdle || isHealthChecking);
let errorMessage = $derived(
healthState.status === HealthCheckStatus.ERROR ? healthState.message : undefined
);
let tools = $derived(healthState.status === HealthCheckStatus.SUCCESS ? healthState.tools : []);
let connectionLogs = $derived(
healthState.status === HealthCheckStatus.CONNECTING ||
healthState.status === HealthCheckStatus.SUCCESS ||
healthState.status === HealthCheckStatus.ERROR
? healthState.logs
: []
);
let successState = $derived(
healthState.status === HealthCheckStatus.SUCCESS ? healthState : null
);
let serverInfo = $derived(successState?.serverInfo);
let capabilities = $derived(successState?.capabilities);
let transportType = $derived(successState?.transportType);
let protocolVersion = $derived(successState?.protocolVersion);
let connectionTimeMs = $derived(successState?.connectionTimeMs);
let instructions = $derived(successState?.instructions);
let isEditing = $derived(!server.url.trim());
let showDeleteDialog = $state(false);
let editFormRef: McpServerCardEditForm | null = $state(null);
function handleHealthCheck() {
mcpStore.runHealthCheck(server);
}
async function startEditing() {
isEditing = true;
await tick();
editFormRef?.setInitialValues(server.url, server.headers || '', server.useProxy || false);
}
function cancelEditing() {
if (server.url.trim()) {
isEditing = false;
} else {
onDelete();
}
}
function saveEditing(url: string, headers: string, useProxy: boolean) {
onUpdate({
url: url,
headers: headers || undefined,
useProxy: useProxy
});
isEditing = false;
if (server.enabled && url) {
setTimeout(() => mcpStore.runHealthCheck({ ...server, url, useProxy }), 100);
}
}
function handleDeleteClick() {
showDeleteDialog = true;
}
</script>
<Card.Root class="!gap-3 bg-muted/30 p-4">
{#if isEditing}
<McpServerCardEditForm
bind:this={editFormRef}
serverId={server.id}
serverUrl={server.url}
serverUseProxy={server.useProxy}
onSave={saveEditing}
onCancel={cancelEditing}
/>
{:else}
<McpServerCardHeader
{displayName}
{faviconUrl}
enabled={enabled ?? server.enabled}
disabled={isError}
{onToggle}
{serverInfo}
{capabilities}
{transportType}
/>
{#if isError && errorMessage}
<p class="text-xs text-destructive">{errorMessage}</p>
{/if}
{#if isConnected && serverInfo?.description}
<p class="line-clamp-2 text-xs text-muted-foreground">
{serverInfo.description}
</p>
{/if}
<div class="grid gap-3">
{#if showSkeleton}
<div class="space-y-2">
<div class="flex items-center gap-2">
<Skeleton class="h-4 w-4 rounded" />
<Skeleton class="h-3 w-24" />
</div>
<div class="flex flex-wrap gap-1.5">
<Skeleton class="h-5 w-16 rounded-full" />
<Skeleton class="h-5 w-20 rounded-full" />
<Skeleton class="h-5 w-14 rounded-full" />
</div>
</div>
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<Skeleton class="h-4 w-4 rounded" />
<Skeleton class="h-3 w-32" />
</div>
</div>
{:else}
{#if isConnected && instructions}
<McpServerInfo {instructions} />
{/if}
{#if tools.length > 0}
<McpServerCardToolsList {tools} />
{/if}
{#if connectionLogs.length > 0}
<McpConnectionLogs logs={connectionLogs} {connectionTimeMs} />
{/if}
{/if}
</div>
<div class="flex justify-between gap-4">
{#if showSkeleton}
<Skeleton class="h-3 w-28" />
{:else if protocolVersion}
<div class="flex flex-wrap items-center gap-1">
<span class="text-[10px] text-muted-foreground">
Protocol version: {protocolVersion}
</span>
</div>
{/if}
<McpServerCardActions
{isHealthChecking}
onEdit={startEditing}
onRefresh={handleHealthCheck}
onDelete={handleDeleteClick}
/>
</div>
{/if}
</Card.Root>
<McpServerCardDeleteDialog
bind:open={showDeleteDialog}
{displayName}
onOpenChange={(open) => (showDeleteDialog = open)}
onConfirm={onDelete}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
import { Trash2, RefreshCw, Pencil } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
isHealthChecking: boolean;
onEdit: () => void;
onRefresh: () => void;
onDelete: () => void;
}
let { isHealthChecking, onEdit, onRefresh, onDelete }: Props = $props();
</script>
<div class="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={onEdit} aria-label="Edit">
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={onRefresh}
disabled={isHealthChecking}
aria-label="Refresh"
>
<RefreshCw class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive/10"
onclick={onDelete}
aria-label="Delete"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
interface Props {
open: boolean;
displayName: string;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
let { open = $bindable(), displayName, onOpenChange, onConfirm }: Props = $props();
</script>
<AlertDialog.Root bind:open {onOpenChange}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Server</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete <strong>{displayName}</strong>? This action cannot be
undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
onclick={onConfirm}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,64 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { McpServerForm } from '$lib/components/app/mcp';
interface Props {
serverId: string;
serverUrl: string;
serverUseProxy?: boolean;
onSave: (url: string, headers: string, useProxy: boolean) => void;
onCancel: () => void;
}
let { serverId, serverUrl, serverUseProxy = false, onSave, onCancel }: Props = $props();
let editUrl = $derived(serverUrl);
let editHeaders = $state('');
let editUseProxy = $derived(serverUseProxy);
let urlError = $derived.by(() => {
if (!editUrl.trim()) return 'URL is required';
try {
new URL(editUrl);
return null;
} catch {
return 'Invalid URL format';
}
});
let canSave = $derived(!urlError);
function handleSave() {
if (!canSave) return;
onSave(editUrl.trim(), editHeaders.trim(), editUseProxy);
}
export function setInitialValues(url: string, headers: string, useProxy: boolean) {
editUrl = url;
editHeaders = headers;
editUseProxy = useProxy;
}
</script>
<div class="space-y-4">
<p class="font-medium">Configure Server</p>
<McpServerForm
url={editUrl}
headers={editHeaders}
useProxy={editUseProxy}
onUrlChange={(v) => (editUrl = v)}
onHeadersChange={(v) => (editHeaders = v)}
onUseProxyChange={(v) => (editUseProxy = v)}
urlError={editUrl ? urlError : null}
id={serverId}
/>
<div class="flex items-center justify-end gap-2">
<Button variant="secondary" size="sm" onclick={onCancel}>Cancel</Button>
<Button size="sm" onclick={handleSave} disabled={!canSave}>
{serverUrl.trim() ? 'Update' : 'Add'}
</Button>
</div>
</div>
@@ -0,0 +1,97 @@
<script lang="ts">
import { Cable, ExternalLink } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import { Badge } from '$lib/components/ui/badge';
import { McpCapabilitiesBadges } from '$lib/components/app/mcp';
import { MCP_TRANSPORT_LABELS, MCP_TRANSPORT_ICONS } from '$lib/constants';
import { MCPTransportType } from '$lib/enums';
import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
interface Props {
displayName: string;
faviconUrl: string | null;
enabled: boolean;
disabled?: boolean;
onToggle: (enabled: boolean) => void;
serverInfo?: MCPServerInfo;
capabilities?: MCPCapabilitiesInfo;
transportType?: MCPTransportType;
}
let {
displayName,
faviconUrl,
enabled,
disabled = false,
onToggle,
serverInfo,
capabilities,
transportType
}: Props = $props();
</script>
<div class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="grid min-w-0 gap-3">
<div class="flex items-center gap-2 overflow-hidden">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-5 w-5 shrink-0 rounded"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<div class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted">
<Cable class="h-3 w-3 text-muted-foreground" />
</div>
{/if}
<p class="min-w-0 shrink-0 truncate leading-none font-medium">{displayName}</p>
{#if serverInfo?.version}
<Badge variant="secondary" class="h-4 min-w-0 truncate px-1 text-[10px]">
v{serverInfo.version}
</Badge>
{/if}
{#if serverInfo?.websiteUrl}
<a
href={serverInfo.websiteUrl}
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Open website"
>
<ExternalLink class="h-3 w-3" />
</a>
{/if}
</div>
{#if capabilities || transportType}
<div class="flex flex-wrap items-center gap-1">
{#if transportType}
{@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]}
<Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">
{#if TransportIcon}
<TransportIcon class="h-3 w-3" />
{/if}
{MCP_TRANSPORT_LABELS[transportType] || transportType}
</Badge>
{/if}
{#if capabilities}
<McpCapabilitiesBadges {capabilities} />
{/if}
</div>
{/if}
</div>
<div class="flex shrink-0 items-center pl-2">
<Switch checked={enabled} {disabled} onCheckedChange={onToggle} />
</div>
</div>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { Badge } from '$lib/components/ui/badge';
interface Tool {
name: string;
description?: string;
}
interface Props {
tools: Tool[];
}
let { tools }: Props = $props();
let isExpanded = $state(false);
let toolsCount = $derived(tools.length);
</script>
<Collapsible.Root bind:open={isExpanded}>
<Collapsible.Trigger
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span>{toolsCount} tools available · Show details</span>
</Collapsible.Trigger>
<Collapsible.Content class="mt-2">
<div class="max-h-64 space-y-3 overflow-y-auto">
{#each tools as tool (tool.name)}
<div>
<Badge variant="secondary">{tool.name}</Badge>
{#if tool.description}
<p class="mt-1 text-xs text-muted-foreground">{tool.description}</p>
{/if}
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
@@ -0,0 +1,34 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
</script>
<Card.Root class="grid gap-3 p-4">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Skeleton class="h-5 w-5 rounded" />
<Skeleton class="h-5 w-28" />
<Skeleton class="h-5 w-12 rounded-full" />
</div>
<Skeleton class="h-6 w-11 rounded-full" />
</div>
<div class="flex flex-wrap gap-1.5">
<Skeleton class="h-5 w-14 rounded-full" />
<Skeleton class="h-5 w-12 rounded-full" />
<Skeleton class="h-5 w-16 rounded-full" />
</div>
<div class="space-y-1.5">
<Skeleton class="h-4 w-40" />
<Skeleton class="h-4 w-52" />
</div>
<Skeleton class="h-3.5 w-36" />
<div class="flex justify-end gap-2">
<Skeleton class="h-8 w-8 rounded" />
<Skeleton class="h-8 w-8 rounded" />
<Skeleton class="h-8 w-8 rounded" />
</div>
</Card.Root>
@@ -0,0 +1,88 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Switch } from '$lib/components/ui/switch';
import { KeyValuePairs } from '$lib/components/app';
import type { KeyValuePair } from '$lib/types';
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
import { UrlProtocol } from '$lib/enums';
import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants';
interface Props {
url: string;
headers: string;
useProxy?: boolean;
onUrlChange: (url: string) => void;
onHeadersChange: (headers: string) => void;
onUseProxyChange?: (useProxy: boolean) => void;
urlError?: string | null;
id?: string;
}
let {
url,
headers,
useProxy = false,
onUrlChange,
onHeadersChange,
onUseProxyChange,
urlError = null,
id = 'server'
}: Props = $props();
let isWebSocket = $derived(
url.toLowerCase().startsWith(UrlProtocol.WEBSOCKET) ||
url.toLowerCase().startsWith(UrlProtocol.WEBSOCKET_SECURE)
);
let headerPairs = $derived<KeyValuePair[]>(parseHeadersToArray(headers));
function updateHeaderPairs(newPairs: KeyValuePair[]) {
headerPairs = newPairs;
onHeadersChange(serializeHeaders(newPairs));
}
</script>
<div class="grid gap-3">
<div>
<label for="server-url-{id}" class="mb-2 block text-xs font-medium">
Server URL <span class="text-destructive">*</span>
</label>
<Input
id="server-url-{id}"
type="url"
placeholder={MCP_SERVER_URL_PLACEHOLDER}
value={url}
oninput={(e) => onUrlChange(e.currentTarget.value)}
class={urlError ? 'border-destructive' : ''}
/>
{#if urlError}
<p class="mt-1.5 text-xs text-destructive">{urlError}</p>
{/if}
{#if !isWebSocket && onUseProxyChange}
<label class="mt-3 flex cursor-pointer items-center gap-2">
<Switch
id="use-proxy-{id}"
checked={useProxy}
onCheckedChange={(checked) => onUseProxyChange?.(checked)}
/>
<span class="text-xs text-muted-foreground">Use llama-server proxy</span>
</label>
{/if}
</div>
<KeyValuePairs
class="mt-2"
pairs={headerPairs}
onPairsChange={updateHeaderPairs}
keyPlaceholder="Header name"
valuePlaceholder="Value"
addButtonLabel="Add"
emptyMessage="No custom headers configured."
sectionLabel="Custom Headers"
sectionLabelOptional
/>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
interface Props {
instructions?: string;
class?: string;
}
let { instructions, class: className }: Props = $props();
let isExpanded = $state(false);
</script>
{#if instructions}
<Collapsible.Root bind:open={isExpanded} class={className}>
<Collapsible.Trigger
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span>Server instructions</span>
</Collapsible.Trigger>
<Collapsible.Content class="mt-2">
<p class="rounded bg-muted/50 p-2 text-xs text-muted-foreground">
{instructions}
</p>
</Collapsible.Content>
</Collapsible.Root>
{/if}
@@ -0,0 +1,160 @@
<script lang="ts">
import { Settings } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch';
import { DropdownMenuSearchable, McpActiveServersAvatars } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
interface Props {
class?: string;
disabled?: boolean;
onSettingsClick?: () => void;
}
let { class: className = '', disabled = false, onSettingsClick }: Props = $props();
let searchQuery = $state('');
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let hasMcpServers = $derived(mcpServers.length > 0);
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
);
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpStore.getHealthCheckState(s.id);
return healthState.status !== HealthCheckStatus.ERROR;
})
);
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, 3)
.map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
.filter((f) => f.url !== null)
);
let filteredMcpServers = $derived.by(() => {
const query = searchQuery.toLowerCase().trim();
if (query) {
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
}
return mcpServers;
});
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function handleDropdownOpen(open: boolean) {
if (open) {
mcpStore.runHealthChecksForServers(mcpServers);
}
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
</script>
{#if hasMcpServers && hasEnabledMcpServers && mcpFavicons.length > 0}
<DropdownMenu.Root
onOpenChange={(open) => {
if (!open) {
searchQuery = '';
}
handleDropdownOpen(open);
}}
>
<DropdownMenu.Trigger
{disabled}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<button
type="button"
class="inline-flex cursor-pointer items-center rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
{disabled}
aria-label="MCP Servers"
>
<McpActiveServersAvatars class={className} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-72 pt-0">
<DropdownMenuSearchable
bind:searchValue={searchQuery}
placeholder="Search servers..."
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if mcpStore.getServerFavicon(server.id)}
<img
src={mcpStore.getServerFavicon(server.id)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate text-sm">{getServerLabel(server)}</span>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</button>
{/each}
</div>
{#snippet footer()}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={onSettingsClick}
>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
@@ -0,0 +1,150 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { uuid } from '$lib/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { McpServerCard, McpServerCardSkeleton, McpServerForm } from '$lib/components/app/mcp';
import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
let servers = $derived(mcpStore.getServersSorted());
let initialLoadComplete = $state(false);
$effect(() => {
if (initialLoadComplete) return;
const allChecked =
servers.length > 0 &&
servers.every((server) => {
const state = mcpStore.getHealthCheckState(server.id);
return (
state.status === HealthCheckStatus.SUCCESS || state.status === HealthCheckStatus.ERROR
);
});
if (allChecked) {
initialLoadComplete = true;
}
});
let isAddingServer = $state(false);
let newServerUrl = $state('');
let newServerHeaders = $state('');
let newServerUrlError = $derived.by(() => {
if (!newServerUrl.trim()) return 'URL is required';
try {
new URL(newServerUrl);
return null;
} catch {
return 'Invalid URL format';
}
});
function showAddServerForm() {
isAddingServer = true;
newServerUrl = '';
newServerHeaders = '';
}
function cancelAddServer() {
isAddingServer = false;
newServerUrl = '';
newServerHeaders = '';
}
function saveNewServer() {
if (newServerUrlError) return;
const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
mcpStore.addServer({
id: newServerId,
enabled: true,
url: newServerUrl.trim(),
headers: newServerHeaders.trim() || undefined
});
conversationsStore.setMcpServerOverride(newServerId, true);
isAddingServer = false;
newServerUrl = '';
newServerHeaders = '';
}
</script>
<div class="space-y-5 md:space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-semibold">Manage Servers</h4>
</div>
{#if !isAddingServer}
<Button variant="outline" size="sm" class="shrink-0" onclick={showAddServerForm}>
<Plus class="h-4 w-4" />
Add New Server
</Button>
{/if}
</div>
{#if isAddingServer}
<Card.Root class="bg-muted/30 p-4">
<div class="space-y-4">
<p class="font-medium">Add New Server</p>
<McpServerForm
url={newServerUrl}
headers={newServerHeaders}
onUrlChange={(v) => (newServerUrl = v)}
onHeadersChange={(v) => (newServerHeaders = v)}
urlError={newServerUrl ? newServerUrlError : null}
id="new-server"
/>
<div class="flex items-center justify-end gap-2">
<Button variant="secondary" size="sm" onclick={cancelAddServer}>Cancel</Button>
<Button
variant="default"
size="sm"
onclick={saveNewServer}
disabled={!!newServerUrlError}
aria-label="Save"
>
Add
</Button>
</div>
</div>
</Card.Root>
{/if}
{#if servers.length === 0 && !isAddingServer}
<div class="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No MCP Servers configured yet. Add one to enable agentic features.
</div>
{/if}
{#if servers.length > 0}
<div class="space-y-3">
{#each servers as server (server.id)}
{#if !initialLoadComplete}
<McpServerCardSkeleton />
{:else}
<McpServerCard
{server}
faviconUrl={mcpStore.getServerFavicon(server.id)}
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
onToggle={async () => await conversationsStore.toggleMcpServerForChat(server.id)}
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
onDelete={() => mcpStore.removeServer(server.id)}
/>
{/if}
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,262 @@
/**
*
* MCP (Model Context Protocol)
*
* Components for managing MCP server connections and displaying server status.
* MCP enables agentic workflows by connecting to external tool servers.
*
* The MCP system integrates with:
* - `mcpStore` for server CRUD operations and health checks
* - `conversationsStore` for per-conversation server enable/disable
*
*/
/**
* **McpServersSettings** - MCP servers configuration section
*
* Settings section for configuring MCP server connections.
* Displays server cards with status, tools, and management actions.
* Used within the MCP tab of ChatSettings.
*
* **Architecture:**
* - Manages add server form state locally
* - Delegates server display to McpServerCard components
* - Integrates with mcpStore for server operations
* - Shows skeleton loading states during health checks
*
* **Features:**
* - Add new MCP servers by URL with validation
* - Server cards with connection status indicators
* - Health check status (connected/disconnected/error)
* - Tools list per server showing available capabilities
* - Enable/disable toggle per conversation
* - Edit/delete server actions
* - Skeleton loading states during connection
* - Empty state with helpful message
*
* @example
* ```svelte
* <McpServersSettings />
* ```
*/
export { default as McpServersSettings } from './McpServersSettings.svelte';
/**
* **McpActiveServersAvatars** - Active MCP servers indicator
*
* Compact avatar row showing favicons of active MCP servers.
* Displays up to 3 server icons with "+N" counter for additional servers.
* Clickable to open MCP settings dialog.
*
* **Architecture:**
* - Filters servers by enabled status and health check
* - Fetches favicons from server URLs
* - Integrates with conversationsStore for per-chat server state
*
* **Features:**
* - Overlapping favicon avatars (max 3 visible)
* - "+N" counter for additional servers
* - Click handler for settings navigation
* - Disabled state support
* - Only shows healthy, enabled servers
*
* @example
* ```svelte
* <McpActiveServersAvatars
* onSettingsClick={() => showMcpSettings = true}
* />
* ```
*/
export { default as McpActiveServersAvatars } from './McpActiveServersAvatars.svelte';
/**
* **McpServersSelector** - Quick MCP server toggle dropdown
*
* Compact dropdown for quickly enabling/disabling MCP servers for the current chat.
* Uses McpActiveServersAvatars as trigger and shows searchable server list with switches.
*
* **Architecture:**
* - Uses DropdownMenuSearchable for searchable dropdown UI
* - McpActiveServersAvatars as the trigger element
* - Integrates with conversationsStore for per-chat toggle
* - Runs health checks on dropdown open
*
* **Features:**
* - Searchable server list by name/URL
* - Switch toggles matching McpServersSettings behavior
* - Error state display for unhealthy servers
* - Footer link to full MCP settings dialog
*
* @example
* ```svelte
* <McpServersSelector
* onSettingsClick={() => showMcpSettings = true}
* />
* ```
*/
export { default as McpServersSelector } from './McpServersSelector.svelte';
/**
* **McpCapabilitiesBadges** - Server capabilities display
*
* Displays MCP server capabilities as colored badges.
* Shows which features the server supports (tools, resources, prompts, etc.).
*
* **Features:**
* - Tools badge (green) - server provides callable tools
* - Resources badge (blue) - server provides data resources
* - Prompts badge (purple) - server provides prompt templates
* - Logging badge (orange) - server supports logging
* - Completions badge (cyan) - server provides completions
* - Tasks badge (pink) - server supports task management
*/
export { default as McpCapabilitiesBadges } from './McpCapabilitiesBadges.svelte';
/**
* **McpConnectionLogs** - Connection log viewer
*
* Collapsible panel showing MCP server connection logs.
* Displays timestamped log entries with level-based styling.
*
* **Features:**
* - Collapsible log list with entry count
* - Connection time display in milliseconds
* - Log level icons and color coding
* - Scrollable log container with max height
* - Monospace font for log readability
*/
export { default as McpConnectionLogs } from './McpConnectionLogs.svelte';
/**
* **McpServerForm** - Server URL and headers input form
*
* Reusable form for entering MCP server connection details.
* Used in both add new server and edit server flows.
*
* **Features:**
* - URL input with validation error display
* - Custom headers key-value pairs editor
* - Controlled component with change callbacks
*
* @example
* ```svelte
* <McpServerForm
* url={serverUrl}
* headers={serverHeaders}
* onUrlChange={(v) => serverUrl = v}
* onHeadersChange={(v) => serverHeaders = v}
* urlError={validationError}
* />
* ```
*/
export { default as McpServerForm } from './McpServerForm.svelte';
/**
* MCP protocol logo SVG component. Renders the official MCP icon
* with customizable size via class and style props.
*/
export { default as McpLogo } from './McpLogo.svelte';
/**
*
* SERVER CARD
*
* Components for displaying individual MCP server status and controls.
* McpServerCard is the main component, with sub-components for specific sections.
*
*/
/**
* **McpServerCard** - Individual server display card
*
* Main component for displaying a single MCP server with all its details.
* Manages edit mode, delete confirmation, and health check actions.
*
* **Architecture:**
* - Composes header, tools list, logs, and actions sub-components
* - Manages local edit/delete state
* - Reads health state from mcpStore
* - Triggers health checks via mcpStore
*
* **Features:**
* - Server header with favicon, name, version, and toggle
* - Capabilities badges display
* - Tools list with descriptions
* - Connection logs viewer
* - Edit form for URL and headers
* - Delete confirmation dialog
* - Skeleton loading states
*/
export { default as McpServerCard } from './McpServerCard/McpServerCard.svelte';
/** Server card header with favicon, name, version badge, and enable toggle. */
export { default as McpServerCardHeader } from './McpServerCard/McpServerCardHeader.svelte';
/** Action buttons row: edit, refresh, delete. */
export { default as McpServerCardActions } from './McpServerCard/McpServerCardActions.svelte';
/** Collapsible tools list showing available server tools with descriptions. */
export { default as McpServerCardToolsList } from './McpServerCard/McpServerCardToolsList.svelte';
/** Inline edit form for server URL and custom headers. */
export { default as McpServerCardEditForm } from './McpServerCard/McpServerCardEditForm.svelte';
/** Delete confirmation dialog with server name display. */
export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerCardDeleteDialog.svelte';
/** Skeleton loading state for server card during health checks. */
export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
/**
* **McpServerInfo** - Server instructions display
*
* Collapsible panel showing server-provided instructions.
* Displays guidance text from the MCP server for users.
*/
export { default as McpServerInfo } from './McpServerInfo.svelte';
/**
* **McpResourceBrowser** - MCP resources tree browser
*
* Tree view component showing resources grouped by server.
* Supports resource selection and quick attach actions.
*
* **Features:**
* - Collapsible server sections
* - Resource icons based on MIME type
* - Resource selection highlighting
* - Quick attach button per resource
* - Refresh all resources action
* - Loading states per server
*/
export { default as McpResourceBrowser } from './McpResourceBrowser/McpResourceBrowser.svelte';
/**
* **McpResourcePreview** - MCP resource content preview
*
* Preview panel showing resource content with metadata.
* Supports text and binary content display.
*
* **Features:**
* - Text content display with monospace formatting
* - Image preview for image MIME types
* - Copy to clipboard action
* - Download content action
* - Resource metadata display (MIME type, priority, server)
* - Loading and error states
*/
export { default as McpResourcePreview } from './McpResourcePreview.svelte';
/**
* **McpResourceTemplateForm** - MCP resource template variable form
*
* Form for filling in resource template variables with auto-completion
* via the Completions API. Shows live URI preview as variables are filled.
*
* **Features:**
* - Template variable input fields
* - Completions API integration for variable auto-complete
* - Live URI preview as variables are filled
* - Read resolved resource action
*/
export { default as McpResourceTemplateForm } from './McpResourceTemplateForm.svelte';
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte'; import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import type { Snippet } from 'svelte';
interface Props { interface Props {
class?: string; class?: string;
children?: import('svelte').Snippet; children?: Snippet;
gapSize?: string; gapSize?: string;
onScrollableChange?: (isScrollable: boolean) => void; onScrollableChange?: (isScrollable: boolean) => void;
} }
@@ -4,9 +4,10 @@
interface Props { interface Props {
text: string; text: string;
class?: 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 textElement: HTMLSpanElement | undefined = $state();
let isTruncated = $state(false); let isTruncated = $state(false);
@@ -29,7 +30,7 @@
}); });
</script> </script>
{#if isTruncated} {#if isTruncated && showTooltip}
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger class={className}> <Tooltip.Trigger class={className}>
<span bind:this={textElement} class="block truncate"> <span bind:this={textElement} class="block truncate">
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ModelsService } from '$lib/services/models.service'; import { ModelsService } from '$lib/services/models.service';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { TruncatedText } from '$lib/components/app';
interface Props { interface Props {
modelId: string; modelId: string;
@@ -30,11 +31,11 @@
</script> </script>
{#if resolvedShowRaw} {#if resolvedShowRaw}
<span class="min-w-0 truncate font-medium {className}">{modelId}</span> <TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} />
{:else} {:else}
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}"> <span class="flex min-w-0 flex-wrap items-center gap-1 {className}">
<span class="min-w-0 truncate font-medium"> <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> </span>
{#if parsed.params} {#if parsed.params}
@@ -0,0 +1,408 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import * as Sheet from '$lib/components/ui/sheet';
import { cn } from '$lib/components/ui/utils';
import {
modelsStore,
modelOptions,
modelsLoading,
modelsUpdating,
selectedModelId,
singleModelName
} from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import {
DialogModelInformation,
SearchInput,
TruncatedText,
ModelsSelectorOption
} from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
interface Props {
class?: string;
currentModel?: string | null;
/** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
disabled?: boolean;
forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean;
}
let {
class: className = '',
currentModel = null,
onModelChange,
disabled = false,
forceForegroundText = false,
useGlobalSelection = false
}: Props = $props();
let options = $derived(
modelOptions().filter((option) => {
const modelProps = modelsStore.getModelProps(option.model);
return modelProps?.webui !== false;
})
);
let loading = $derived(modelsLoading());
let updating = $derived(modelsUpdating());
let activeId = $derived(selectedModelId());
let isRouter = $derived(isRouterMode());
let serverModel = $derived(singleModelName());
let isLoadingModel = $state(false);
let isHighlightedCurrentModelActive = $derived(
!isRouter || !currentModel
? false
: (() => {
const currentOption = options.find((option) => option.model === currentModel);
return currentOption ? currentOption.id === activeId : false;
})()
);
let isCurrentModelInCache = $derived.by(() => {
if (!isRouter || !currentModel) return true;
return options.some((option) => option.model === currentModel);
});
let searchTerm = $state('');
let filteredOptions: ModelOption[] = $derived.by(() => {
const term = searchTerm.trim().toLowerCase();
if (!term) return options;
return options.filter(
(option) =>
option.model.toLowerCase().includes(term) ||
option.name?.toLowerCase().includes(term) ||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
);
});
let groupedFilteredOptions = $derived.by(() => {
const favIds = modelsStore.favouriteModelIds;
const result: {
orgName: string | null;
isFavouritesGroup: boolean;
isLoadedGroup: boolean;
items: { option: ModelOption; flatIndex: number }[];
}[] = [];
// Loaded models group (top)
const loadedItems: { option: ModelOption; flatIndex: number }[] = [];
for (let i = 0; i < filteredOptions.length; i++) {
if (modelsStore.isModelLoaded(filteredOptions[i].model)) {
loadedItems.push({ option: filteredOptions[i], flatIndex: i });
}
}
if (loadedItems.length > 0) {
result.push({
orgName: null,
isFavouritesGroup: false,
isLoadedGroup: true,
items: loadedItems
});
}
// Favourites group
const loadedModelIds = new Set(loadedItems.map((item) => item.option.model));
const favItems: { option: ModelOption; flatIndex: number }[] = [];
for (let i = 0; i < filteredOptions.length; i++) {
if (favIds.has(filteredOptions[i].model) && !loadedModelIds.has(filteredOptions[i].model)) {
favItems.push({ option: filteredOptions[i], flatIndex: i });
}
}
if (favItems.length > 0) {
result.push({
orgName: null,
isFavouritesGroup: true,
isLoadedGroup: false,
items: favItems
});
}
// Org groups (excluding loaded and favourites)
const orgGroups = new SvelteMap<string, { option: ModelOption; flatIndex: number }[]>();
for (let i = 0; i < filteredOptions.length; i++) {
const option = filteredOptions[i];
if (loadedModelIds.has(option.model) || favIds.has(option.model)) continue;
const orgName = option.parsedId?.orgName ?? null;
const key = orgName ?? '';
if (!orgGroups.has(key)) orgGroups.set(key, []);
orgGroups.get(key)!.push({ option, flatIndex: i });
}
for (const [orgName, items] of orgGroups) {
result.push({
orgName: orgName || null,
isFavouritesGroup: false,
isLoadedGroup: false,
items
});
}
return result;
});
let sheetOpen = $state(false);
let showModelDialog = $state(false);
onMount(() => {
modelsStore.fetch().catch((error) => {
console.error('Unable to load models:', error);
});
});
function handleOpenChange(open: boolean) {
if (loading || updating) return;
if (isRouter) {
if (open) {
sheetOpen = true;
searchTerm = '';
modelsStore.fetchRouterModels().then(() => {
modelsStore.fetchModalitiesForLoadedModels();
});
} else {
sheetOpen = false;
searchTerm = '';
}
} else {
showModelDialog = open;
}
}
export function open() {
handleOpenChange(true);
}
function handleSheetOpenChange(open: boolean) {
if (!open) {
handleOpenChange(false);
}
}
async function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
if (!option) return;
let shouldCloseMenu = true;
if (onModelChange) {
const result = await onModelChange(option.id, option.model);
if (result === false) {
shouldCloseMenu = false;
}
} else {
await modelsStore.selectModelById(option.id);
}
if (shouldCloseMenu) {
handleOpenChange(false);
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-slot="chat-form"] textarea'
);
textarea?.focus();
});
}
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore
.loadModel(option.model)
.catch((error) => console.error('Failed to load model:', error))
.finally(() => (isLoadingModel = false));
}
}
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
if (serverModel) {
return {
id: 'current',
model: serverModel,
name: serverModel.split('/').pop() || serverModel,
capabilities: []
};
}
return undefined;
}
if (useGlobalSelection && activeId) {
const selected = options.find((option) => option.id === activeId);
if (selected) return selected;
}
if (currentModel) {
if (!isCurrentModelInCache) {
return {
id: 'not-in-cache',
model: currentModel,
name: currentModel.split('/').pop() || currentModel,
capabilities: []
};
}
return options.find((option) => option.model === currentModel);
}
if (activeId) {
return options.find((option) => option.id === activeId);
}
return undefined;
}
</script>
<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
{#if loading && options.length === 0 && isRouter}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading models…
</div>
{:else if options.length === 0 && isRouter}
<p class="text-xs text-muted-foreground">No models available.</p>
{:else}
{@const selectedOption = getDisplayOption()}
{#if isRouter}
<button
type="button"
class={cn(
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-muted-foreground',
sheetOpen ? 'text-foreground' : ''
)}
style="max-width: min(calc(100cqw - 9rem), 20rem)"
disabled={disabled || updating}
onclick={() => handleOpenChange(true)}
>
<Package class="h-3.5 w-3.5" />
<TruncatedText text={selectedOption?.model || 'Select model'} class="min-w-0 font-medium" />
{#if updating || isLoadingModel}
<Loader2 class="h-3 w-3.5 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5" />
{/if}
</button>
<Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>
<Sheet.Content side="bottom" class="max-h-[85vh] gap-1">
<Sheet.Header>
<Sheet.Title>Select Model</Sheet.Title>
<Sheet.Description class="sr-only">
Choose a model to use for the conversation
</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-1 pb-4">
<div class="mb-3 px-4">
<SearchInput placeholder="Search models..." bind:value={searchTerm} />
</div>
<div class="max-h-[60vh] overflow-y-auto px-2">
{#if !isCurrentModelInCache && currentModel}
<button
type="button"
class="flex w-full cursor-not-allowed items-center rounded-md bg-red-400/10 px-3 py-2.5 text-left text-sm text-red-400"
disabled
>
<span class="min-w-0 flex-1 truncate">
{selectedOption?.name || currentModel}
</span>
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
<div class="my-1 h-px bg-border"></div>
{/if}
{#if filteredOptions.length === 0}
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
{/if}
{#each groupedFilteredOptions as group (group.isLoadedGroup ? '__loaded__' : group.isFavouritesGroup ? '__favourites__' : group.orgName)}
{#if group.isLoadedGroup}
<p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
Loaded models
</p>
{:else if group.isFavouritesGroup}
<p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
Favourite models
</p>
{:else if group.orgName}
<p
class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
>
{group.orgName}
</p>
{/if}
{#each group.items as { option } (group.isLoadedGroup ? `loaded-${option.id}` : group.isFavouritesGroup ? `fav-${option.id}` : option.id)}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isFav = modelsStore.favouriteModelIds.has(option.model)}
<ModelsSelectorOption
{option}
{isSelected}
isHighlighted={false}
{isFav}
showOrgName={group.isFavouritesGroup || group.isLoadedGroup}
onSelect={handleSelect}
onMouseEnter={() => {}}
onKeyDown={() => {}}
/>
{/each}
{/each}
</div>
</div>
</Sheet.Content>
</Sheet.Root>
{:else}
<button
class={cn(
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-muted-foreground'
)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
onclick={() => handleOpenChange(true)}
disabled={disabled || updating}
>
<Package class="h-3.5 w-3.5" />
<TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
{#if updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
{/if}
</button>
{/if}
{/if}
</div>
{#if showModelDialog && !isRouter}
<DialogModelInformation bind:open={showModelDialog} />
{/if}
@@ -44,6 +44,15 @@
*/ */
export { default as ModelsSelector } from './ModelsSelector.svelte'; 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 * **ModelBadge** - Model name display badge
* *
@@ -14,14 +14,16 @@
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border', '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: secondary:
'dark:bg-secondary dark:text-secondary-foreground bg-background shadow-sm text-foreground hover:bg-muted-foreground/20', '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' link: 'text-primary underline-offset-4 hover:underline'
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', 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', 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', 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: { defaultVariants: {
@@ -1,7 +1,7 @@
<script lang="ts" module> <script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants'; import { tv, type VariantProps } from 'tailwind-variants';
export const sheetVariants = tv({ 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: { variants: {
side: { 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', 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 type { Snippet } from 'svelte';
import SheetOverlay from './sheet-overlay.svelte'; import SheetOverlay from './sheet-overlay.svelte';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js'; import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
import { PANEL_CLASSES } from '$lib/constants';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -20,8 +20,10 @@
data-sidebar="trigger" data-sidebar="trigger"
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
variant="ghost" variant="ghost"
size="icon" size="icon-lg"
class="rounded-full backdrop-blur-lg {className} h-9! w-9!" class="rounded-full backdrop-blur-lg {className} md:left-{sidebar.open
? 'unset'
: '2'} -top-2 -left-2 md:top-0"
type="button" type="button"
onclick={(e) => { onclick={(e) => {
onclick?.(e); onclick?.(e);
@@ -1,3 +1,20 @@
import type { AgenticConfig } from '$lib/types/agentic';
export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/;
export const NEWLINE_SEPARATOR = '\n';
export const TURN_LIMIT_MESSAGE = '\n\n```\nTurn limit reached\n```\n';
export const LLM_ERROR_BLOCK_START = '\n\n```\nUpstream LLM error:\n';
export const LLM_ERROR_BLOCK_END = '\n```\n';
export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
enabled: true,
maxTurns: 100,
maxToolPreviewLines: 25
} as const;
// Agentic tool call tag markers // Agentic tool call tag markers
export const AGENTIC_TAGS = { export const AGENTIC_TAGS = {
TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>', TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>',
@@ -13,6 +30,9 @@ export const REASONING_TAGS = {
END: '<<<reasoning_content_end>>>' END: '<<<reasoning_content_end>>>'
} as const; } as const;
// Regex for trimming leading/trailing newlines
export const TRIM_NEWLINES_REGEX = /^\n+|\n+$/g;
// Regex patterns for parsing agentic content // Regex patterns for parsing agentic content
export const AGENTIC_REGEX = { export const AGENTIC_REGEX = {
// Matches completed tool calls (with END marker) // 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, REASONING_BLOCK: /<<<reasoning_content_start>>>[\s\S]*?<<<reasoning_content_end>>>/g,
// Matches an opening reasoning tag and any remaining content (unterminated) // Matches an opening reasoning tag and any remaining content (unterminated)
REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/, 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 // Matches tool name inside content
TOOL_NAME_EXTRACT: /<<<TOOL_NAME:([^>]+)>>>/ TOOL_NAME_EXTRACT: /<<<TOOL_NAME:([^>]+)>>>/
} as const; } as const;
@@ -3,3 +3,6 @@ export const API_MODELS = {
LOAD: '/models/load', LOAD: '/models/load',
UNLOAD: '/models/unload' UNLOAD: '/models/unload'
}; };
/** CORS proxy endpoint path */
export const CORS_PROXY_ENDPOINT = '/cors-proxy';
@@ -1,2 +1,4 @@
export const ATTACHMENT_LABEL_FILE = 'File'; export const ATTACHMENT_LABEL_FILE = 'File';
export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File'; export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File';
export const ATTACHMENT_LABEL_MCP_PROMPT = 'MCP Prompt';
export const ATTACHMENT_LABEL_MCP_RESOURCE = 'MCP Resource';
@@ -27,6 +27,18 @@ export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000;
*/ */
export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50; 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 * Maximum number of inactive conversation states to keep in memory
* States for conversations beyond this limit will be cleaned up * States for conversations beyond this limit will be cleaned up
@@ -1,3 +1,5 @@
export const INITIAL_FILE_SIZE = 0; export const INITIAL_FILE_SIZE = 0;
export const PROMPT_CONTENT_SEPARATOR = '\n\n'; export const PROMPT_CONTENT_SEPARATOR = '\n\n';
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"'; export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
export const PROMPT_TRIGGER_PREFIX = '/';
export const RESOURCE_TRIGGER_PREFIX = '@';
@@ -8,3 +8,12 @@ export const INPUT_CLASSES = `
outline-none outline-none
text-foreground text-foreground
`; `;
export const PANEL_CLASSES = `
bg-background
border border-border/30 dark:border-border/20
shadow-sm backdrop-blur-lg!
rounded-t-lg!
`;
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
@@ -0,0 +1,4 @@
export const GOOGLE_FAVICON_BASE_URL = 'https://www.google.com/s2/favicons';
export const DEFAULT_FAVICON_SIZE = 32;
export const DOMAIN_SEPARATOR = '.';
export const ROOT_DOMAIN_MIN_PARTS = 2;
@@ -11,14 +11,19 @@ export * from './chat-form';
export * from './code-blocks'; export * from './code-blocks';
export * from './code'; export * from './code';
export * from './css-classes'; export * from './css-classes';
export * from './favicon';
export * from './floating-ui-constraints'; export * from './floating-ui-constraints';
export * from './formatters'; export * from './formatters';
export * from './key-value-pairs';
export * from './icons'; export * from './icons';
export * from './latex-protection'; export * from './latex-protection';
export * from './literal-html'; export * from './literal-html';
export * from './localstorage-keys'; export * from './localstorage-keys';
export * from './markdown'; export * from './markdown';
export * from './max-bundle-size'; export * from './max-bundle-size';
export * from './mcp';
export * from './mcp-form';
export * from './mcp-resource';
export * from './model-id'; export * from './model-id';
export * from './precision'; export * from './precision';
export * from './processing-info'; export * from './processing-info';
@@ -30,4 +35,5 @@ export * from './supported-file-types';
export * from './table-html-restorer'; export * from './table-html-restorer';
export * from './tooltip-config'; export * from './tooltip-config';
export * from './ui'; export * from './ui';
export * from './uri-template';
export * from './viewport'; export * from './viewport';
@@ -0,0 +1,20 @@
/**
* Key-value pair form constraints and sanitization patterns.
*
* Both regexes target characters dangerous in HTTP-header / env-var contexts:
* \x00 null byte (injection)
* \x0A (\n) LF (HTTP header injection / response splitting)
* \x0D (\r) CR (HTTP header injection / response splitting)
* \x01\x08, \x0B\x0C, \x0E\x1F, \x7F other C0/DEL control chars
*
* KEY_UNSAFE_RE additionally strips TAB (\x09); values keep TAB because it is
* a valid header-value continuation character per RFC 7230.
*/
export const KEY_VALUE_PAIR_KEY_MAX_LENGTH = 256;
export const KEY_VALUE_PAIR_VALUE_MAX_LENGTH = 8192;
// eslint-disable-next-line no-control-regex
export const KEY_VALUE_PAIR_UNSAFE_KEY_RE = /[\x00-\x1F\x7F]/g;
// eslint-disable-next-line no-control-regex
export const KEY_VALUE_PAIR_UNSAFE_VALUE_RE = /[\x00-\x08\x0A-\x0D\x0E-\x1F\x7F]/g;
@@ -0,0 +1,2 @@
export const MCP_SERVER_URL_PLACEHOLDER = 'https://mcp.example.com/sse';
export const MIN_AUTOCOMPLETE_INPUT_LENGTH = 1;

Some files were not shown because too many files have changed in this diff Show More