Merge ffa26323d8 into b83111815e
This commit is contained in:
commit
cbb8416178
|
|
@ -81,7 +81,8 @@ jobs:
|
|||
cmake -B build \
|
||||
-DCMAKE_BUILD_RPATH="@loader_path" \
|
||||
-DLLAMA_FATAL_WARNINGS=ON \
|
||||
-DLLAMA_BUILD_BORINGSSL=ON \
|
||||
-DLLAMA_CURL=OFF \
|
||||
-DLLAMA_BORINGSSL=ON \
|
||||
-DGGML_METAL_USE_BF16=ON \
|
||||
-DGGML_METAL_EMBED_LIBRARY=OFF \
|
||||
-DGGML_METAL_SHADER_DEBUG=ON \
|
||||
|
|
@ -119,7 +120,8 @@ jobs:
|
|||
cmake -B build \
|
||||
-DCMAKE_BUILD_RPATH="@loader_path" \
|
||||
-DLLAMA_FATAL_WARNINGS=ON \
|
||||
-DLLAMA_BUILD_BORINGSSL=ON \
|
||||
-DLLAMA_CURL=OFF \
|
||||
-DLLAMA_BORINGSSL=ON \
|
||||
-DGGML_METAL=OFF \
|
||||
-DGGML_RPC=ON \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=13.3
|
||||
|
|
@ -1019,7 +1021,7 @@ jobs:
|
|||
id: cmake_build
|
||||
run: |
|
||||
cmake -S . -B build ${{ matrix.defines }} `
|
||||
-DLLAMA_BUILD_BORINGSSL=ON
|
||||
-DLLAMA_CURL=OFF -DLLAMA_BORINGSSL=ON
|
||||
cmake --build build --config Release -j ${env:NUMBER_OF_PROCESSORS}
|
||||
|
||||
- name: Add libopenblas.dll
|
||||
|
|
@ -1124,7 +1126,8 @@ jobs:
|
|||
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" x64
|
||||
cmake -S . -B build -G "Ninja Multi-Config" ^
|
||||
-DLLAMA_BUILD_SERVER=ON ^
|
||||
-DLLAMA_BUILD_BORINGSSL=ON ^
|
||||
-DLLAMA_CURL=OFF ^
|
||||
-DLLAMA_BORINGSSL=ON ^
|
||||
-DGGML_NATIVE=OFF ^
|
||||
-DGGML_BACKEND_DL=ON ^
|
||||
-DGGML_CPU_ALL_VARIANTS=ON ^
|
||||
|
|
@ -1231,7 +1234,8 @@ jobs:
|
|||
-DCMAKE_CXX_COMPILER="${env:HIP_PATH}\bin\clang++.exe" `
|
||||
-DCMAKE_CXX_FLAGS="-I$($PWD.Path.Replace('\', '/'))/opt/rocm-${{ env.ROCM_VERSION }}/include/" `
|
||||
-DCMAKE_BUILD_TYPE=Release `
|
||||
-DLLAMA_BUILD_BORINGSSL=ON `
|
||||
-DLLAMA_CURL=OFF `
|
||||
-DLLAMA_BORINGSSL=ON `
|
||||
-DROCM_DIR="${env:HIP_PATH}" `
|
||||
-DGGML_HIP=ON `
|
||||
-DGGML_HIP_ROCWMMA_FATTN=ON `
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ jobs:
|
|||
- name: Build
|
||||
id: cmake_build
|
||||
run: |
|
||||
cmake -B build -DLLAMA_BUILD_BORINGSSL=ON -DGGML_SCHED_NO_REALLOC=ON
|
||||
cmake -B build -DLLAMA_BORINGSSL=ON -DGGML_SCHED_NO_REALLOC=ON
|
||||
cmake --build build --config Release -j ${env:NUMBER_OF_PROCESSORS} --target llama-server
|
||||
|
||||
- name: Python setup
|
||||
|
|
|
|||
|
|
@ -111,8 +111,11 @@ option(LLAMA_BUILD_SERVER "llama: build server example" ${LLAMA_STANDALONE})
|
|||
option(LLAMA_TOOLS_INSTALL "llama: install tools" ${LLAMA_TOOLS_INSTALL_DEFAULT})
|
||||
|
||||
# 3rd party libs
|
||||
option(LLAMA_HTTPLIB "llama: httplib for downloading functionality" ON)
|
||||
option(LLAMA_OPENSSL "llama: use openssl to support HTTPS" ON)
|
||||
option(LLAMA_CURL "llama: use libcurl to download model from an URL" OFF)
|
||||
option(LLAMA_HTTPLIB "llama: if libcurl is disabled, use httplib to download model from an URL" ON)
|
||||
option(LLAMA_BORINGSSL "llama: use boringssl to support HTTPS" ON)
|
||||
option(LLAMA_LIBRESSL "llama: use libressl to support HTTPS" OFF)
|
||||
option(LLAMA_OPENSSL "llama: use openssl to support HTTPS" OFF)
|
||||
option(LLAMA_LLGUIDANCE "llama-common: include LLGuidance library for structured output in common utils" OFF)
|
||||
|
||||
# deprecated
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1000,11 +1000,20 @@ server_http_proxy::server_http_proxy(
|
|||
int32_t timeout_write
|
||||
) {
|
||||
// shared between reader and writer threads
|
||||
auto cli = std::make_shared<httplib::Client>(host, port);
|
||||
auto cli = std::make_shared<httplib::ClientImpl>(host, port);
|
||||
auto pipe = std::make_shared<pipe_t<msg_t>>();
|
||||
|
||||
if (port == 443) {
|
||||
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
|
||||
cli.reset(new httplib::SSLClient(host, port));
|
||||
#else
|
||||
throw std::runtime_error("HTTPS requested but CPPHTTPLIB_OPENSSL_SUPPORT is not defined");
|
||||
#endif
|
||||
}
|
||||
|
||||
// setup Client
|
||||
cli->set_connection_timeout(0, 200000); // 200 milliseconds
|
||||
cli->set_follow_location(true);
|
||||
cli->set_connection_timeout(5, 0); // 5 seconds
|
||||
cli->set_write_timeout(timeout_read, 0); // reversed for cli (client) vs srv (server)
|
||||
cli->set_read_timeout(timeout_write, 0);
|
||||
this->status = 500; // to be overwritten upon response
|
||||
|
|
@ -1053,7 +1062,15 @@ server_http_proxy::server_http_proxy(
|
|||
req.method = method;
|
||||
req.path = path;
|
||||
for (const auto & [key, value] : headers) {
|
||||
req.set_header(key, value);
|
||||
if (key == "Accept-Encoding") {
|
||||
// disable Accept-Encoding to avoid compressed responses
|
||||
continue;
|
||||
}
|
||||
if (key == "Host" || key == "host") {
|
||||
req.set_header(key, host);
|
||||
} else {
|
||||
req.set_header(key, value);
|
||||
}
|
||||
}
|
||||
req.body = body;
|
||||
req.response_handler = response_handler;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include "common.h"
|
||||
#include "preset.h"
|
||||
#include "http.h"
|
||||
#include "server-common.h"
|
||||
#include "server-http.h"
|
||||
|
||||
|
|
@ -184,8 +185,8 @@ public:
|
|||
const std::map<std::string, std::string> & headers,
|
||||
const std::string & body,
|
||||
const std::function<bool()> should_stop,
|
||||
int32_t timeout_read,
|
||||
int32_t timeout_write
|
||||
int32_t timeout_read = 600,
|
||||
int32_t timeout_write = 600
|
||||
);
|
||||
~server_http_proxy() {
|
||||
if (cleanup) {
|
||||
|
|
@ -201,3 +202,48 @@ private:
|
|||
std::string content_type;
|
||||
};
|
||||
};
|
||||
|
||||
// BELOW IS DEMO CODE FOR PROXY HANDLERS
|
||||
// DO NOT MERGE IT AS-IS
|
||||
|
||||
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);
|
||||
|
||||
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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -197,6 +197,9 @@ int main(int argc, char ** argv) {
|
|||
// Save & load slots
|
||||
ctx_http.get ("/slots", ex_wrapper(routes.get_slots));
|
||||
ctx_http.post("/slots/:id_slot", ex_wrapper(routes.post_slots));
|
||||
// CORS proxy
|
||||
ctx_http.get ("/cors-proxy", ex_wrapper(proxy_handler_get));
|
||||
ctx_http.post("/cors-proxy", ex_wrapper(proxy_handler_post));
|
||||
|
||||
//
|
||||
// Start the server
|
||||
|
|
|
|||
|
|
@ -1,17 +1,24 @@
|
|||
import type { StorybookConfig } from '@storybook/sveltekit';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../tests/stories/**/*.mdx', '../tests/stories/**/*.stories.@(js|ts|svelte)'],
|
||||
addons: [
|
||||
'@storybook/addon-svelte-csf',
|
||||
'@chromatic-com/storybook',
|
||||
'@storybook/addon-docs',
|
||||
'@storybook/addon-vitest',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-vitest'
|
||||
'@storybook/addon-docs'
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/sveltekit',
|
||||
options: {}
|
||||
framework: '@storybook/sveltekit',
|
||||
viteFinal: async (config) => {
|
||||
config.server = config.server || {};
|
||||
config.server.fs = config.server.fs || {};
|
||||
config.server.fs.allow = [...(config.server.fs.allow || []), resolve(__dirname, '../tests')];
|
||||
return config;
|
||||
}
|
||||
};
|
||||
export default config;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const preview: Preview = {
|
|||
},
|
||||
|
||||
backgrounds: {
|
||||
disable: true
|
||||
disabled: true
|
||||
},
|
||||
|
||||
a11y: {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ flowchart TB
|
|||
C_Form["ChatForm"]
|
||||
C_Messages["ChatMessages"]
|
||||
C_Message["ChatMessage"]
|
||||
C_AgenticContent["AgenticContent"]
|
||||
C_MessageEditForm["ChatMessageEditForm"]
|
||||
C_ModelsSelector["ModelsSelector"]
|
||||
C_Settings["ChatSettings"]
|
||||
C_McpSettings["McpSettingsSection"]
|
||||
end
|
||||
|
||||
subgraph Hooks["🪝 Hooks"]
|
||||
|
|
@ -27,20 +29,26 @@ flowchart TB
|
|||
S2["conversationsStore<br/><i>Conversation data & messages</i>"]
|
||||
S3["modelsStore<br/><i>Model selection & loading</i>"]
|
||||
S4["serverStore<br/><i>Server props & role detection</i>"]
|
||||
S5["settingsStore<br/><i>User configuration</i>"]
|
||||
S5["settingsStore<br/><i>User configuration incl. MCP</i>"]
|
||||
end
|
||||
|
||||
subgraph Services["⚙️ Services"]
|
||||
SV1["ChatService"]
|
||||
SV1["ChatService<br/><i>incl. agentic loop</i>"]
|
||||
SV2["ModelsService"]
|
||||
SV3["PropsService"]
|
||||
SV4["DatabaseService"]
|
||||
SV5["ParameterSyncService"]
|
||||
end
|
||||
|
||||
subgraph MCP["🔧 MCP (Model Context Protocol)"]
|
||||
MCP1["MCPClient<br/><i>@modelcontextprotocol/sdk</i>"]
|
||||
MCP2["mcpStore<br/><i>reactive state</i>"]
|
||||
MCP3["OpenAISseClient"]
|
||||
end
|
||||
|
||||
subgraph Storage["💾 Storage"]
|
||||
ST1["IndexedDB<br/><i>conversations, messages</i>"]
|
||||
ST2["LocalStorage<br/><i>config, userOverrides</i>"]
|
||||
ST2["LocalStorage<br/><i>config, userOverrides, mcpServers</i>"]
|
||||
end
|
||||
|
||||
subgraph APIs["🌐 llama-server API"]
|
||||
|
|
@ -50,6 +58,11 @@ flowchart TB
|
|||
API4["/v1/models"]
|
||||
end
|
||||
|
||||
subgraph ExternalMCP["🔌 External MCP Servers"]
|
||||
EXT1["MCP Server 1"]
|
||||
EXT2["MCP Server N"]
|
||||
end
|
||||
|
||||
%% Routes → Components
|
||||
R1 & R2 --> C_Screen
|
||||
RL --> C_Sidebar
|
||||
|
|
@ -57,8 +70,10 @@ flowchart TB
|
|||
%% Component hierarchy
|
||||
C_Screen --> C_Form & C_Messages & C_Settings
|
||||
C_Messages --> C_Message
|
||||
C_Message --> C_AgenticContent
|
||||
C_Message --> C_MessageEditForm
|
||||
C_Form & C_MessageEditForm --> C_ModelsSelector
|
||||
C_Settings --> C_McpSettings
|
||||
|
||||
%% Components → Hooks → Stores
|
||||
C_Form & C_Messages --> H1 & H2
|
||||
|
|
@ -70,6 +85,7 @@ flowchart TB
|
|||
C_Sidebar --> S2
|
||||
C_ModelsSelector --> S3 & S4
|
||||
C_Settings --> S5
|
||||
C_McpSettings --> S5
|
||||
|
||||
%% Stores → Services
|
||||
S1 --> SV1 & SV4
|
||||
|
|
@ -78,6 +94,12 @@ flowchart TB
|
|||
S4 --> SV3
|
||||
S5 --> SV5
|
||||
|
||||
%% ChatService → MCP (Agentic Mode)
|
||||
SV1 --> MCP2
|
||||
MCP2 --> MCP1
|
||||
SV1 --> MCP3
|
||||
MCP3 --> API1
|
||||
|
||||
%% Services → Storage
|
||||
SV4 --> ST1
|
||||
SV5 --> ST2
|
||||
|
|
@ -87,6 +109,9 @@ flowchart TB
|
|||
SV2 --> API3 & API4
|
||||
SV3 --> API2
|
||||
|
||||
%% MCP → External Servers
|
||||
MCP1 --> EXT1 & EXT2
|
||||
|
||||
%% Styling
|
||||
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
|
||||
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
|
||||
|
|
@ -95,12 +120,16 @@ flowchart TB
|
|||
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
|
||||
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
|
||||
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
||||
classDef mcpStyle fill:#e0f2f1,stroke:#00695c,stroke-width:2px
|
||||
classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
|
||||
|
||||
class R1,R2,RL routeStyle
|
||||
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle
|
||||
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_AgenticContent,C_MessageEditForm,C_ModelsSelector,C_Settings,C_McpSettings componentStyle
|
||||
class H1,H2 hookStyle
|
||||
class S1,S2,S3,S4,S5 storeStyle
|
||||
class SV1,SV2,SV3,SV4,SV5 serviceStyle
|
||||
class ST1,ST2 storageStyle
|
||||
class API1,API2,API3,API4 apiStyle
|
||||
class MCP1,MCP2,MCP3 mcpStyle
|
||||
class EXT1,EXT2 externalStyle
|
||||
```
|
||||
|
|
|
|||
|
|
@ -164,6 +164,32 @@ end
|
|||
end
|
||||
end
|
||||
|
||||
subgraph MCP["🔧 MCP (Model Context Protocol)"]
|
||||
direction TB
|
||||
subgraph MCPStoreBox["mcpStore"]
|
||||
MCPStoreState["<b>State:</b><br/>client, isInitializing<br/>error, availableTools"]
|
||||
MCPStoreLifecycle["<b>Lifecycle:</b><br/>ensureClient()<br/>shutdown()"]
|
||||
MCPStoreExec["<b>Execution:</b><br/>execute()"]
|
||||
end
|
||||
subgraph MCPClient["MCPClient"]
|
||||
MCP1Init["<b>Lifecycle:</b><br/>initialize()<br/>shutdown()"]
|
||||
MCP1Tools["<b>Tools:</b><br/>listTools()<br/>getToolsDefinition()<br/>execute()"]
|
||||
MCP1Transport["<b>Transport:</b><br/>StreamableHTTPClientTransport<br/>SSEClientTransport (fallback)"]
|
||||
end
|
||||
subgraph MCPSse["OpenAISseClient"]
|
||||
MCP3Stream["<b>Streaming:</b><br/>streamChatCompletion()"]
|
||||
MCP3Parse["<b>Parsing:</b><br/>tool call delta merging"]
|
||||
end
|
||||
subgraph MCPConfig["config/mcp"]
|
||||
MCP4Parse["<b>Parsing:</b><br/>parseServersFromSettings()"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph ExternalMCP["🔌 External MCP Servers"]
|
||||
EXT1["MCP Server 1<br/>(StreamableHTTP/SSE)"]
|
||||
EXT2["MCP Server N"]
|
||||
end
|
||||
|
||||
subgraph Storage["💾 Storage"]
|
||||
ST1["IndexedDB"]
|
||||
ST2["conversations"]
|
||||
|
|
@ -240,6 +266,16 @@ end
|
|||
SV2 --> API3 & API4
|
||||
SV3 --> API2
|
||||
|
||||
%% ChatService → MCP (Agentic Mode)
|
||||
SV1 --> MCPStoreBox
|
||||
MCPStoreBox --> MCPClient
|
||||
SV1 --> MCPSse
|
||||
MCPSse --> API1
|
||||
MCPConfig --> MCPStoreBox
|
||||
|
||||
%% MCP → External Servers
|
||||
MCPClient --> EXT1 & EXT2
|
||||
|
||||
%% Styling
|
||||
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
|
||||
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
|
||||
|
|
@ -250,6 +286,9 @@ end
|
|||
classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px
|
||||
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
|
||||
classDef serviceMStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
|
||||
classDef mcpStyle fill:#e0f2f1,stroke:#00695c,stroke-width:2px
|
||||
classDef mcpMethodStyle fill:#b2dfdb,stroke:#00695c,stroke-width:1px
|
||||
classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
|
||||
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
|
||||
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
||||
|
||||
|
|
@ -269,6 +308,9 @@ end
|
|||
class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle
|
||||
class ChatExports,ConvExports,ModelsExports,ServerExports,SettingsExports reactiveStyle
|
||||
class SV1,SV2,SV3,SV4,SV5 serviceStyle
|
||||
class MCPStoreBox,MCPClient,MCPSse,MCPConfig mcpStyle
|
||||
class MCPStoreState,MCPStoreLifecycle,MCPStoreExec,MCP1Init,MCP1Tools,MCP1Transport,MCP3Stream,MCP3Parse,MCP4Build,MCP4Parse mcpMethodStyle
|
||||
class EXT1,EXT2 externalStyle
|
||||
class SV1Msg,SV1Stream,SV1Convert,SV1Utils serviceMStyle
|
||||
class SV2List,SV2LoadUnload,SV2Status serviceMStyle
|
||||
class SV3Fetch serviceMStyle
|
||||
|
|
|
|||
|
|
@ -139,6 +139,6 @@ sequenceDiagram
|
|||
|
||||
Note over settingsStore: UI-only (not synced):
|
||||
rect rgb(255, 240, 240)
|
||||
Note over settingsStore: systemMessage, custom (JSON)<br/>showStatistics, enableContinueGeneration<br/>autoMicOnEmpty, disableAutoScroll<br/>apiKey, pdfAsImage, disableReasoningFormat
|
||||
Note over settingsStore: systemMessage, custom (JSON)<br/>showStatistics, enableContinueGeneration<br/>autoMicOnEmpty, disableAutoScroll<br/>apiKey, pdfAsImage, disableReasoningParsing, showRawOutputSwitch
|
||||
end
|
||||
```
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,31 +23,32 @@
|
|||
"cleanup": "rm -rf .svelte-kit build node_modules test-results"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^4.1.2",
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@lucide/svelte": "^0.515.0",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@storybook/addon-a11y": "^10.0.7",
|
||||
"@storybook/addon-docs": "^10.0.7",
|
||||
"@storybook/addon-a11y": "^10.2.4",
|
||||
"@storybook/addon-docs": "^10.2.4",
|
||||
"@storybook/addon-svelte-csf": "^5.0.10",
|
||||
"@storybook/addon-vitest": "^10.0.7",
|
||||
"@storybook/sveltekit": "^10.0.7",
|
||||
"@storybook/addon-vitest": "^10.2.4",
|
||||
"@storybook/sveltekit": "^10.2.4",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.48.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^22",
|
||||
"@types/node": "^24",
|
||||
"@vitest/browser": "^3.2.3",
|
||||
"@vitest/coverage-v8": "^3.2.3",
|
||||
"bits-ui": "^2.14.4",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.0.11",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-storybook": "^10.0.7",
|
||||
"eslint-plugin-storybook": "^10.2.4",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"fflate": "^0.8.2",
|
||||
"globals": "^16.0.0",
|
||||
|
|
@ -61,7 +62,7 @@
|
|||
"rehype-katex": "^7.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sass": "^1.93.3",
|
||||
"storybook": "^10.0.7",
|
||||
"storybook": "^10.2.4",
|
||||
"svelte": "^5.38.2",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
|
@ -78,6 +79,7 @@
|
|||
"vitest-browser-svelte": "^0.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pdfjs-dist": "^5.4.54",
|
||||
|
|
@ -89,6 +91,7 @@
|
|||
"remark-html": "^16.0.1",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@
|
|||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary: oklch(0.95 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent: oklch(0.95 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.875 0 0);
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--code-background: oklch(0.975 0 0);
|
||||
--code-background: oklch(0.985 0 0);
|
||||
--code-foreground: oklch(0.145 0 0);
|
||||
--layer-popover: 1000000;
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary: oklch(0.29 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
|
|
@ -116,12 +116,62 @@
|
|||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--chat-form-area-height: 8rem;
|
||||
--chat-form-area-offset: 2rem;
|
||||
--max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem)));
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--chat-form-area-height: 24rem;
|
||||
--chat-form-area-offset: 12rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Global scrollbar styling - visible only on hover */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.2s ease;
|
||||
}
|
||||
|
||||
*:hover {
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
aria-label={ariaLabel || tooltip}
|
||||
>
|
||||
{@const IconComponent = icon}
|
||||
|
||||
<IconComponent class="h-3 w-3" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}"
|
||||
onclick={(e) => {
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.(id);
|
||||
}}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { Eye } from '@lucide/svelte';
|
||||
import { ActionIconCopyToClipboard } from '$lib/components/app';
|
||||
import { FileTypeText } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
language: string;
|
||||
disabled?: boolean;
|
||||
onPreview?: (code: string, language: string) => void;
|
||||
}
|
||||
|
||||
let { code, language, disabled = false, onPreview }: Props = $props();
|
||||
|
||||
const showPreview = $derived(language?.toLowerCase() === FileTypeText.HTML);
|
||||
|
||||
function handlePreview() {
|
||||
if (disabled) return;
|
||||
onPreview?.(code, language);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="code-block-actions">
|
||||
<div class="copy-code-btn" class:opacity-50={disabled} class:!cursor-not-allowed={disabled}>
|
||||
<ActionIconCopyToClipboard
|
||||
text={code}
|
||||
canCopy={!disabled}
|
||||
ariaLabel={disabled ? 'Code incomplete' : 'Copy code'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showPreview}
|
||||
<button
|
||||
class="preview-code-btn"
|
||||
class:opacity-50={disabled}
|
||||
class:!cursor-not-allowed={disabled}
|
||||
title={disabled ? 'Code incomplete' : 'Preview code'}
|
||||
aria-label="Preview code"
|
||||
aria-disabled={disabled}
|
||||
type="button"
|
||||
onclick={handlePreview}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
*
|
||||
* ACTIONS
|
||||
*
|
||||
* Small interactive components for user actions.
|
||||
*
|
||||
*/
|
||||
|
||||
/** Styled icon button for action triggers with tooltip. */
|
||||
export { default as ActionIcon } from './ActionIcon.svelte';
|
||||
|
||||
/** Code block actions component (copy, preview). */
|
||||
export { default as ActionIconsCodeBlock } from './ActionIconsCodeBlock.svelte';
|
||||
|
||||
/** Copy-to-clipboard icon button with click handler. */
|
||||
export { default as ActionIconCopyToClipboard } from './ActionIconCopyToClipboard.svelte';
|
||||
|
||||
/** Remove/delete icon button with X icon. */
|
||||
export { default as ActionIconRemove } from './ActionIconRemove.svelte';
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
*
|
||||
* BADGES & INDICATORS
|
||||
*
|
||||
* Small visual indicators for status and metadata.
|
||||
*
|
||||
*/
|
||||
|
||||
/** Badge displaying chat statistics (tokens, timing). */
|
||||
export { default as BadgeChatStatistic } from './BadgeChatStatistic.svelte';
|
||||
|
||||
/** Generic info badge with optional tooltip and click handler. */
|
||||
export { default as BadgeInfo } from './BadgeInfo.svelte';
|
||||
|
||||
/** Badge indicating model modality (vision, audio, tools). */
|
||||
export { default as BadgeModality } from './BadgeModality.svelte';
|
||||
|
|
@ -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,108 @@
|
|||
<script lang="ts">
|
||||
import { FileText, Loader2, AlertCircle, Database, Image, Code, File } from '@lucide/svelte';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { MCPResourceAttachment, MCPResourceInfo } from '$lib/types';
|
||||
import {
|
||||
IMAGE_FILE_EXTENSION_REGEX,
|
||||
CODE_FILE_EXTENSION_REGEX,
|
||||
TEXT_FILE_EXTENSION_REGEX
|
||||
} from '$lib/constants/mcp-resource';
|
||||
import { MimeTypePrefix, MimeTypeIncludes, UriPattern } from '$lib/enums';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ActionIconRemove } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
attachment: MCPResourceAttachment;
|
||||
onRemove?: (attachmentId: string) => void;
|
||||
onClick?: () => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { attachment, onRemove, onClick, class: className }: Props = $props();
|
||||
|
||||
function getResourceIcon(resource: MCPResourceInfo) {
|
||||
const mimeType = resource.mimeType?.toLowerCase() || '';
|
||||
const uri = resource.uri.toLowerCase();
|
||||
|
||||
if (mimeType.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(uri)) {
|
||||
return Image;
|
||||
}
|
||||
if (
|
||||
mimeType.includes(MimeTypeIncludes.JSON) ||
|
||||
mimeType.includes(MimeTypeIncludes.JAVASCRIPT) ||
|
||||
mimeType.includes(MimeTypeIncludes.TYPESCRIPT) ||
|
||||
CODE_FILE_EXTENSION_REGEX.test(uri)
|
||||
) {
|
||||
return Code;
|
||||
}
|
||||
if (mimeType.includes(MimeTypePrefix.TEXT) || TEXT_FILE_EXTENSION_REGEX.test(uri)) {
|
||||
return FileText;
|
||||
}
|
||||
if (uri.includes(UriPattern.DATABASE_KEYWORD) || uri.includes(UriPattern.DATABASE_SCHEME)) {
|
||||
return Database;
|
||||
}
|
||||
return File;
|
||||
}
|
||||
|
||||
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));
|
||||
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-2 rounded-md border p-0.5 pl-2 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.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
{:else if attachment.error}
|
||||
<AlertCircle class="h-3.5 w-3.5 text-red-500" />
|
||||
{:else}
|
||||
<ResourceIcon class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span class="max-w-[150px] truncate">
|
||||
{attachment.resource.title || attachment.resource.name}
|
||||
</span>
|
||||
|
||||
{#if onRemove}
|
||||
<ActionIconRemove class="bg-transparent " 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,99 @@
|
|||
<script lang="ts">
|
||||
import { FileText, Database, Image, Code, File } from '@lucide/svelte';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||
import {
|
||||
IMAGE_FILE_EXTENSION_REGEX,
|
||||
CODE_FILE_EXTENSION_REGEX,
|
||||
TEXT_FILE_EXTENSION_REGEX
|
||||
} from '$lib/constants/mcp-resource';
|
||||
import { MimeTypePrefix, MimeTypeIncludes, UriPattern } from '$lib/enums';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ActionIconRemove } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
extra: DatabaseMessageExtraMcpResource;
|
||||
readonly?: boolean;
|
||||
onRemove?: () => void;
|
||||
onClick?: (event?: MouseEvent) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { extra, readonly = true, onRemove, onClick, class: className }: Props = $props();
|
||||
|
||||
function getResourceIcon(mimeType?: string, uri?: string) {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
|
||||
if (mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u)) {
|
||||
return Image;
|
||||
}
|
||||
if (
|
||||
mime.includes(MimeTypeIncludes.JSON) ||
|
||||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
|
||||
mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
|
||||
CODE_FILE_EXTENSION_REGEX.test(u)
|
||||
) {
|
||||
return Code;
|
||||
}
|
||||
if (mime.includes(MimeTypePrefix.TEXT) || TEXT_FILE_EXTENSION_REGEX.test(u)) {
|
||||
return FileText;
|
||||
}
|
||||
if (u.includes(UriPattern.DATABASE_KEYWORD) || u.includes(UriPattern.DATABASE_SCHEME)) {
|
||||
return Database;
|
||||
}
|
||||
return File;
|
||||
}
|
||||
|
||||
const ResourceIcon = $derived(getResourceIcon(extra.mimeType, extra.uri));
|
||||
const serverName = $derived(mcpStore.getServerDisplayName(extra.serverName));
|
||||
const favicon = $derived(mcpStore.getServerFavicon(extra.serverName));
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'flex flex-shrink-0 items-center gap-2 rounded-md border border-border/50 bg-muted/30 p-0.5 px-2 text-sm',
|
||||
onClick && 'cursor-pointer hover:bg-muted/50',
|
||||
className
|
||||
)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
disabled={!onClick}
|
||||
>
|
||||
<ResourceIcon class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
||||
<span class="max-w-[150px] truncate">
|
||||
{extra.name}
|
||||
</span>
|
||||
|
||||
{#if !readonly && onRemove}
|
||||
<ActionIconRemove class="bg-transparent" id={extra.uri} onRemove={() => 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,40 @@
|
|||
<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 (attachment.id)}
|
||||
<ChatAttachmentMcpResource
|
||||
{attachment}
|
||||
onRemove={handleRemove}
|
||||
onClick={() => handleResourceClick(attachment.resource.uri)}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalScrollCarousel>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -8,7 +8,8 @@
|
|||
isImageFile,
|
||||
isPdfFile,
|
||||
isAudioFile,
|
||||
getLanguageFromFilename
|
||||
getLanguageFromFilename,
|
||||
createBase64DataUrl
|
||||
} from '$lib/utils';
|
||||
import { convertPDFToImage } from '$lib/utils/browser-only';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
|
|
@ -255,7 +256,7 @@
|
|||
<audio
|
||||
controls
|
||||
class="mb-4 w-full"
|
||||
src={`data:${attachment.mimeType};base64,${attachment.base64Data}`}
|
||||
src={createBase64DataUrl(attachment.mimeType, attachment.base64Data)}
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { RemoveButton } from '$lib/components/app';
|
||||
import { ActionIconRemove } from '$lib/components/app';
|
||||
import { formatFileSize, getFileTypeLabel, getPreviewText, isTextFile } from '$lib/utils';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
onclick={onClick}
|
||||
>
|
||||
<div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<RemoveButton {id} {onRemove} />
|
||||
<ActionIconRemove {id} {onRemove} />
|
||||
</div>
|
||||
|
||||
<div class="pr-8">
|
||||
|
|
@ -158,7 +158,7 @@
|
|||
|
||||
{#if !readonly}
|
||||
<div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<RemoveButton {id} {onRemove} />
|
||||
<ActionIconRemove {id} {onRemove} />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { RemoveButton } from '$lib/components/app';
|
||||
import { ActionIconRemove } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
<div
|
||||
class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<RemoveButton {id} {onRemove} class="text-white" />
|
||||
<ActionIconRemove {id} {onRemove} class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
|
||||
import {
|
||||
ChatAttachmentMcpPrompt,
|
||||
ChatAttachmentMcpResourceStored,
|
||||
ChatAttachmentThumbnailImage,
|
||||
ChatAttachmentThumbnailFile,
|
||||
HorizontalScrollCarousel,
|
||||
DialogChatAttachmentPreview,
|
||||
DialogChatAttachmentsViewAll,
|
||||
DialogMcpResourcePreview
|
||||
} from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||
import { getAttachmentDisplayItems } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -41,12 +50,12 @@
|
|||
|
||||
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
|
||||
|
||||
let canScrollLeft = $state(false);
|
||||
let canScrollRight = $state(false);
|
||||
let carouselRef: HorizontalScrollCarousel | undefined = $state();
|
||||
let isScrollable = $state(false);
|
||||
let previewDialogOpen = $state(false);
|
||||
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
|
||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||
let mcpResourcePreviewOpen = $state(false);
|
||||
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
|
||||
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
|
||||
let viewAllDialogOpen = $state(false);
|
||||
|
||||
|
|
@ -65,41 +74,16 @@
|
|||
previewDialogOpen = true;
|
||||
}
|
||||
|
||||
function scrollLeft(event?: MouseEvent) {
|
||||
function openMcpResourcePreview(extra: DatabaseMessageExtraMcpResource, event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function scrollRight(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function updateScrollButtons() {
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
|
||||
|
||||
canScrollLeft = scrollLeft > 0;
|
||||
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
|
||||
isScrollable = scrollWidth > clientWidth;
|
||||
mcpResourcePreviewExtra = extra;
|
||||
mcpResourcePreviewOpen = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (scrollContainer && displayItems.length) {
|
||||
scrollContainer.scrollLeft = 0;
|
||||
|
||||
setTimeout(() => {
|
||||
updateScrollButtons();
|
||||
}, 0);
|
||||
if (carouselRef && displayItems.length) {
|
||||
carouselRef.resetScroll();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -107,67 +91,75 @@
|
|||
{#if displayItems.length > 0}
|
||||
<div class={className} {style}>
|
||||
{#if limitToSingleRow}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollLeft
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={scrollLeft}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="scrollbar-hide flex items-start gap-3 overflow-x-auto"
|
||||
bind:this={scrollContainer}
|
||||
onscroll={updateScrollButtons}
|
||||
>
|
||||
{#each displayItems as item (item.id)}
|
||||
{#if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow
|
||||
<HorizontalScrollCarousel
|
||||
bind:this={carouselRef}
|
||||
onScrollableChange={(scrollable) => (isScrollable = scrollable)}
|
||||
>
|
||||
{#each displayItems as item (item.id)}
|
||||
{#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'
|
||||
: ''}"
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
preview={item.preview}
|
||||
prompt={mcpPrompt}
|
||||
{readonly}
|
||||
onRemove={onFileRemove}
|
||||
height={imageHeight}
|
||||
width={imageWidth}
|
||||
{imageClass}
|
||||
onClick={(event) => openPreview(item, event)}
|
||||
/>
|
||||
{:else}
|
||||
<ChatAttachmentThumbnailFile
|
||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow
|
||||
? 'first:ml-4 last:mr-4'
|
||||
: ''}"
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
size={item.size}
|
||||
{readonly}
|
||||
onRemove={onFileRemove}
|
||||
textContent={item.textContent}
|
||||
attachment={item.attachment}
|
||||
uploadedFile={item.uploadedFile}
|
||||
onClick={(event) => openPreview(item, event)}
|
||||
isLoading={item.isLoading}
|
||||
loadError={item.loadError}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={scrollRight}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else if item.isMcpResource && item.attachment?.type === AttachmentType.MCP_RESOURCE}
|
||||
<ChatAttachmentMcpResourceStored
|
||||
class="flex-shrink-0 {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||
extra={item.attachment as DatabaseMessageExtraMcpResource}
|
||||
{readonly}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
onClick={(event) =>
|
||||
openMcpResourcePreview(item.attachment as DatabaseMessageExtraMcpResource, event)}
|
||||
/>
|
||||
{:else if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
preview={item.preview}
|
||||
{readonly}
|
||||
onRemove={onFileRemove}
|
||||
height={imageHeight}
|
||||
width={imageWidth}
|
||||
{imageClass}
|
||||
onClick={(event) => openPreview(item, event)}
|
||||
/>
|
||||
{:else}
|
||||
<ChatAttachmentThumbnailFile
|
||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
size={item.size}
|
||||
{readonly}
|
||||
onRemove={onFileRemove}
|
||||
textContent={item.textContent}
|
||||
attachment={item.attachment}
|
||||
uploadedFile={item.uploadedFile}
|
||||
onClick={(event) => openPreview(item, event)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</HorizontalScrollCarousel>
|
||||
|
||||
{#if showViewAll}
|
||||
<div class="mt-2 -mr-2 flex justify-end px-4">
|
||||
|
|
@ -185,7 +177,40 @@
|
|||
{:else}
|
||||
<div class="flex flex-wrap items-start justify-end gap-3">
|
||||
{#each displayItems as item (item.id)}
|
||||
{#if item.isImage && item.preview}
|
||||
{#if item.isMcpPrompt}
|
||||
{@const mcpPrompt =
|
||||
item.attachment?.type === AttachmentType.MCP_PROMPT
|
||||
? (item.attachment as DatabaseMessageExtraMcpPrompt)
|
||||
: item.uploadedFile?.mcpPrompt
|
||||
? {
|
||||
type: AttachmentType.MCP_PROMPT as const,
|
||||
name: item.name,
|
||||
serverName: item.uploadedFile.mcpPrompt.serverName,
|
||||
promptName: item.uploadedFile.mcpPrompt.promptName,
|
||||
content: item.textContent ?? '',
|
||||
arguments: item.uploadedFile.mcpPrompt.arguments
|
||||
}
|
||||
: null}
|
||||
|
||||
{#if mcpPrompt}
|
||||
<ChatAttachmentMcpPrompt
|
||||
class="max-w-[300px] min-w-[200px]"
|
||||
prompt={mcpPrompt}
|
||||
{readonly}
|
||||
isLoading={item.isLoading}
|
||||
loadError={item.loadError}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else if item.isMcpResource && item.attachment?.type === AttachmentType.MCP_RESOURCE}
|
||||
<ChatAttachmentMcpResourceStored
|
||||
extra={item.attachment as DatabaseMessageExtraMcpResource}
|
||||
{readonly}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
onClick={(event) =>
|
||||
openMcpResourcePreview(item.attachment as DatabaseMessageExtraMcpResource, event)}
|
||||
/>
|
||||
{:else if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="cursor-pointer"
|
||||
id={item.id}
|
||||
|
|
@ -241,3 +266,7 @@
|
|||
{imageClass}
|
||||
{activeModelId}
|
||||
/>
|
||||
|
||||
{#if mcpResourcePreviewExtra}
|
||||
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import {
|
||||
ChatAttachmentsList,
|
||||
ChatAttachmentMcpResources,
|
||||
ChatFormActions,
|
||||
ChatFormFileInputInvisible,
|
||||
ChatFormHelperText,
|
||||
ChatFormPromptPicker,
|
||||
ChatFormTextarea
|
||||
} from '$lib/components/app';
|
||||
import { INPUT_CLASSES } from '$lib/constants/input-classes';
|
||||
import { DialogMcpResources } from '$lib/components/app/dialogs';
|
||||
import { INPUT_CLASSES } from '$lib/constants/css-classes';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import {
|
||||
CLIPBOARD_CONTENT_QUOTE_PREFIX,
|
||||
INITIAL_FILE_SIZE,
|
||||
PROMPT_CONTENT_SEPARATOR
|
||||
} from '$lib/constants/chat-form';
|
||||
import { ContentPartType, KeyboardKey, MimeTypeText, SpecialFileType } from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { MimeTypeText } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
|
||||
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo, PromptMessage } from '$lib/types';
|
||||
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
|
||||
import {
|
||||
AudioRecorder,
|
||||
|
|
@ -25,51 +34,95 @@
|
|||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
// Data
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
uploadedFiles?: ChatUploadedFile[];
|
||||
value?: string;
|
||||
|
||||
// UI State
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onFileRemove?: (fileId: string) => void;
|
||||
onFileUpload?: (files: File[]) => void;
|
||||
onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
|
||||
placeholder?: string;
|
||||
showMcpPromptButton?: boolean;
|
||||
|
||||
// Event Handlers
|
||||
onAttachmentRemove?: (index: number) => void;
|
||||
onFilesAdd?: (files: File[]) => void;
|
||||
onStop?: () => void;
|
||||
showHelperText?: boolean;
|
||||
uploadedFiles?: ChatUploadedFile[];
|
||||
onSubmit?: () => void;
|
||||
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
|
||||
onUploadedFileRemove?: (fileId: string) => void;
|
||||
onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className,
|
||||
attachments = [],
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
onFileRemove,
|
||||
onFileUpload,
|
||||
onSend,
|
||||
placeholder = 'Type a message...',
|
||||
showMcpPromptButton = false,
|
||||
uploadedFiles = $bindable([]),
|
||||
value = $bindable(''),
|
||||
onAttachmentRemove,
|
||||
onFilesAdd,
|
||||
onStop,
|
||||
showHelperText = true,
|
||||
uploadedFiles = $bindable([])
|
||||
onSubmit,
|
||||
onSystemPromptClick,
|
||||
onUploadedFileRemove,
|
||||
onUploadedFilesChange,
|
||||
onValueChange
|
||||
}: Props = $props();
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* STATE
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
// Component References
|
||||
let audioRecorder: AudioRecorder | undefined;
|
||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||
let currentConfig = $derived(config());
|
||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
||||
let promptPickerRef: ChatFormPromptPicker | undefined = $state(undefined);
|
||||
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
|
||||
|
||||
// Audio Recording State
|
||||
let isRecording = $state(false);
|
||||
let message = $state('');
|
||||
let recordingSupported = $state(false);
|
||||
|
||||
// Prompt Picker State
|
||||
let isPromptPickerOpen = $state(false);
|
||||
let promptSearchQuery = $state('');
|
||||
|
||||
// Resource Picker State
|
||||
let isResourcePickerOpen = $state(false);
|
||||
let preSelectedResourceUri = $state<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* DERIVED STATE
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
let currentConfig = $derived(config());
|
||||
let pasteLongTextToFileLength = $derived.by(() => {
|
||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
||||
});
|
||||
let previousIsLoading = $state(isLoading);
|
||||
let recordingSupported = $state(false);
|
||||
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
|
||||
|
||||
// Check if model is selected (in ROUTER mode)
|
||||
// Model Selection Logic
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let conversationModel = $derived(
|
||||
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
|
||||
);
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
|
||||
|
||||
// Get active model ID for capability detection
|
||||
let activeModelId = $derived.by(() => {
|
||||
const options = modelOptions();
|
||||
|
||||
|
|
@ -77,14 +130,12 @@
|
|||
return options.length > 0 ? options[0].model : null;
|
||||
}
|
||||
|
||||
// First try user-selected model
|
||||
const selectedId = selectedModelId();
|
||||
if (selectedId) {
|
||||
const model = options.find((m) => m.id === selectedId);
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
// Fallback to conversation model
|
||||
if (conversationModel) {
|
||||
const model = options.find((m) => m.model === conversationModel);
|
||||
if (model) return model.model;
|
||||
|
|
@ -93,46 +144,111 @@
|
|||
return null;
|
||||
});
|
||||
|
||||
function checkModelSelected(): boolean {
|
||||
// Form Validation State
|
||||
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
|
||||
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
|
||||
let hasAttachments = $derived(
|
||||
(attachments && attachments.length > 0) || (uploadedFiles && uploadedFiles.length > 0)
|
||||
);
|
||||
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* PUBLIC API
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
export function focus() {
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
export function resetTextareaHeight() {
|
||||
textareaRef?.resetHeight();
|
||||
}
|
||||
|
||||
export function openModelSelector() {
|
||||
chatFormActionsRef?.openModelSelector();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is selected, open selector if not
|
||||
* @returns true if model is selected, false otherwise
|
||||
*/
|
||||
export function checkModelSelected(): boolean {
|
||||
if (!hasModelSelected) {
|
||||
// Open the model selector
|
||||
chatFormActionsRef?.openModelSelector();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* EVENT HANDLERS - File Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
function handleFileSelect(files: File[]) {
|
||||
onFileUpload?.(files);
|
||||
onFilesAdd?.(files);
|
||||
}
|
||||
|
||||
function handleFileUpload() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
|
||||
async function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
|
||||
function handleFileRemove(fileId: string) {
|
||||
if (fileId.startsWith('attachment-')) {
|
||||
const index = parseInt(fileId.replace('attachment-', ''), 10);
|
||||
if (!isNaN(index) && index >= 0 && index < attachments.length) {
|
||||
onAttachmentRemove?.(index);
|
||||
}
|
||||
} else {
|
||||
onUploadedFileRemove?.(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* EVENT HANDLERS - Input & Keyboard
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
function handleInput() {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
|
||||
|
||||
if (value.startsWith('/') && hasServers) {
|
||||
isPromptPickerOpen = true;
|
||||
promptSearchQuery = value.slice(1);
|
||||
} else {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
|
||||
event.preventDefault();
|
||||
|
||||
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
|
||||
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
|
||||
|
||||
if (!checkModelSelected()) return;
|
||||
|
||||
const messageToSend = message.trim();
|
||||
const filesToSend = [...uploadedFiles];
|
||||
|
||||
message = '';
|
||||
uploadedFiles = [];
|
||||
|
||||
textareaRef?.resetHeight();
|
||||
|
||||
const success = await onSend?.(messageToSend, filesToSend);
|
||||
|
||||
if (!success) {
|
||||
message = messageToSend;
|
||||
uploadedFiles = filesToSend;
|
||||
}
|
||||
onSubmit?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,29 +262,51 @@
|
|||
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onFileUpload?.(files);
|
||||
|
||||
onFilesAdd?.(files);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
|
||||
|
||||
if (text.startsWith('"')) {
|
||||
if (text.startsWith(CLIPBOARD_CONTENT_QUOTE_PREFIX)) {
|
||||
const parsed = parseClipboardContent(text);
|
||||
|
||||
if (parsed.textAttachments.length > 0) {
|
||||
if (parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0) {
|
||||
event.preventDefault();
|
||||
value = parsed.message;
|
||||
onValueChange?.(parsed.message);
|
||||
|
||||
message = parsed.message;
|
||||
// Handle text attachments as files
|
||||
if (parsed.textAttachments.length > 0) {
|
||||
const attachmentFiles = parsed.textAttachments.map(
|
||||
(att) =>
|
||||
new File([att.content], att.name, {
|
||||
type: MimeTypeText.PLAIN
|
||||
})
|
||||
);
|
||||
onFilesAdd?.(attachmentFiles);
|
||||
}
|
||||
|
||||
const attachmentFiles = parsed.textAttachments.map(
|
||||
(att) =>
|
||||
new File([att.content], att.name, {
|
||||
type: MimeTypeText.PLAIN
|
||||
})
|
||||
);
|
||||
// Handle MCP prompt attachments as ChatUploadedFile with mcpPrompt data
|
||||
if (parsed.mcpPromptAttachments.length > 0) {
|
||||
const mcpPromptFiles: ChatUploadedFile[] = parsed.mcpPromptAttachments.map((att) => ({
|
||||
id: crypto.randomUUID(),
|
||||
name: att.name,
|
||||
size: att.content.length,
|
||||
type: SpecialFileType.MCP_PROMPT,
|
||||
file: new File([att.content], `${att.name}.txt`, { type: MimeTypeText.PLAIN }),
|
||||
isLoading: false,
|
||||
textContent: att.content,
|
||||
mcpPrompt: {
|
||||
serverName: att.serverName,
|
||||
promptName: att.promptName,
|
||||
arguments: att.arguments
|
||||
}
|
||||
}));
|
||||
|
||||
onFileUpload?.(attachmentFiles);
|
||||
uploadedFiles = [...uploadedFiles, ...mcpPromptFiles];
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
textareaRef?.focus();
|
||||
|
|
@ -189,14 +327,100 @@
|
|||
type: MimeTypeText.PLAIN
|
||||
});
|
||||
|
||||
onFileUpload?.([textFile]);
|
||||
onFilesAdd?.([textFile]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* EVENT HANDLERS - Prompt Picker
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
function handlePromptLoadStart(
|
||||
placeholderId: string,
|
||||
promptInfo: MCPPromptInfo,
|
||||
args?: Record<string, string>
|
||||
) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
|
||||
const promptName = promptInfo.title || promptInfo.name;
|
||||
const placeholder: ChatUploadedFile = {
|
||||
id: placeholderId,
|
||||
name: promptName,
|
||||
size: 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}.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 - Audio Recording
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
async function handleMicClick() {
|
||||
if (!audioRecorder || !recordingSupported) {
|
||||
console.warn('Audio recording not supported');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +430,7 @@
|
|||
const wavBlob = await convertToWav(audioBlob);
|
||||
const audioFile = createAudioFile(wavBlob);
|
||||
|
||||
onFileUpload?.([audioFile]);
|
||||
onFilesAdd?.([audioFile]);
|
||||
isRecording = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to stop recording:', error);
|
||||
|
|
@ -222,94 +446,111 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
onStop?.();
|
||||
}
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
|
||||
|
||||
// Check if model is selected first
|
||||
if (!checkModelSelected()) return;
|
||||
|
||||
const messageToSend = message.trim();
|
||||
const filesToSend = [...uploadedFiles];
|
||||
|
||||
message = '';
|
||||
uploadedFiles = [];
|
||||
|
||||
textareaRef?.resetHeight();
|
||||
|
||||
const success = await onSend?.(messageToSend, filesToSend);
|
||||
|
||||
if (!success) {
|
||||
message = messageToSend;
|
||||
uploadedFiles = filesToSend;
|
||||
}
|
||||
}
|
||||
/**
|
||||
*
|
||||
*
|
||||
* LIFECYCLE
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(() => textareaRef?.focus(), 10);
|
||||
recordingSupported = isAudioRecordingSupported();
|
||||
audioRecorder = new AudioRecorder();
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
setTimeout(() => textareaRef?.focus(), 10);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (previousIsLoading && !isLoading) {
|
||||
setTimeout(() => textareaRef?.focus(), 10);
|
||||
}
|
||||
|
||||
previousIsLoading = isLoading;
|
||||
});
|
||||
</script>
|
||||
|
||||
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
|
||||
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: ''} {className}"
|
||||
data-slot="chat-form"
|
||||
class="relative {className}"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
|
||||
onSubmit?.();
|
||||
}}
|
||||
>
|
||||
<ChatAttachmentsList
|
||||
bind:uploadedFiles
|
||||
{onFileRemove}
|
||||
limitToSingleRow
|
||||
class="py-5"
|
||||
style="scroll-padding: 1rem;"
|
||||
activeModelId={activeModelId ?? undefined}
|
||||
<ChatFormPromptPicker
|
||||
bind:this={promptPickerRef}
|
||||
isOpen={isPromptPickerOpen}
|
||||
searchQuery={promptSearchQuery}
|
||||
onClose={handlePromptPickerClose}
|
||||
onPromptLoadStart={handlePromptLoadStart}
|
||||
onPromptLoadComplete={handlePromptLoadComplete}
|
||||
onPromptLoadError={handlePromptLoadError}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
|
||||
onpaste={handlePaste}
|
||||
class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: ''}"
|
||||
data-slot="input-area"
|
||||
>
|
||||
<ChatFormTextarea
|
||||
bind:this={textareaRef}
|
||||
bind:value={message}
|
||||
onKeydown={handleKeydown}
|
||||
{disabled}
|
||||
<ChatAttachmentsList
|
||||
{attachments}
|
||||
bind:uploadedFiles
|
||||
onFileRemove={handleFileRemove}
|
||||
limitToSingleRow
|
||||
class="py-5"
|
||||
style="scroll-padding: 1rem;"
|
||||
activeModelId={activeModelId ?? undefined}
|
||||
/>
|
||||
|
||||
<ChatFormActions
|
||||
bind:this={chatFormActionsRef}
|
||||
canSend={message.trim().length > 0 || uploadedFiles.length > 0}
|
||||
hasText={message.trim().length > 0}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
{isRecording}
|
||||
{uploadedFiles}
|
||||
onFileUpload={handleFileUpload}
|
||||
onMicClick={handleMicClick}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<div
|
||||
class="flex-column relative min-h-[48px] items-center rounded-3xl p-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!p-3"
|
||||
onpaste={handlePaste}
|
||||
>
|
||||
<ChatFormTextarea
|
||||
class="px-2 py-1.5 md:pt-0"
|
||||
bind:this={textareaRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
/>
|
||||
|
||||
{#if mcpHasResourceAttachments()}
|
||||
<ChatAttachmentMcpResources
|
||||
class="mb-3"
|
||||
onResourceClick={(uri) => {
|
||||
preSelectedResourceUri = uri;
|
||||
isResourcePickerOpen = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ChatFormActions
|
||||
bind:this={chatFormActionsRef}
|
||||
canSend={canSubmit}
|
||||
hasText={value.trim().length > 0}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
{isRecording}
|
||||
{uploadedFiles}
|
||||
onFileUpload={handleFileUpload}
|
||||
onMicClick={handleMicClick}
|
||||
{onStop}
|
||||
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
|
||||
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
|
||||
onMcpResourcesClick={() => (isResourcePickerOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ChatFormHelperText show={showHelperText} />
|
||||
<DialogMcpResources
|
||||
bind:open={isResourcePickerOpen}
|
||||
preSelectedUri={preSelectedResourceUri}
|
||||
onAttach={(resource) => {
|
||||
mcpStore.attachResource(resource.uri);
|
||||
}}
|
||||
onOpenChange={(newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
preSelectedResourceUri = undefined;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Plus, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
hasAudioModality?: boolean;
|
||||
hasVisionModality?: boolean;
|
||||
hasMcpPromptsSupport?: boolean;
|
||||
hasMcpResourcesSupport?: boolean;
|
||||
onFileUpload?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
onMcpServersClick?: () => void;
|
||||
onMcpResourcesClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
hasAudioModality = false,
|
||||
hasVisionModality = false,
|
||||
hasMcpPromptsSupport = false,
|
||||
hasMcpResourcesSupport = false,
|
||||
onFileUpload,
|
||||
onSystemPromptClick,
|
||||
onMcpPromptClick,
|
||||
onMcpServersClick,
|
||||
onMcpResourcesClick
|
||||
}: Props = $props();
|
||||
|
||||
let isNewChat = $derived(!page.params.id);
|
||||
|
||||
let systemMessageTooltip = $derived(
|
||||
isNewChat
|
||||
? 'Add custom system message for a new conversation'
|
||||
: 'Inject custom system message at the beginning of the conversation'
|
||||
);
|
||||
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
function handleMcpPromptClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpPromptClick?.();
|
||||
}
|
||||
|
||||
function handleMcpServersClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpServersClick?.();
|
||||
}
|
||||
|
||||
function handleMcpResourcesClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpResourcesClick?.();
|
||||
}
|
||||
|
||||
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
<DropdownMenu.Root bind:open={dropdownOpen}>
|
||||
<DropdownMenu.Trigger name="Attach files" {disabled}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<Button
|
||||
class="file-upload-button h-8 w-8 rounded-full p-0"
|
||||
{disabled}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
>
|
||||
<span class="sr-only">{fileUploadTooltipText}</span>
|
||||
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{fileUploadTooltipText}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content align="start" class="w-48">
|
||||
{#if hasVisionModality}
|
||||
<DropdownMenu.Item
|
||||
class="images-button flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onFileUpload?.()}
|
||||
>
|
||||
<FILE_TYPE_ICONS.image class="h-4 w-4" />
|
||||
|
||||
<span>Images</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="images-button flex cursor-pointer items-center gap-2"
|
||||
disabled
|
||||
>
|
||||
<FILE_TYPE_ICONS.image class="h-4 w-4" />
|
||||
|
||||
<span>Images</span>
|
||||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>Images require vision models to be processed</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if hasAudioModality}
|
||||
<DropdownMenu.Item
|
||||
class="audio-button flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onFileUpload?.()}
|
||||
>
|
||||
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
|
||||
|
||||
<span>Audio Files</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item class="audio-button flex cursor-pointer items-center gap-2" disabled>
|
||||
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
|
||||
|
||||
<span>Audio Files</span>
|
||||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>Audio files require audio models to be processed</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onFileUpload?.()}
|
||||
>
|
||||
<FILE_TYPE_ICONS.text class="h-4 w-4" />
|
||||
|
||||
<span>Text Files</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{#if hasVisionModality}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onFileUpload?.()}
|
||||
>
|
||||
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
|
||||
|
||||
<span>PDF Files</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onFileUpload?.()}
|
||||
>
|
||||
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
|
||||
|
||||
<span>PDF Files</span>
|
||||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onSystemPromptClick?.()}
|
||||
>
|
||||
<MessageSquare class="h-4 w-4" />
|
||||
|
||||
<span>System Message</span>
|
||||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>{systemMessageTooltip}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpServersClick}
|
||||
>
|
||||
<McpLogo class="h-4 w-4" />
|
||||
|
||||
<span>MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{#if hasMcpPromptsSupport}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpPromptClick}
|
||||
>
|
||||
<Zap class="h-4 w-4" />
|
||||
|
||||
<span>MCP Prompt</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if hasMcpResourcesSupport}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpResourcesClick}
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
|
||||
<span>MCP Resources</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Paperclip } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
hasAudioModality?: boolean;
|
||||
hasVisionModality?: boolean;
|
||||
onFileUpload?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
hasAudioModality = false,
|
||||
hasVisionModality = false,
|
||||
onFileUpload
|
||||
}: Props = $props();
|
||||
|
||||
const fileUploadTooltipText = $derived.by(() => {
|
||||
return !hasVisionModality
|
||||
? 'Text files and PDFs supported. Images, audio, and video require vision models.'
|
||||
: 'Attach files';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger name="Attach files" {disabled}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
|
||||
{disabled}
|
||||
type="button"
|
||||
>
|
||||
<span class="sr-only">Attach files</span>
|
||||
|
||||
<Paperclip class="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{fileUploadTooltipText}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content align="start" class="w-48">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="images-button flex cursor-pointer items-center gap-2"
|
||||
disabled={!hasVisionModality}
|
||||
onclick={() => onFileUpload?.()}
|
||||
>
|
||||
<FILE_TYPE_ICONS.image class="h-4 w-4" />
|
||||
|
||||
<span>Images</span>
|
||||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if !hasVisionModality}
|
||||
<Tooltip.Content>
|
||||
<p>Images require vision models to be processed</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="audio-button flex cursor-pointer items-center gap-2"
|
||||
disabled={!hasAudioModality}
|
||||
onclick={() => onFileUpload?.()}
|
||||
>
|
||||
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
|
||||
|
||||
<span>Audio Files</span>
|
||||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if !hasAudioModality}
|
||||
<Tooltip.Content>
|
||||
<p>Audio files require audio models to be processed</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onFileUpload?.()}
|
||||
>
|
||||
<FILE_TYPE_ICONS.text class="h-4 w-4" />
|
||||
|
||||
<span>Text Files</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onFileUpload?.()}
|
||||
>
|
||||
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
|
||||
|
||||
<span>PDF Files</span>
|
||||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if !hasVisionModality}
|
||||
<Tooltip.Content>
|
||||
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
|
@ -2,19 +2,21 @@
|
|||
import { Square } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
ChatFormActionFileAttachments,
|
||||
ChatFormActionAttachmentsDropdown,
|
||||
ChatFormActionRecord,
|
||||
ChatFormActionSubmit,
|
||||
McpServersSelector,
|
||||
ModelsSelector
|
||||
} from '$lib/components/app';
|
||||
import { DialogMcpServersSettings } from '$lib/components/app/dialogs';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { FileTypeCategory } from '$lib/enums';
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { activeMessages, usedModalities } from '$lib/stores/conversations.svelte';
|
||||
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
|
||||
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
|
||||
interface Props {
|
||||
canSend?: boolean;
|
||||
|
|
@ -27,6 +29,9 @@
|
|||
onFileUpload?: () => void;
|
||||
onMicClick?: () => void;
|
||||
onStop?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
onMcpResourcesClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -39,7 +44,10 @@
|
|||
uploadedFiles = [],
|
||||
onFileUpload,
|
||||
onMicClick,
|
||||
onStop
|
||||
onStop,
|
||||
onSystemPromptClick,
|
||||
onMcpPromptClick,
|
||||
onMcpResourcesClick
|
||||
}: Props = $props();
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
|
|
@ -153,42 +161,61 @@
|
|||
selectorModelRef?.open();
|
||||
}
|
||||
|
||||
const { handleModelChange } = useModelChangeValidation({
|
||||
getRequiredModalities: () => usedModalities(),
|
||||
onValidationFailure: async (previousModelId) => {
|
||||
if (previousModelId) {
|
||||
await modelsStore.selectModelById(previousModelId);
|
||||
}
|
||||
}
|
||||
let showMcpDialog = $state(false);
|
||||
|
||||
let hasMcpPromptsSupport = $derived.by(() => {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
|
||||
return mcpStore.hasPromptsCapability(perChatOverrides);
|
||||
});
|
||||
|
||||
let hasMcpResourcesSupport = $derived.by(() => {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
|
||||
return mcpStore.hasResourcesCapability(perChatOverrides);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
|
||||
<ChatFormActionFileAttachments
|
||||
class="mr-auto"
|
||||
{disabled}
|
||||
{hasAudioModality}
|
||||
{hasVisionModality}
|
||||
{onFileUpload}
|
||||
/>
|
||||
<div class="mr-auto flex items-center gap-2">
|
||||
<ChatFormActionAttachmentsDropdown
|
||||
{disabled}
|
||||
{hasAudioModality}
|
||||
{hasVisionModality}
|
||||
{hasMcpPromptsSupport}
|
||||
{hasMcpResourcesSupport}
|
||||
{onFileUpload}
|
||||
{onSystemPromptClick}
|
||||
{onMcpPromptClick}
|
||||
{onMcpResourcesClick}
|
||||
onMcpServersClick={() => (showMcpDialog = true)}
|
||||
/>
|
||||
|
||||
<ModelsSelector
|
||||
{disabled}
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={conversationModel}
|
||||
forceForegroundText={true}
|
||||
useGlobalSelection={true}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
<McpServersSelector {disabled} onSettingsClick={() => (showMcpDialog = true)} />
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<ModelsSelector
|
||||
{disabled}
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={conversationModel}
|
||||
forceForegroundText={true}
|
||||
useGlobalSelection={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onclick={onStop}
|
||||
class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
|
||||
class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
|
||||
>
|
||||
<span class="sr-only">Stop</span>
|
||||
<Square class="h-8 w-8 fill-destructive stroke-destructive" />
|
||||
|
||||
<Square
|
||||
class="h-8 w-8 fill-muted-foreground stroke-muted-foreground group-hover:fill-destructive group-hover:stroke-destructive hover:fill-destructive hover:stroke-destructive"
|
||||
/>
|
||||
</Button>
|
||||
{:else if shouldShowRecordButton}
|
||||
<ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
|
||||
|
|
@ -202,3 +229,8 @@
|
|||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DialogMcpServersSettings
|
||||
bind:open={showMcpDialog}
|
||||
onOpenChange={(open) => (showMcpDialog = open)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,401 @@
|
|||
<script lang="ts">
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { debounce } from '$lib/utils';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import ChatFormPromptPickerList from './ChatFormPromptPickerList.svelte';
|
||||
import ChatFormPromptPickerHeader from './ChatFormPromptPickerHeader.svelte';
|
||||
import ChatFormPromptPickerArgumentForm from './ChatFormPromptPickerArgumentForm.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
|
||||
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 = crypto.randomUUID();
|
||||
|
||||
const nonEmptyArgs = Object.fromEntries(
|
||||
Object.entries(args).filter(([, value]) => value.trim() !== '')
|
||||
);
|
||||
const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
|
||||
|
||||
onPromptLoadStart?.(placeholderId, prompt, argsToPass);
|
||||
onClose?.();
|
||||
|
||||
try {
|
||||
const result = await 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>
|
||||
|
||||
<Popover.Root
|
||||
bind:open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger class="pointer-events-none absolute top-0 right-0 left-0 h-0 w-full opacity-0">
|
||||
<span class="sr-only">Open prompt picker</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
avoidCollisions={false}
|
||||
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
|
||||
onkeydown={handleKeydown}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{#if selectedPrompt}
|
||||
{@const server = serverSettingsMap.get(selectedPrompt.serverName)}
|
||||
{@const serverLabel = server ? mcpStore.getServerLabel(server) : selectedPrompt.serverName}
|
||||
|
||||
<div class="p-4">
|
||||
<ChatFormPromptPickerHeader prompt={selectedPrompt} {server} {serverLabel} />
|
||||
|
||||
<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}
|
||||
<ChatFormPromptPickerList
|
||||
prompts={filteredPrompts}
|
||||
{isLoading}
|
||||
{selectedIndex}
|
||||
bind:searchQuery={internalSearchQuery}
|
||||
{showSearchInput}
|
||||
{serverSettingsMap}
|
||||
getServerLabel={(server) => mcpStore.getServerLabel(server)}
|
||||
onPromptClick={handlePromptClick}
|
||||
/>
|
||||
{/if}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts">
|
||||
import type { MCPPromptInfo } from '$lib/types';
|
||||
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
|
||||
|
||||
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="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Use Prompt
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -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,51 @@
|
|||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import type { MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { getFaviconUrl } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
prompt: MCPPromptInfo;
|
||||
server: MCPServerSettingsEntry | undefined;
|
||||
serverLabel: string;
|
||||
}
|
||||
|
||||
let { prompt, server, serverLabel }: Props = $props();
|
||||
|
||||
let faviconUrl = $derived(server ? getFaviconUrl(server.url) : null);
|
||||
</script>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<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">
|
||||
{prompt.title || prompt.name}
|
||||
</span>
|
||||
|
||||
{#if prompt.arguments?.length}
|
||||
<Badge variant="secondary">
|
||||
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if prompt.description}
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{prompt.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<script lang="ts">
|
||||
import type { MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import ChatFormPromptPickerListItem from './ChatFormPromptPickerListItem.svelte';
|
||||
import ChatFormPromptPickerListItemSkeleton from './ChatFormPromptPickerListItemSkeleton.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||
|
||||
interface Props {
|
||||
prompts: MCPPromptInfo[];
|
||||
isLoading: boolean;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
showSearchInput: boolean;
|
||||
serverSettingsMap: SvelteMap<string, MCPServerSettingsEntry>;
|
||||
getServerLabel: (server: MCPServerSettingsEntry) => string;
|
||||
onPromptClick: (prompt: MCPPromptInfo) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
prompts,
|
||||
isLoading,
|
||||
selectedIndex,
|
||||
searchQuery = $bindable(),
|
||||
showSearchInput,
|
||||
serverSettingsMap,
|
||||
getServerLabel,
|
||||
onPromptClick
|
||||
}: Props = $props();
|
||||
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (listContainer && selectedIndex >= 0 && selectedIndex < prompts.length) {
|
||||
const selectedElement = listContainer.querySelector(
|
||||
`[data-prompt-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="Search prompts..." bind:value={searchQuery} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div bind:this={listContainer} class="max-h-64 p-2" class:pt-13={showSearchInput}>
|
||||
{#if isLoading}
|
||||
<ChatFormPromptPickerListItemSkeleton />
|
||||
{:else if prompts.length === 0}
|
||||
<div class="py-6 text-center text-sm text-muted-foreground">
|
||||
{prompts.length === 0 ? 'No MCP prompts available' : 'No prompts found'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each prompts as prompt, index (prompt.serverName + ':' + prompt.name)}
|
||||
{@const server = serverSettingsMap.get(prompt.serverName)}
|
||||
{@const serverLabel = server ? getServerLabel(server) : prompt.serverName}
|
||||
|
||||
<ChatFormPromptPickerListItem
|
||||
data-prompt-index={index}
|
||||
{prompt}
|
||||
{server}
|
||||
{serverLabel}
|
||||
isSelected={index === selectedIndex}
|
||||
onClick={() => onPromptClick(prompt)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import type { MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||
import ChatFormPromptPickerHeader from './ChatFormPromptPickerHeader.svelte';
|
||||
|
||||
interface Props {
|
||||
prompt: MCPPromptInfo;
|
||||
server: MCPServerSettingsEntry | undefined;
|
||||
serverLabel: string;
|
||||
isSelected?: boolean;
|
||||
onClick: () => void;
|
||||
'data-prompt-index'?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
prompt,
|
||||
server,
|
||||
serverLabel,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
'data-prompt-index': dataPromptIndex
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-prompt-index={dataPromptIndex}
|
||||
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'
|
||||
: ''}"
|
||||
>
|
||||
<ChatFormPromptPickerHeader {prompt} {server} {serverLabel} />
|
||||
</button>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<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 w-32 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-4 w-12 animate-pulse rounded-full bg-muted"></div>
|
||||
</div>
|
||||
|
||||
<!-- Description skeleton -->
|
||||
<div class="h-3 w-full animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
onInput?: () => void;
|
||||
onKeydown?: (event: KeyboardEvent) => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
placeholder?: string;
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
onInput,
|
||||
onKeydown,
|
||||
onPaste,
|
||||
placeholder = 'Ask anything...',
|
||||
|
|
@ -52,7 +54,10 @@
|
|||
class:cursor-not-allowed={disabled}
|
||||
{disabled}
|
||||
onkeydown={onKeydown}
|
||||
oninput={(event) => autoResizeTextarea(event.currentTarget)}
|
||||
oninput={(event) => {
|
||||
autoResizeTextarea(event.currentTarget);
|
||||
onInput?.();
|
||||
}}
|
||||
onpaste={onPaste}
|
||||
{placeholder}
|
||||
></textarea>
|
||||
|
|
|
|||
|
|
@ -1,51 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
|
||||
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
|
||||
import ChatMessageUser from './ChatMessageUser.svelte';
|
||||
import ChatMessageSystem from './ChatMessageSystem.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
|
||||
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { DatabaseService } from '$lib/services';
|
||||
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
|
||||
import { MessageRole, AttachmentType } from '$lib/enums';
|
||||
import {
|
||||
ChatMessageAssistant,
|
||||
ChatMessageUser,
|
||||
ChatMessageSystem,
|
||||
ChatMessageMcpPrompt
|
||||
} from '$lib/components/app/chat';
|
||||
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
onCopy?: (message: DatabaseMessage) => void;
|
||||
onContinueAssistantMessage?: (message: DatabaseMessage) => void;
|
||||
onDelete?: (message: DatabaseMessage) => void;
|
||||
onEditWithBranching?: (
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => void;
|
||||
onEditWithReplacement?: (
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
shouldBranch: boolean
|
||||
) => void;
|
||||
onEditUserMessagePreserveResponses?: (
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => void;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
|
||||
isLastAssistantMessage?: boolean;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
onCopy,
|
||||
onContinueAssistantMessage,
|
||||
onDelete,
|
||||
onEditWithBranching,
|
||||
onEditWithReplacement,
|
||||
onEditUserMessagePreserveResponses,
|
||||
onNavigateToSibling,
|
||||
onRegenerateWithBranching,
|
||||
isLastAssistantMessage = false,
|
||||
siblingInfo = null
|
||||
}: Props = $props();
|
||||
|
||||
const chatActions = getChatActionsContext();
|
||||
|
||||
let deletionInfo = $state<{
|
||||
totalCount: number;
|
||||
userMessages: number;
|
||||
|
|
@ -60,62 +46,93 @@
|
|||
let shouldBranchAfterEdit = $state(false);
|
||||
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
||||
|
||||
let thinkingContent = $derived.by(() => {
|
||||
if (message.role === 'assistant') {
|
||||
const trimmedThinking = message.thinking?.trim();
|
||||
let showSaveOnlyOption = $derived(message.role === MessageRole.USER);
|
||||
|
||||
return trimmedThinking ? trimmedThinking : null;
|
||||
setMessageEditContext({
|
||||
get isEditing() {
|
||||
return isEditing;
|
||||
},
|
||||
get editedContent() {
|
||||
return editedContent;
|
||||
},
|
||||
get editedExtras() {
|
||||
return editedExtras;
|
||||
},
|
||||
get editedUploadedFiles() {
|
||||
return editedUploadedFiles;
|
||||
},
|
||||
get originalContent() {
|
||||
return message.content;
|
||||
},
|
||||
get originalExtras() {
|
||||
return message.extra || [];
|
||||
},
|
||||
get showSaveOnlyOption() {
|
||||
return showSaveOnlyOption;
|
||||
},
|
||||
setContent: (content: string) => {
|
||||
editedContent = content;
|
||||
},
|
||||
setExtras: (extras: DatabaseMessageExtra[]) => {
|
||||
editedExtras = extras;
|
||||
},
|
||||
setUploadedFiles: (files: ChatUploadedFile[]) => {
|
||||
editedUploadedFiles = files;
|
||||
},
|
||||
save: handleSaveEdit,
|
||||
saveOnly: handleSaveEditOnly,
|
||||
cancel: handleCancelEdit,
|
||||
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;
|
||||
});
|
||||
|
||||
let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
|
||||
if (message.role === 'assistant') {
|
||||
const trimmedToolCalls = message.toolCalls?.trim();
|
||||
$effect(() => {
|
||||
const pendingId = pendingEditMessageId();
|
||||
|
||||
if (!trimmedToolCalls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedToolCalls);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed as ApiChatCompletionToolCall[];
|
||||
}
|
||||
} catch {
|
||||
// Harmony-only path: fall back to the raw string so issues surface visibly.
|
||||
}
|
||||
|
||||
return trimmedToolCalls;
|
||||
if (pendingId && pendingId === message.id && !isEditing) {
|
||||
handleEdit();
|
||||
chatStore.clearPendingEditMessageId();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function handleCancelEdit() {
|
||||
async function handleCancelEdit() {
|
||||
isEditing = false;
|
||||
|
||||
// If canceling a new system message with placeholder content, remove it without deleting children
|
||||
if (message.role === MessageRole.SYSTEM) {
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
|
||||
if (conversationDeleted) {
|
||||
goto(`${base}/`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
editedContent = message.content;
|
||||
editedExtras = message.extra ? [...message.extra] : [];
|
||||
editedUploadedFiles = [];
|
||||
}
|
||||
|
||||
function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
|
||||
editedExtras = extras;
|
||||
}
|
||||
|
||||
function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
|
||||
editedUploadedFiles = files;
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
|
||||
const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
|
||||
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
|
||||
onCopy?.(message);
|
||||
function handleCopy() {
|
||||
chatActions.copy(message);
|
||||
}
|
||||
|
||||
function handleConfirmDelete() {
|
||||
onDelete?.(message);
|
||||
chatActions.delete(message);
|
||||
showDeleteDialog = false;
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +143,12 @@
|
|||
|
||||
function handleEdit() {
|
||||
isEditing = true;
|
||||
editedContent = message.content;
|
||||
// Clear temporary placeholder content for system messages
|
||||
editedContent =
|
||||
message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER
|
||||
? ''
|
||||
: message.content;
|
||||
textareaElement?.focus();
|
||||
editedExtras = message.extra ? [...message.extra] : [];
|
||||
editedUploadedFiles = [];
|
||||
|
||||
|
|
@ -141,38 +163,45 @@
|
|||
}, 0);
|
||||
}
|
||||
|
||||
function handleEditedContentChange(content: string) {
|
||||
editedContent = content;
|
||||
}
|
||||
|
||||
function handleEditKeydown(event: KeyboardEvent) {
|
||||
// Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
|
||||
// This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
|
||||
event.preventDefault();
|
||||
handleSaveEdit();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
handleCancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRegenerate(modelOverride?: string) {
|
||||
onRegenerateWithBranching?.(message, modelOverride);
|
||||
chatActions.regenerateWithBranching(message, modelOverride);
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
onContinueAssistantMessage?.(message);
|
||||
chatActions.continueAssistantMessage(message);
|
||||
}
|
||||
|
||||
function handleNavigateToSibling(siblingId: string) {
|
||||
chatActions.navigateToSibling(siblingId);
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (message.role === 'user' || message.role === 'system') {
|
||||
if (message.role === MessageRole.SYSTEM) {
|
||||
// System messages: update in place without branching
|
||||
const newContent = editedContent.trim();
|
||||
|
||||
// If content is empty, remove without deleting children
|
||||
if (!newContent) {
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
isEditing = false;
|
||||
if (conversationDeleted) {
|
||||
goto(`${base}/`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await DatabaseService.updateMessage(message.id, { content: newContent });
|
||||
const index = conversationsStore.findMessageIndex(message.id);
|
||||
if (index !== -1) {
|
||||
conversationsStore.updateMessageAtIndex(index, { content: newContent });
|
||||
}
|
||||
} else if (message.role === MessageRole.USER) {
|
||||
const finalExtras = await getMergedExtras();
|
||||
onEditWithBranching?.(message, editedContent.trim(), finalExtras);
|
||||
chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
|
||||
} else {
|
||||
// For assistant messages, preserve exact content including trailing whitespace
|
||||
// This is important for the Continue feature to work properly
|
||||
onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
|
||||
chatActions.editWithReplacement(message, editedContent, shouldBranchAfterEdit);
|
||||
}
|
||||
|
||||
isEditing = false;
|
||||
|
|
@ -181,10 +210,10 @@
|
|||
}
|
||||
|
||||
async function handleSaveEditOnly() {
|
||||
if (message.role === 'user') {
|
||||
if (message.role === MessageRole.USER) {
|
||||
// For user messages, trim to avoid accidental whitespace
|
||||
const finalExtras = await getMergedExtras();
|
||||
onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
|
||||
chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
|
||||
}
|
||||
|
||||
isEditing = false;
|
||||
|
|
@ -196,8 +225,8 @@
|
|||
return editedExtras;
|
||||
}
|
||||
|
||||
const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
|
||||
const result = await parseFilesToMessageExtras(editedUploadedFiles);
|
||||
const plainFiles = $state.snapshot(editedUploadedFiles);
|
||||
const result = await parseFilesToMessageExtras(plainFiles);
|
||||
const newExtras = result?.extras || [];
|
||||
|
||||
return [...editedExtras, ...newExtras];
|
||||
|
|
@ -208,49 +237,46 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if message.role === 'system'}
|
||||
{#if message.role === MessageRole.SYSTEM}
|
||||
<ChatMessageSystem
|
||||
bind:textareaElement
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{editedContent}
|
||||
{isEditing}
|
||||
{message}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditKeydown={handleEditKeydown}
|
||||
onEditedContentChange={handleEditedContentChange}
|
||||
{onNavigateToSibling}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
/>
|
||||
{:else if message.role === 'user'}
|
||||
<ChatMessageUser
|
||||
bind:textareaElement
|
||||
{:else if mcpPromptExtra}
|
||||
<ChatMessageMcpPrompt
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{editedContent}
|
||||
{editedExtras}
|
||||
{editedUploadedFiles}
|
||||
{isEditing}
|
||||
{message}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
mcpPrompt={mcpPromptExtra}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditKeydown={handleEditKeydown}
|
||||
onEditedContentChange={handleEditedContentChange}
|
||||
onEditedExtrasChange={handleEditedExtrasChange}
|
||||
onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
|
||||
{onNavigateToSibling}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onSaveEditOnly={handleSaveEditOnly}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
/>
|
||||
{:else if message.role === MessageRole.USER}
|
||||
<ChatMessageUser
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{message}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
|
|
@ -260,27 +286,18 @@
|
|||
bind:textareaElement
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{editedContent}
|
||||
{isEditing}
|
||||
{isLastAssistantMessage}
|
||||
{message}
|
||||
messageContent={message.content}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onContinue={handleContinue}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditKeydown={handleEditKeydown}
|
||||
onEditedContentChange={handleEditedContentChange}
|
||||
{onNavigateToSibling}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onRegenerate={handleRegenerate}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{shouldBranchAfterEdit}
|
||||
onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
{thinkingContent}
|
||||
{toolCallContent}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
|
||||
import {
|
||||
ActionButton,
|
||||
ActionIcon,
|
||||
ChatMessageBranchingControls,
|
||||
DialogConfirmation
|
||||
} from '$lib/components/app';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
role: 'user' | 'assistant';
|
||||
role: MessageRole.USER | MessageRole.ASSISTANT;
|
||||
justify: 'start' | 'end';
|
||||
actionsPosition: 'left' | 'right';
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
|
|
@ -26,6 +28,9 @@
|
|||
onConfirmDelete: () => void;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onShowDeleteDialogChange: (show: boolean) => void;
|
||||
showRawOutputSwitch?: boolean;
|
||||
rawOutputEnabled?: boolean;
|
||||
onRawOutputToggle?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -42,7 +47,10 @@
|
|||
onRegenerate,
|
||||
role,
|
||||
siblingInfo = null,
|
||||
showDeleteDialog
|
||||
showDeleteDialog,
|
||||
showRawOutputSwitch = false,
|
||||
rawOutputEnabled = false,
|
||||
onRawOutputToggle
|
||||
}: Props = $props();
|
||||
|
||||
function handleConfirmDelete() {
|
||||
|
|
@ -51,9 +59,9 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-{justify}">
|
||||
<div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-between">
|
||||
<div
|
||||
class="absolute top-0 {actionsPosition === 'left'
|
||||
class="{actionsPosition === 'left'
|
||||
? 'left-0'
|
||||
: 'right-0'} flex items-center gap-2 opacity-100 transition-opacity"
|
||||
>
|
||||
|
|
@ -64,23 +72,33 @@
|
|||
<div
|
||||
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
|
||||
>
|
||||
<ActionButton icon={Copy} tooltip="Copy" onclick={onCopy} />
|
||||
<ActionIcon icon={Copy} tooltip="Copy" onclick={onCopy} />
|
||||
|
||||
{#if onEdit}
|
||||
<ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} />
|
||||
<ActionIcon icon={Edit} tooltip="Edit" onclick={onEdit} />
|
||||
{/if}
|
||||
|
||||
{#if role === 'assistant' && onRegenerate}
|
||||
<ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
|
||||
{#if role === MessageRole.ASSISTANT && onRegenerate}
|
||||
<ActionIcon icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
|
||||
{/if}
|
||||
|
||||
{#if role === 'assistant' && onContinue}
|
||||
<ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
|
||||
{#if role === MessageRole.ASSISTANT && onContinue}
|
||||
<ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
|
||||
{/if}
|
||||
|
||||
<ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
|
||||
<ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showRawOutputSwitch}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">Show raw output</span>
|
||||
<Switch
|
||||
checked={rawOutputEnabled}
|
||||
onCheckedChange={(checked) => onRawOutputToggle?.(checked)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DialogConfirmation
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
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 } from '$lib/constants/agentic-ui';
|
||||
import { parseAgenticContent, type AgenticSection } from '$lib/utils/agentic';
|
||||
import type { DatabaseMessage, DatabaseMessageExtraImageFile } from '$lib/types/database';
|
||||
|
||||
interface Props {
|
||||
/** Optional database message for context */
|
||||
message?: DatabaseMessage;
|
||||
/** Raw content string to parse and display */
|
||||
content: string;
|
||||
/** Whether content is currently streaming */
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
type ToolResultLine = {
|
||||
text: string;
|
||||
image?: DatabaseMessageExtraImageFile;
|
||||
};
|
||||
|
||||
let { content, message, isStreaming = 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)
|
||||
: []
|
||||
}))
|
||||
);
|
||||
|
||||
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('\n');
|
||||
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 };
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="agentic-content">
|
||||
{#each sectionsParsed as section, index (index)}
|
||||
{#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}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.agentic-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.agentic-text {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,26 +1,29 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ModelBadge,
|
||||
ChatMessageAgenticContent,
|
||||
ChatMessageActions,
|
||||
ChatMessageStatistics,
|
||||
ChatMessageThinkingBlock,
|
||||
CopyToClipboardIcon,
|
||||
MarkdownContent,
|
||||
ModelBadge,
|
||||
ModelsSelector
|
||||
} from '$lib/components/app';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
|
||||
import { isLoading } from '$lib/stores/chat.svelte';
|
||||
import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
|
||||
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
|
||||
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
|
||||
import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Check, X, Wrench } from '@lucide/svelte';
|
||||
import { Check, X } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { INPUT_CLASSES } from '$lib/constants/input-classes';
|
||||
import { INPUT_CLASSES } from '$lib/constants/css-classes';
|
||||
import { MessageRole, KeyboardKey } from '$lib/enums';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { AGENTIC_TAGS, REASONING_TAGS } from '$lib/constants/agentic';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -30,150 +33,109 @@
|
|||
assistantMessages: number;
|
||||
messageTypes: string[];
|
||||
} | null;
|
||||
editedContent?: string;
|
||||
isEditing?: boolean;
|
||||
isLastAssistantMessage?: boolean;
|
||||
message: DatabaseMessage;
|
||||
messageContent: string | undefined;
|
||||
onCancelEdit?: () => void;
|
||||
onCopy: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onContinue?: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: () => void;
|
||||
onEditKeydown?: (event: KeyboardEvent) => void;
|
||||
onEditedContentChange?: (content: string) => void;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onRegenerate: (modelOverride?: string) => void;
|
||||
onSaveEdit?: () => void;
|
||||
onShowDeleteDialogChange: (show: boolean) => void;
|
||||
onShouldBranchAfterEditChange?: (value: boolean) => void;
|
||||
showDeleteDialog: boolean;
|
||||
shouldBranchAfterEdit?: boolean;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
textareaElement?: HTMLTextAreaElement;
|
||||
thinkingContent: string | null;
|
||||
toolCallContent: ApiChatCompletionToolCall[] | string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
deletionInfo,
|
||||
editedContent = '',
|
||||
isEditing = false,
|
||||
isLastAssistantMessage = false,
|
||||
message,
|
||||
messageContent,
|
||||
onCancelEdit,
|
||||
onConfirmDelete,
|
||||
onContinue,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onEditKeydown,
|
||||
onEditedContentChange,
|
||||
onNavigateToSibling,
|
||||
onRegenerate,
|
||||
onSaveEdit,
|
||||
onShowDeleteDialogChange,
|
||||
onShouldBranchAfterEditChange,
|
||||
showDeleteDialog,
|
||||
shouldBranchAfterEdit = false,
|
||||
siblingInfo = null,
|
||||
textareaElement = $bindable(),
|
||||
thinkingContent,
|
||||
toolCallContent = null
|
||||
textareaElement = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
const toolCalls = $derived(
|
||||
Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
|
||||
);
|
||||
const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
|
||||
// Get edit context
|
||||
const editCtx = getMessageEditContext();
|
||||
|
||||
// Local state for assistant-specific editing
|
||||
let shouldBranchAfterEdit = $state(false);
|
||||
|
||||
function handleEditKeydown(event: KeyboardEvent) {
|
||||
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
|
||||
event.preventDefault();
|
||||
editCtx.save();
|
||||
} else if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
editCtx.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
const hasAgenticMarkers = $derived(
|
||||
messageContent?.includes(AGENTIC_TAGS.TOOL_CALL_START) ?? false
|
||||
);
|
||||
const hasStreamingToolCall = $derived(
|
||||
isChatStreaming() && agenticStreamingToolCall(message.convId) !== null
|
||||
);
|
||||
const hasReasoningMarkers = $derived(messageContent?.includes(REASONING_TAGS.START) ?? false);
|
||||
const isStructuredContent = $derived(
|
||||
hasAgenticMarkers || hasReasoningMarkers || hasStreamingToolCall
|
||||
);
|
||||
const processingState = useProcessingState();
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let displayedModel = $derived((): string | null => {
|
||||
if (message.model) {
|
||||
return message.model;
|
||||
}
|
||||
let showRawOutput = $state(false);
|
||||
|
||||
return null;
|
||||
});
|
||||
let displayedModel = $derived(message.model ?? null);
|
||||
|
||||
const { handleModelChange } = useModelChangeValidation({
|
||||
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
|
||||
onSuccess: (modelName) => onRegenerate(modelName)
|
||||
});
|
||||
let isCurrentlyLoading = $derived(isLoading());
|
||||
let isStreaming = $derived(isChatStreaming());
|
||||
let hasNoContent = $derived(!message?.content?.trim());
|
||||
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
|
||||
|
||||
let showProcessingInfoTop = $derived(
|
||||
message?.role === MessageRole.ASSISTANT &&
|
||||
isActivelyProcessing &&
|
||||
hasNoContent &&
|
||||
isLastAssistantMessage
|
||||
);
|
||||
|
||||
let showProcessingInfoBottom = $derived(
|
||||
message?.role === MessageRole.ASSISTANT &&
|
||||
isActivelyProcessing &&
|
||||
!hasNoContent &&
|
||||
isLastAssistantMessage
|
||||
);
|
||||
|
||||
function handleCopyModel() {
|
||||
const model = displayedModel();
|
||||
|
||||
void copyToClipboard(model ?? '');
|
||||
void copyToClipboard(displayedModel ?? '');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isEditing && textareaElement) {
|
||||
if (editCtx.isEditing && textareaElement) {
|
||||
autoResizeTextarea(textareaElement);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isLoading() && !message?.content?.trim()) {
|
||||
if (showProcessingInfoTop || showProcessingInfoBottom) {
|
||||
processingState.startMonitoring();
|
||||
}
|
||||
});
|
||||
|
||||
function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
|
||||
const callNumber = index + 1;
|
||||
const functionName = toolCall.function?.name?.trim();
|
||||
const label = functionName || `Call #${callNumber}`;
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
const id = toolCall.id?.trim();
|
||||
if (id) {
|
||||
payload.id = id;
|
||||
}
|
||||
|
||||
const type = toolCall.type?.trim();
|
||||
if (type) {
|
||||
payload.type = type;
|
||||
}
|
||||
|
||||
if (toolCall.function) {
|
||||
const fnPayload: Record<string, unknown> = {};
|
||||
|
||||
const name = toolCall.function.name?.trim();
|
||||
if (name) {
|
||||
fnPayload.name = name;
|
||||
}
|
||||
|
||||
const rawArguments = toolCall.function.arguments?.trim();
|
||||
if (rawArguments) {
|
||||
try {
|
||||
fnPayload.arguments = JSON.parse(rawArguments);
|
||||
} catch {
|
||||
fnPayload.arguments = rawArguments;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(fnPayload).length > 0) {
|
||||
payload.function = fnPayload;
|
||||
}
|
||||
}
|
||||
|
||||
const formattedPayload = JSON.stringify(payload, null, 2);
|
||||
|
||||
return {
|
||||
label,
|
||||
tooltip: formattedPayload,
|
||||
copyValue: formattedPayload
|
||||
};
|
||||
}
|
||||
|
||||
function handleCopyToolCall(payload: string) {
|
||||
void copyToClipboard(payload, 'Tool call copied to clipboard');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
@ -181,34 +143,28 @@
|
|||
role="group"
|
||||
aria-label="Assistant message with actions"
|
||||
>
|
||||
{#if thinkingContent}
|
||||
<ChatMessageThinkingBlock
|
||||
reasoningContent={thinkingContent}
|
||||
isStreaming={!message.timestamp}
|
||||
hasRegularContent={!!messageContent?.trim()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
|
||||
{#if showProcessingInfoTop}
|
||||
<div class="mt-6 w-full max-w-[48rem]" in:fade>
|
||||
<div class="processing-container">
|
||||
<span class="processing-text">
|
||||
{processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
|
||||
{processingState.getPromptProgressText() ??
|
||||
processingState.getProcessingMessage() ??
|
||||
'Processing...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isEditing}
|
||||
{#if editCtx.isEditing}
|
||||
<div class="w-full">
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value={editedContent}
|
||||
value={editCtx.editedContent}
|
||||
class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
|
||||
onkeydown={onEditKeydown}
|
||||
onkeydown={handleEditKeydown}
|
||||
oninput={(e) => {
|
||||
autoResizeTextarea(e.currentTarget);
|
||||
onEditedContentChange?.(e.currentTarget.value);
|
||||
editCtx.setContent(e.currentTarget.value);
|
||||
}}
|
||||
placeholder="Edit assistant message..."
|
||||
></textarea>
|
||||
|
|
@ -218,30 +174,41 @@
|
|||
<Checkbox
|
||||
id="branch-after-edit"
|
||||
bind:checked={shouldBranchAfterEdit}
|
||||
onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)}
|
||||
onCheckedChange={(checked) => (shouldBranchAfterEdit = checked === true)}
|
||||
/>
|
||||
<Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
|
||||
Branch conversation after edit
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
|
||||
<Button class="h-8 px-3" onclick={editCtx.cancel} size="sm" variant="outline">
|
||||
<X class="mr-1 h-3 w-3" />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm">
|
||||
<Button
|
||||
class="h-8 px-3"
|
||||
onclick={editCtx.save}
|
||||
disabled={!editCtx.editedContent?.trim()}
|
||||
size="sm"
|
||||
>
|
||||
<Check class="mr-1 h-3 w-3" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if message.role === 'assistant'}
|
||||
{#if config().disableReasoningFormat}
|
||||
{:else if message.role === MessageRole.ASSISTANT}
|
||||
{#if showRawOutput}
|
||||
<pre class="raw-output">{messageContent || ''}</pre>
|
||||
{:else if isStructuredContent}
|
||||
<ChatMessageAgenticContent
|
||||
content={messageContent || ''}
|
||||
isStreaming={isChatStreaming()}
|
||||
{message}
|
||||
/>
|
||||
{:else}
|
||||
<MarkdownContent content={messageContent || ''} />
|
||||
<MarkdownContent content={messageContent || ''} attachments={message.extra} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm whitespace-pre-wrap">
|
||||
|
|
@ -249,18 +216,38 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showProcessingInfoBottom}
|
||||
<div class="mt-4 w-full max-w-[48rem]" in:fade>
|
||||
<div class="processing-container">
|
||||
<span class="processing-text">
|
||||
{processingState.getPromptProgressText() ??
|
||||
processingState.getProcessingMessage() ??
|
||||
'Processing...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info my-6 grid gap-4 tabular-nums">
|
||||
{#if displayedModel()}
|
||||
{#if displayedModel}
|
||||
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||
{#if isRouter}
|
||||
<ModelsSelector
|
||||
currentModel={displayedModel()}
|
||||
onModelChange={handleModelChange}
|
||||
currentModel={displayedModel}
|
||||
disabled={isLoading()}
|
||||
upToMessageId={message.id}
|
||||
onModelChange={async (modelId, modelName) => {
|
||||
const status = modelsStore.getModelStatus(modelId);
|
||||
|
||||
if (status !== ServerModelStatus.LOADED) {
|
||||
await modelsStore.loadModel(modelId);
|
||||
}
|
||||
|
||||
onRegenerate(modelName);
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
|
||||
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
|
||||
{/if}
|
||||
|
||||
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||
|
|
@ -269,6 +256,7 @@
|
|||
promptMs={message.timings.prompt_ms}
|
||||
predictedTokens={message.timings.predicted_n}
|
||||
predictedMs={message.timings.predicted_ms}
|
||||
agenticTimings={message.timings.agentic}
|
||||
/>
|
||||
{:else if isLoading() && currentConfig.showMessageStats}
|
||||
{@const liveStats = processingState.getLiveProcessingStats()}
|
||||
|
|
@ -290,53 +278,11 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if config().showToolCalls}
|
||||
{#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
|
||||
<span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Wrench class="h-3.5 w-3.5" />
|
||||
|
||||
<span>Tool calls:</span>
|
||||
</span>
|
||||
|
||||
{#if toolCalls && toolCalls.length > 0}
|
||||
{#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
|
||||
{@const badge = formatToolCallBadge(toolCall, index)}
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
title={badge.tooltip}
|
||||
aria-label={`Copy tool call ${badge.label}`}
|
||||
onclick={() => handleCopyToolCall(badge.copyValue)}
|
||||
>
|
||||
{badge.label}
|
||||
<CopyToClipboardIcon
|
||||
text={badge.copyValue}
|
||||
ariaLabel={`Copy tool call ${badge.label}`}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if fallbackToolCalls}
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-badge tool-call-badge--fallback inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
title={fallbackToolCalls}
|
||||
aria-label="Copy tool call payload"
|
||||
onclick={() => handleCopyToolCall(fallbackToolCalls)}
|
||||
>
|
||||
{fallbackToolCalls}
|
||||
<CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if message.timestamp && !isEditing}
|
||||
{#if message.timestamp && !editCtx.isEditing}
|
||||
<ChatMessageActions
|
||||
role="assistant"
|
||||
role={MessageRole.ASSISTANT}
|
||||
justify="start"
|
||||
actionsPosition="left"
|
||||
{siblingInfo}
|
||||
|
|
@ -345,13 +291,16 @@
|
|||
{onCopy}
|
||||
{onEdit}
|
||||
{onRegenerate}
|
||||
onContinue={currentConfig.enableContinueGeneration && !thinkingContent
|
||||
onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
|
||||
? onContinue
|
||||
: undefined}
|
||||
{onDelete}
|
||||
{onConfirmDelete}
|
||||
{onNavigateToSibling}
|
||||
{onShowDeleteDialogChange}
|
||||
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
|
||||
rawOutputEnabled={showRawOutput}
|
||||
onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -402,17 +351,4 @@
|
|||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-call-badge {
|
||||
max-width: 12rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tool-call-badge--fallback {
|
||||
max-width: 20rem;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,79 +1,26 @@
|
|||
<script lang="ts">
|
||||
import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
|
||||
import { X, AlertTriangle } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
|
||||
import { INPUT_CLASSES } from '$lib/constants/input-classes';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
|
||||
import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import {
|
||||
autoResizeTextarea,
|
||||
getFileTypeCategory,
|
||||
getFileTypeCategoryByExtension,
|
||||
parseClipboardContent
|
||||
} from '$lib/utils';
|
||||
import { ChatForm, DialogConfirmation } from '$lib/components/app';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
|
||||
|
||||
interface Props {
|
||||
messageId: string;
|
||||
editedContent: string;
|
||||
editedExtras?: DatabaseMessageExtra[];
|
||||
editedUploadedFiles?: ChatUploadedFile[];
|
||||
originalContent: string;
|
||||
originalExtras?: DatabaseMessageExtra[];
|
||||
showSaveOnlyOption?: boolean;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: () => void;
|
||||
onSaveEditOnly?: () => void;
|
||||
onEditKeydown: (event: KeyboardEvent) => void;
|
||||
onEditedContentChange: (content: string) => void;
|
||||
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
|
||||
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
|
||||
textareaElement?: HTMLTextAreaElement;
|
||||
}
|
||||
const editCtx = getMessageEditContext();
|
||||
|
||||
let {
|
||||
messageId,
|
||||
editedContent,
|
||||
editedExtras = [],
|
||||
editedUploadedFiles = [],
|
||||
originalContent,
|
||||
originalExtras = [],
|
||||
showSaveOnlyOption = false,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onSaveEditOnly,
|
||||
onEditKeydown,
|
||||
onEditedContentChange,
|
||||
onEditedExtrasChange,
|
||||
onEditedUploadedFilesChange,
|
||||
textareaElement = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
let fileInputElement: HTMLInputElement | undefined = $state();
|
||||
let inputAreaRef: ChatForm | undefined = $state(undefined);
|
||||
let saveWithoutRegenerate = $state(false);
|
||||
let showDiscardDialog = $state(false);
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let currentConfig = $derived(config());
|
||||
|
||||
let pasteLongTextToFileLength = $derived.by(() => {
|
||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||
|
||||
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
||||
});
|
||||
|
||||
let hasUnsavedChanges = $derived.by(() => {
|
||||
if (editedContent !== originalContent) return true;
|
||||
if (editedUploadedFiles.length > 0) return true;
|
||||
if (editCtx.editedContent !== editCtx.originalContent) return true;
|
||||
if (editCtx.editedUploadedFiles.length > 0) return true;
|
||||
|
||||
const extrasChanged =
|
||||
editedExtras.length !== originalExtras.length ||
|
||||
editedExtras.some((extra, i) => extra !== originalExtras[i]);
|
||||
editCtx.editedExtras.length !== editCtx.originalExtras.length ||
|
||||
editCtx.editedExtras.some((extra, i) => extra !== editCtx.originalExtras[i]);
|
||||
|
||||
if (extrasChanged) return true;
|
||||
|
||||
|
|
@ -81,77 +28,14 @@
|
|||
});
|
||||
|
||||
let hasAttachments = $derived(
|
||||
(editedExtras && editedExtras.length > 0) ||
|
||||
(editedUploadedFiles && editedUploadedFiles.length > 0)
|
||||
(editCtx.editedExtras && editCtx.editedExtras.length > 0) ||
|
||||
(editCtx.editedUploadedFiles && editCtx.editedUploadedFiles.length > 0)
|
||||
);
|
||||
|
||||
let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
|
||||
|
||||
function getEditedAttachmentsModalities(): ModelModalities {
|
||||
const modalities: ModelModalities = { vision: false, audio: false };
|
||||
|
||||
for (const extra of editedExtras) {
|
||||
if (extra.type === AttachmentType.IMAGE) {
|
||||
modalities.vision = true;
|
||||
}
|
||||
|
||||
if (
|
||||
extra.type === AttachmentType.PDF &&
|
||||
'processedAsImages' in extra &&
|
||||
extra.processedAsImages
|
||||
) {
|
||||
modalities.vision = true;
|
||||
}
|
||||
|
||||
if (extra.type === AttachmentType.AUDIO) {
|
||||
modalities.audio = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of editedUploadedFiles) {
|
||||
const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
|
||||
if (category === FileTypeCategory.IMAGE) {
|
||||
modalities.vision = true;
|
||||
}
|
||||
if (category === FileTypeCategory.AUDIO) {
|
||||
modalities.audio = true;
|
||||
}
|
||||
}
|
||||
|
||||
return modalities;
|
||||
}
|
||||
|
||||
function getRequiredModalities(): ModelModalities {
|
||||
const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
|
||||
const editedModalities = getEditedAttachmentsModalities();
|
||||
|
||||
return {
|
||||
vision: beforeModalities.vision || editedModalities.vision,
|
||||
audio: beforeModalities.audio || editedModalities.audio
|
||||
};
|
||||
}
|
||||
|
||||
const { handleModelChange } = useModelChangeValidation({
|
||||
getRequiredModalities,
|
||||
onValidationFailure: async (previousModelId) => {
|
||||
if (previousModelId) {
|
||||
await modelsStore.selectModelById(previousModelId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileInputChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
const files = Array.from(input.files);
|
||||
|
||||
processNewFiles(files);
|
||||
input.value = '';
|
||||
}
|
||||
let canSubmit = $derived(editCtx.editedContent.trim().length > 0 || hasAttachments);
|
||||
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
attemptCancel();
|
||||
}
|
||||
|
|
@ -161,205 +45,71 @@
|
|||
if (hasUnsavedChanges) {
|
||||
showDiscardDialog = true;
|
||||
} else {
|
||||
onCancelEdit();
|
||||
editCtx.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveExistingAttachment(index: number) {
|
||||
if (!onEditedExtrasChange) return;
|
||||
|
||||
const newExtras = [...editedExtras];
|
||||
|
||||
newExtras.splice(index, 1);
|
||||
onEditedExtrasChange(newExtras);
|
||||
}
|
||||
|
||||
function handleRemoveUploadedFile(fileId: string) {
|
||||
if (!onEditedUploadedFilesChange) return;
|
||||
|
||||
const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
|
||||
|
||||
onEditedUploadedFilesChange(newFiles);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
|
||||
if (saveWithoutRegenerate && onSaveEditOnly) {
|
||||
onSaveEditOnly();
|
||||
if (saveWithoutRegenerate && editCtx.showSaveOnlyOption) {
|
||||
editCtx.saveOnly();
|
||||
} else {
|
||||
onSaveEdit();
|
||||
editCtx.save();
|
||||
}
|
||||
|
||||
saveWithoutRegenerate = false;
|
||||
}
|
||||
|
||||
async function processNewFiles(files: File[]) {
|
||||
if (!onEditedUploadedFilesChange) return;
|
||||
function handleAttachmentRemove(index: number) {
|
||||
const newExtras = [...editCtx.editedExtras];
|
||||
newExtras.splice(index, 1);
|
||||
editCtx.setExtras(newExtras);
|
||||
}
|
||||
|
||||
const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
|
||||
function handleUploadedFileRemove(fileId: string) {
|
||||
const newFiles = editCtx.editedUploadedFiles.filter((f) => f.id !== fileId);
|
||||
editCtx.setUploadedFiles(newFiles);
|
||||
}
|
||||
|
||||
async function handleFilesAdd(files: File[]) {
|
||||
const processed = await processFilesToChatUploaded(files);
|
||||
|
||||
onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
|
||||
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
if (!event.clipboardData) return;
|
||||
|
||||
const files = Array.from(event.clipboardData.items)
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
processNewFiles(files);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
|
||||
|
||||
if (text.startsWith('"')) {
|
||||
const parsed = parseClipboardContent(text);
|
||||
|
||||
if (parsed.textAttachments.length > 0) {
|
||||
event.preventDefault();
|
||||
onEditedContentChange(parsed.message);
|
||||
|
||||
const attachmentFiles = parsed.textAttachments.map(
|
||||
(att) =>
|
||||
new File([att.content], att.name, {
|
||||
type: MimeTypeText.PLAIN
|
||||
})
|
||||
);
|
||||
|
||||
processNewFiles(attachmentFiles);
|
||||
|
||||
setTimeout(() => {
|
||||
textareaElement?.focus();
|
||||
}, 10);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
text.length > 0 &&
|
||||
pasteLongTextToFileLength > 0 &&
|
||||
text.length > pasteLongTextToFileLength
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
const textFile = new File([text], 'Pasted', {
|
||||
type: MimeTypeText.PLAIN
|
||||
});
|
||||
|
||||
processNewFiles([textFile]);
|
||||
}
|
||||
function handleUploadedFilesChange(files: ChatUploadedFile[]) {
|
||||
editCtx.setUploadedFiles(files);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (textareaElement) {
|
||||
autoResizeTextarea(textareaElement);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
setEditModeActive(processNewFiles);
|
||||
chatStore.setEditModeActive(handleFilesAdd);
|
||||
|
||||
return () => {
|
||||
clearEditMode();
|
||||
chatStore.clearEditMode();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||
|
||||
<input
|
||||
bind:this={fileInputElement}
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
|
||||
data-slot="edit-form"
|
||||
>
|
||||
<ChatAttachmentsList
|
||||
attachments={editedExtras}
|
||||
uploadedFiles={editedUploadedFiles}
|
||||
readonly={false}
|
||||
onFileRemove={(fileId) => {
|
||||
if (fileId.startsWith('attachment-')) {
|
||||
const index = parseInt(fileId.replace('attachment-', ''), 10);
|
||||
if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
|
||||
handleRemoveExistingAttachment(index);
|
||||
}
|
||||
} else {
|
||||
handleRemoveUploadedFile(fileId);
|
||||
}
|
||||
}}
|
||||
limitToSingleRow
|
||||
class="py-5"
|
||||
style="scroll-padding: 1rem;"
|
||||
<div class="relative w-full max-w-[80%]">
|
||||
<ChatForm
|
||||
bind:this={inputAreaRef}
|
||||
value={editCtx.editedContent}
|
||||
attachments={editCtx.editedExtras}
|
||||
uploadedFiles={editCtx.editedUploadedFiles}
|
||||
placeholder="Edit your message..."
|
||||
onValueChange={editCtx.setContent}
|
||||
onAttachmentRemove={handleAttachmentRemove}
|
||||
onUploadedFileRemove={handleUploadedFileRemove}
|
||||
onUploadedFilesChange={handleUploadedFilesChange}
|
||||
onFilesAdd={handleFilesAdd}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<div class="relative min-h-[48px] px-5 py-3">
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value={editedContent}
|
||||
class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
|
||||
onkeydown={onEditKeydown}
|
||||
oninput={(e) => {
|
||||
autoResizeTextarea(e.currentTarget);
|
||||
onEditedContentChange(e.currentTarget.value);
|
||||
}}
|
||||
onpaste={handlePaste}
|
||||
placeholder="Edit your message..."
|
||||
></textarea>
|
||||
|
||||
<div class="flex w-full items-center gap-3" style="container-type: inline-size">
|
||||
<Button
|
||||
class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
|
||||
onclick={() => fileInputElement?.click()}
|
||||
type="button"
|
||||
title="Add attachment"
|
||||
>
|
||||
<span class="sr-only">Attach files</span>
|
||||
|
||||
<Paperclip class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if isRouter}
|
||||
<ModelsSelector
|
||||
forceForegroundText={true}
|
||||
useGlobalSelection={true}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
class="h-8 w-8 shrink-0 rounded-full p-0"
|
||||
onclick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
type="button"
|
||||
title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
|
||||
>
|
||||
<span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
|
||||
|
||||
<ArrowUp class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
|
||||
{#if showSaveOnlyOption && onSaveEditOnly}
|
||||
{#if editCtx.showSaveOnlyOption}
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
|
||||
|
||||
|
|
@ -386,6 +136,6 @@
|
|||
cancelText="Keep editing"
|
||||
variant="destructive"
|
||||
icon={AlertTriangle}
|
||||
onConfirm={onCancelEdit}
|
||||
onConfirm={editCtx.cancel}
|
||||
onCancel={() => (showDiscardDialog = false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,194 @@
|
|||
<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,20 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
|
||||
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
|
||||
import { BadgeChatStatistic } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ChatMessageStatsView } from '$lib/enums';
|
||||
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
|
||||
import { formatPerformanceTime } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
predictedTokens?: number;
|
||||
predictedMs?: number;
|
||||
promptTokens?: number;
|
||||
promptMs?: number;
|
||||
// Live mode: when true, shows stats during streaming
|
||||
isLive?: boolean;
|
||||
// Whether prompt processing is still in progress
|
||||
isProcessingPrompt?: boolean;
|
||||
// Initial view to show (defaults to READING in live mode)
|
||||
initialView?: ChatMessageStatsView;
|
||||
agenticTimings?: ChatMessageAgenticTimings;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -24,7 +24,8 @@
|
|||
promptMs,
|
||||
isLive = false,
|
||||
isProcessingPrompt = false,
|
||||
initialView = ChatMessageStatsView.GENERATION
|
||||
initialView = ChatMessageStatsView.GENERATION,
|
||||
agenticTimings
|
||||
}: Props = $props();
|
||||
|
||||
let activeView: ChatMessageStatsView = $state(initialView);
|
||||
|
|
@ -57,8 +58,8 @@
|
|||
);
|
||||
|
||||
let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
|
||||
let timeInSeconds = $derived(
|
||||
predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00'
|
||||
let formattedTime = $derived(
|
||||
predictedMs !== undefined ? formatPerformanceTime(predictedMs) : '0s'
|
||||
);
|
||||
|
||||
let promptTokensPerSecond = $derived(
|
||||
|
|
@ -67,19 +68,39 @@
|
|||
: undefined
|
||||
);
|
||||
|
||||
let promptTimeInSeconds = $derived(
|
||||
promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined
|
||||
let formattedPromptTime = $derived(
|
||||
promptMs !== undefined ? formatPerformanceTime(promptMs) : undefined
|
||||
);
|
||||
|
||||
let hasPromptStats = $derived(
|
||||
promptTokens !== undefined &&
|
||||
promptMs !== undefined &&
|
||||
promptTokensPerSecond !== undefined &&
|
||||
promptTimeInSeconds !== undefined
|
||||
formattedPromptTime !== undefined
|
||||
);
|
||||
|
||||
// In live mode, generation tab is disabled until we have generation stats
|
||||
let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
|
||||
|
||||
let hasAgenticStats = $derived(agenticTimings !== undefined && agenticTimings.toolCallsCount > 0);
|
||||
|
||||
let agenticToolsPerSecond = $derived(
|
||||
hasAgenticStats && agenticTimings!.toolsMs > 0
|
||||
? (agenticTimings!.toolCallsCount / agenticTimings!.toolsMs) * 1000
|
||||
: 0
|
||||
);
|
||||
|
||||
let formattedAgenticToolsTime = $derived(
|
||||
hasAgenticStats ? formatPerformanceTime(agenticTimings!.toolsMs) : '0s'
|
||||
);
|
||||
|
||||
let agenticTotalTimeMs = $derived(
|
||||
hasAgenticStats
|
||||
? agenticTimings!.toolsMs + agenticTimings!.llm.predicted_ms + agenticTimings!.llm.prompt_ms
|
||||
: 0
|
||||
);
|
||||
|
||||
let formattedAgenticTotalTime = $derived(formatPerformanceTime(agenticTotalTimeMs));
|
||||
</script>
|
||||
|
||||
<div class="inline-flex items-center text-xs text-muted-foreground">
|
||||
|
|
@ -129,6 +150,49 @@
|
|||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{#if hasAgenticStats}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
|
||||
ChatMessageStatsView.TOOLS
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:text-foreground'}"
|
||||
onclick={() => (activeView = ChatMessageStatsView.TOOLS)}
|
||||
>
|
||||
<Wrench class="h-3 w-3" />
|
||||
|
||||
<span class="sr-only">Tools</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Tool calls</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
|
|
@ -142,15 +206,53 @@
|
|||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value="{timeInSeconds}s"
|
||||
value={formattedTime}
|
||||
tooltipLabel="Generation time"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Gauge}
|
||||
value="{tokensPerSecond.toFixed(2)} tokens/s"
|
||||
value="{tokensPerSecond.toFixed(2)} t/s"
|
||||
tooltipLabel="Generation speed"
|
||||
/>
|
||||
{:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats}
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Wrench}
|
||||
value="{agenticTimings!.toolCallsCount} calls"
|
||||
tooltipLabel="Tool calls executed"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value={formattedAgenticToolsTime}
|
||||
tooltipLabel="Tool execution time"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Gauge}
|
||||
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
|
||||
tooltipLabel="Tool execution rate"
|
||||
/>
|
||||
{:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats}
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Layers}
|
||||
value="{agenticTimings!.turns} turns"
|
||||
tooltipLabel="Agentic turns (LLM calls)"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={WholeWord}
|
||||
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
|
||||
tooltipLabel="Total tokens generated"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value={formattedAgenticTotalTime}
|
||||
tooltipLabel="Total time (LLM + tools)"
|
||||
/>
|
||||
{:else if hasPromptStats}
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
|
|
@ -161,7 +263,7 @@
|
|||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value="{promptTimeInSeconds}s"
|
||||
value={formattedPromptTime ?? '0s'}
|
||||
tooltipLabel="Prompt processing time"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@
|
|||
import { Card } from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { MarkdownContent } from '$lib/components/app';
|
||||
import { INPUT_CLASSES } from '$lib/constants/input-classes';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import { INPUT_CLASSES } from '$lib/constants/css-classes';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { isIMEComposing } from '$lib/utils';
|
||||
import ChatMessageActions from './ChatMessageActions.svelte';
|
||||
import { KeyboardKey, MessageRole } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
isEditing: boolean;
|
||||
editedContent: string;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
showDeleteDialog: boolean;
|
||||
deletionInfo: {
|
||||
|
|
@ -20,10 +21,6 @@
|
|||
assistantMessages: number;
|
||||
messageTypes: string[];
|
||||
} | null;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: () => void;
|
||||
onEditKeydown: (event: KeyboardEvent) => void;
|
||||
onEditedContentChange: (content: string) => void;
|
||||
onCopy: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
|
|
@ -36,15 +33,9 @@
|
|||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
isEditing,
|
||||
editedContent,
|
||||
siblingInfo = null,
|
||||
showDeleteDialog,
|
||||
deletionInfo,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onEditKeydown,
|
||||
onEditedContentChange,
|
||||
onCopy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
|
|
@ -54,10 +45,25 @@
|
|||
textareaElement = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
const editCtx = getMessageEditContext();
|
||||
|
||||
function handleEditKeydown(event: KeyboardEvent) {
|
||||
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
|
||||
event.preventDefault();
|
||||
|
||||
editCtx.save();
|
||||
} else if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
|
||||
editCtx.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
let isMultiline = $state(false);
|
||||
let messageElement: HTMLElement | undefined = $state();
|
||||
let isExpanded = $state(false);
|
||||
let contentHeight = $state(0);
|
||||
|
||||
const MAX_HEIGHT = 200; // pixels
|
||||
const currentConfig = config();
|
||||
|
||||
|
|
@ -97,26 +103,31 @@
|
|||
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
|
||||
role="group"
|
||||
>
|
||||
{#if isEditing}
|
||||
{#if editCtx.isEditing}
|
||||
<div class="w-full max-w-[80%]">
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value={editedContent}
|
||||
value={editCtx.editedContent}
|
||||
class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
|
||||
onkeydown={onEditKeydown}
|
||||
oninput={(e) => onEditedContentChange(e.currentTarget.value)}
|
||||
onkeydown={handleEditKeydown}
|
||||
oninput={(e) => editCtx.setContent(e.currentTarget.value)}
|
||||
placeholder="Edit system message..."
|
||||
></textarea>
|
||||
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
|
||||
<Button class="h-8 px-3" onclick={editCtx.cancel} size="sm" variant="outline">
|
||||
<X class="mr-1 h-3 w-3" />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
|
||||
<Button
|
||||
class="h-8 px-3"
|
||||
onclick={editCtx.save}
|
||||
disabled={!editCtx.editedContent.trim()}
|
||||
size="sm"
|
||||
>
|
||||
<Check class="mr-1 h-3 w-3" />
|
||||
Send
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -131,12 +142,12 @@
|
|||
type="button"
|
||||
>
|
||||
<Card
|
||||
class="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}
|
||||
style="border: 2px dashed hsl(var(--border));"
|
||||
style="border: 2px dashed hsl(var(--border)); max-height: var(--max-message-height);"
|
||||
>
|
||||
<div
|
||||
class="relative overflow-hidden transition-all duration-300 {isExpanded
|
||||
class="relative transition-all duration-300 {isExpanded
|
||||
? 'cursor-text select-text'
|
||||
: 'select-none'}"
|
||||
style={!isExpanded && showExpandButton
|
||||
|
|
@ -145,7 +156,10 @@
|
|||
>
|
||||
{#if currentConfig.renderUserContentAsMarkdown}
|
||||
<div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
|
||||
<MarkdownContent class="markdown-system-content" content={message.content} />
|
||||
<MarkdownContent
|
||||
class="markdown-system-content overflow-auto"
|
||||
content={message.content}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<span
|
||||
|
|
@ -208,7 +222,7 @@
|
|||
{onShowDeleteDialogChange}
|
||||
{siblingInfo}
|
||||
{showDeleteDialog}
|
||||
role="user"
|
||||
role={MessageRole.USER}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,67 +1,48 @@
|
|||
<script lang="ts">
|
||||
import { Card } from '$lib/components/ui/card';
|
||||
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import ChatMessageActions from './ChatMessageActions.svelte';
|
||||
import ChatMessageEditForm from './ChatMessageEditForm.svelte';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
isEditing: boolean;
|
||||
editedContent: string;
|
||||
editedExtras?: DatabaseMessageExtra[];
|
||||
editedUploadedFiles?: ChatUploadedFile[];
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
showDeleteDialog: boolean;
|
||||
deletionInfo: {
|
||||
totalCount: number;
|
||||
userMessages: number;
|
||||
assistantMessages: number;
|
||||
messageTypes: string[];
|
||||
} | null;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: () => void;
|
||||
onSaveEditOnly?: () => void;
|
||||
onEditKeydown: (event: KeyboardEvent) => void;
|
||||
onEditedContentChange: (content: string) => void;
|
||||
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
|
||||
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
|
||||
onCopy: () => void;
|
||||
showDeleteDialog: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onShowDeleteDialogChange: (show: boolean) => void;
|
||||
textareaElement?: HTMLTextAreaElement;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
isEditing,
|
||||
editedContent,
|
||||
editedExtras = [],
|
||||
editedUploadedFiles = [],
|
||||
siblingInfo = null,
|
||||
showDeleteDialog,
|
||||
deletionInfo,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onSaveEditOnly,
|
||||
onEditKeydown,
|
||||
onEditedContentChange,
|
||||
onEditedExtrasChange,
|
||||
onEditedUploadedFilesChange,
|
||||
onCopy,
|
||||
showDeleteDialog,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onNavigateToSibling,
|
||||
onShowDeleteDialogChange,
|
||||
textareaElement = $bindable()
|
||||
onNavigateToSibling,
|
||||
onCopy
|
||||
}: Props = $props();
|
||||
|
||||
// Get contexts
|
||||
const editCtx = getMessageEditContext();
|
||||
|
||||
let isMultiline = $state(false);
|
||||
let messageElement: HTMLElement | undefined = $state();
|
||||
const currentConfig = config();
|
||||
|
|
@ -96,24 +77,8 @@
|
|||
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
|
||||
role="group"
|
||||
>
|
||||
{#if isEditing}
|
||||
<ChatMessageEditForm
|
||||
bind:textareaElement
|
||||
messageId={message.id}
|
||||
{editedContent}
|
||||
{editedExtras}
|
||||
{editedUploadedFiles}
|
||||
originalContent={message.content}
|
||||
originalExtras={message.extra}
|
||||
showSaveOnlyOption={!!onSaveEditOnly}
|
||||
{onCancelEdit}
|
||||
{onSaveEdit}
|
||||
{onSaveEditOnly}
|
||||
{onEditKeydown}
|
||||
{onEditedContentChange}
|
||||
{onEditedExtrasChange}
|
||||
{onEditedUploadedFilesChange}
|
||||
/>
|
||||
{#if editCtx.isEditing}
|
||||
<ChatMessageEditForm />
|
||||
{:else}
|
||||
{#if message.extra && message.extra.length > 0}
|
||||
<div class="mb-2 max-w-[80%]">
|
||||
|
|
@ -123,8 +88,9 @@
|
|||
|
||||
{#if message.content.trim()}
|
||||
<Card
|
||||
class="max-w-[80%] rounded-[1.125rem] border-none bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5"
|
||||
class="max-w-[80%] overflow-y-auto rounded-[1.125rem] border-none bg-primary/5 px-3.75 py-1.5 text-foreground backdrop-blur-md data-[multiline]:py-2.5 dark:bg-primary/15"
|
||||
data-multiline={isMultiline ? '' : undefined}
|
||||
style="max-height: var(--max-message-height); overflow-wrap: anywhere; word-break: break-word;"
|
||||
>
|
||||
{#if currentConfig.renderUserContentAsMarkdown}
|
||||
<div bind:this={messageElement} class="text-md">
|
||||
|
|
@ -155,7 +121,7 @@
|
|||
{onShowDeleteDialogChange}
|
||||
{siblingInfo}
|
||||
{showDeleteDialog}
|
||||
role="user"
|
||||
role={MessageRole.USER}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { ChatMessage } from '$lib/components/app';
|
||||
import { setChatActionsContext } from '$lib/contexts';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { getMessageSiblings } from '$lib/utils';
|
||||
import { copyToClipboard, formatMessageForClipboard, getMessageSiblings } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -16,6 +18,62 @@
|
|||
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
||||
const currentConfig = config();
|
||||
|
||||
setChatActionsContext({
|
||||
copy: async (message: DatabaseMessage) => {
|
||||
const asPlainText = Boolean(currentConfig.copyTextAttachmentsAsPlainText);
|
||||
const clipboardContent = formatMessageForClipboard(
|
||||
message.content,
|
||||
message.extra,
|
||||
asPlainText
|
||||
);
|
||||
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
|
||||
},
|
||||
delete: async (message: DatabaseMessage) => {
|
||||
await chatStore.deleteMessage(message.id);
|
||||
refreshAllMessages();
|
||||
},
|
||||
navigateToSibling: async (siblingId: string) => {
|
||||
await conversationsStore.navigateToSibling(siblingId);
|
||||
},
|
||||
editWithBranching: async (
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => {
|
||||
onUserAction?.();
|
||||
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
|
||||
refreshAllMessages();
|
||||
},
|
||||
editWithReplacement: async (
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
shouldBranch: boolean
|
||||
) => {
|
||||
onUserAction?.();
|
||||
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
|
||||
refreshAllMessages();
|
||||
},
|
||||
editUserMessagePreserveResponses: async (
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => {
|
||||
onUserAction?.();
|
||||
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
|
||||
refreshAllMessages();
|
||||
},
|
||||
regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
|
||||
onUserAction?.();
|
||||
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
|
||||
refreshAllMessages();
|
||||
},
|
||||
continueAssistantMessage: async (message: DatabaseMessage) => {
|
||||
onUserAction?.();
|
||||
await chatStore.continueAssistantMessage(message.id);
|
||||
refreshAllMessages();
|
||||
}
|
||||
});
|
||||
|
||||
function refreshAllMessages() {
|
||||
const conversation = activeConversation();
|
||||
|
||||
|
|
@ -42,16 +100,28 @@
|
|||
return [];
|
||||
}
|
||||
|
||||
// Filter out system messages if showSystemMessage is false
|
||||
const filteredMessages = currentConfig.showSystemMessage
|
||||
? messages
|
||||
: messages.filter((msg) => msg.type !== 'system');
|
||||
: messages.filter((msg) => msg.type !== MessageRole.SYSTEM);
|
||||
|
||||
return filteredMessages.map((message) => {
|
||||
let lastAssistantIndex = -1;
|
||||
|
||||
for (let i = filteredMessages.length - 1; i >= 0; i--) {
|
||||
if (filteredMessages[i].role === MessageRole.ASSISTANT) {
|
||||
lastAssistantIndex = i;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return filteredMessages.map((message, index) => {
|
||||
const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
|
||||
const isLastAssistantMessage =
|
||||
message.role === MessageRole.ASSISTANT && index === lastAssistantIndex;
|
||||
|
||||
return {
|
||||
message,
|
||||
isLastAssistantMessage,
|
||||
siblingInfo: siblingInfo || {
|
||||
message,
|
||||
siblingIds: [message.id],
|
||||
|
|
@ -61,83 +131,15 @@
|
|||
};
|
||||
});
|
||||
});
|
||||
|
||||
async function handleNavigateToSibling(siblingId: string) {
|
||||
await conversationsStore.navigateToSibling(siblingId);
|
||||
}
|
||||
|
||||
async function handleEditWithBranching(
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) {
|
||||
onUserAction?.();
|
||||
|
||||
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
async function handleEditWithReplacement(
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
shouldBranch: boolean
|
||||
) {
|
||||
onUserAction?.();
|
||||
|
||||
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
|
||||
onUserAction?.();
|
||||
|
||||
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
async function handleContinueAssistantMessage(message: DatabaseMessage) {
|
||||
onUserAction?.();
|
||||
|
||||
await chatStore.continueAssistantMessage(message.id);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
async function handleEditUserMessagePreserveResponses(
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) {
|
||||
onUserAction?.();
|
||||
|
||||
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
async function handleDeleteMessage(message: DatabaseMessage) {
|
||||
await chatStore.deleteMessage(message.id);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
|
||||
{#each displayMessages as { message, siblingInfo } (message.id)}
|
||||
<div class="flex h-full flex-col space-y-10 pt-24 {className}" style="height: auto; ">
|
||||
{#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)}
|
||||
<ChatMessage
|
||||
class="mx-auto w-full max-w-[48rem]"
|
||||
{message}
|
||||
{isLastAssistantMessage}
|
||||
{siblingInfo}
|
||||
onDelete={handleDeleteMessage}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onEditWithBranching={handleEditWithBranching}
|
||||
onEditWithReplacement={handleEditWithReplacement}
|
||||
onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
|
||||
onRegenerateWithBranching={handleRegenerateWithBranching}
|
||||
onContinueAssistantMessage={handleContinueAssistantMessage}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import {
|
||||
ChatForm,
|
||||
ChatScreenForm,
|
||||
ChatScreenHeader,
|
||||
ChatMessages,
|
||||
ChatScreenProcessingInfo,
|
||||
|
|
@ -12,15 +12,14 @@
|
|||
} from '$lib/components/app';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import {
|
||||
AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
|
||||
AUTO_SCROLL_INTERVAL,
|
||||
INITIAL_SCROLL_DELAY
|
||||
} from '$lib/constants/auto-scroll';
|
||||
import { INITIAL_SCROLL_DELAY } from '$lib/constants/auto-scroll';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import {
|
||||
chatStore,
|
||||
errorDialog,
|
||||
isLoading,
|
||||
isChatStreaming,
|
||||
isEditing,
|
||||
getAddFilesHandler
|
||||
} from '$lib/stores/chat.svelte';
|
||||
|
|
@ -34,6 +33,7 @@
|
|||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
|
||||
import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
|
||||
import { ErrorDialogType } from '$lib/enums';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade, fly, slide } from 'svelte/transition';
|
||||
import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
|
||||
|
|
@ -42,16 +42,13 @@
|
|||
let { showCenteredEmpty = false } = $props();
|
||||
|
||||
let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
|
||||
let autoScrollEnabled = $state(true);
|
||||
let chatScrollContainer: HTMLDivElement | undefined = $state();
|
||||
let dragCounter = $state(0);
|
||||
let isDragOver = $state(false);
|
||||
let lastScrollTop = $state(0);
|
||||
let scrollInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let showFileErrorDialog = $state(false);
|
||||
let uploadedFiles = $state<ChatUploadedFile[]>([]);
|
||||
let userScrolledUp = $state(false);
|
||||
|
||||
const autoScroll = createAutoScrollController();
|
||||
|
||||
let fileErrorData = $state<{
|
||||
generallyUnsupported: File[];
|
||||
|
|
@ -71,6 +68,8 @@
|
|||
|
||||
let emptyFileNames = $state<string[]>([]);
|
||||
|
||||
let initialMessage = $state('');
|
||||
|
||||
let isEmpty = $derived(
|
||||
showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
|
||||
);
|
||||
|
|
@ -79,7 +78,7 @@
|
|||
let isServerLoading = $derived(serverLoading());
|
||||
let hasPropsError = $derived(!!serverError());
|
||||
|
||||
let isCurrentConversationLoading = $derived(isLoading());
|
||||
let isCurrentConversationLoading = $derived(isLoading() || isChatStreaming());
|
||||
|
||||
let isRouter = $derived(isRouterMode());
|
||||
|
||||
|
|
@ -213,7 +212,11 @@
|
|||
function handleKeydown(event: KeyboardEvent) {
|
||||
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (isCtrlOrCmd && event.shiftKey && (event.key === 'd' || event.key === 'D')) {
|
||||
if (
|
||||
isCtrlOrCmd &&
|
||||
event.shiftKey &&
|
||||
(event.key === KeyboardKey.D_LOWER || event.key === KeyboardKey.D_UPPER)
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (activeConversation()) {
|
||||
showDeleteDialog = true;
|
||||
|
|
@ -221,38 +224,22 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handleSystemPromptAdd(draft: { message: string; files: ChatUploadedFile[] }) {
|
||||
if (draft.message || draft.files.length > 0) {
|
||||
chatStore.savePendingDraft(draft.message, draft.files);
|
||||
}
|
||||
|
||||
await chatStore.addSystemPrompt();
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (disableAutoScroll || !chatScrollContainer) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer;
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
|
||||
|
||||
if (scrollTop < lastScrollTop && !isAtBottom) {
|
||||
userScrolledUp = true;
|
||||
autoScrollEnabled = false;
|
||||
} else if (isAtBottom && userScrolledUp) {
|
||||
userScrolledUp = false;
|
||||
autoScrollEnabled = true;
|
||||
}
|
||||
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
|
||||
scrollTimeout = setTimeout(() => {
|
||||
if (isAtBottom) {
|
||||
userScrolledUp = false;
|
||||
autoScrollEnabled = true;
|
||||
}
|
||||
}, AUTO_SCROLL_INTERVAL);
|
||||
|
||||
lastScrollTop = scrollTop;
|
||||
autoScroll.handleScroll();
|
||||
}
|
||||
|
||||
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
|
||||
const result = files
|
||||
? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
|
||||
const plainFiles = files ? $state.snapshot(files) : undefined;
|
||||
const result = plainFiles
|
||||
? await parseFilesToMessageExtras(plainFiles, activeModelId ?? undefined)
|
||||
: undefined;
|
||||
|
||||
if (result?.emptyFiles && result.emptyFiles.length > 0) {
|
||||
|
|
@ -269,12 +256,9 @@
|
|||
const extras = result?.extras;
|
||||
|
||||
// Enable autoscroll for user-initiated message sending
|
||||
if (!disableAutoScroll) {
|
||||
userScrolledUp = false;
|
||||
autoScrollEnabled = true;
|
||||
}
|
||||
autoScroll.enable();
|
||||
await chatStore.sendMessage(message, extras);
|
||||
scrollChatToBottom();
|
||||
autoScroll.scrollToBottom();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -324,43 +308,34 @@
|
|||
}
|
||||
}
|
||||
|
||||
function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') {
|
||||
if (disableAutoScroll) return;
|
||||
|
||||
chatScrollContainer?.scrollTo({
|
||||
top: chatScrollContainer?.scrollHeight,
|
||||
behavior
|
||||
});
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
if (!disableAutoScroll) {
|
||||
setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
|
||||
setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!disableAutoScroll) {
|
||||
setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
|
||||
setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
|
||||
}
|
||||
|
||||
const pendingDraft = chatStore.consumePendingDraft();
|
||||
if (pendingDraft) {
|
||||
initialMessage = pendingDraft.message;
|
||||
uploadedFiles = pendingDraft.files;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (disableAutoScroll) {
|
||||
autoScrollEnabled = false;
|
||||
if (scrollInterval) {
|
||||
clearInterval(scrollInterval);
|
||||
scrollInterval = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
autoScroll.setContainer(chatScrollContainer);
|
||||
});
|
||||
|
||||
if (isCurrentConversationLoading && autoScrollEnabled) {
|
||||
scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL);
|
||||
} else if (scrollInterval) {
|
||||
clearInterval(scrollInterval);
|
||||
scrollInterval = undefined;
|
||||
}
|
||||
$effect(() => {
|
||||
autoScroll.setDisabled(disableAutoScroll);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
autoScroll.updateInterval(isCurrentConversationLoading);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -388,11 +363,8 @@
|
|||
class="mb-16 md:mb-24"
|
||||
messages={activeMessages()}
|
||||
onUserAction={() => {
|
||||
if (!disableAutoScroll) {
|
||||
userScrolledUp = false;
|
||||
autoScrollEnabled = true;
|
||||
scrollChatToBottom();
|
||||
}
|
||||
autoScroll.enable();
|
||||
autoScroll.scrollToBottom();
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -426,13 +398,15 @@
|
|||
{/if}
|
||||
|
||||
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
|
||||
<ChatForm
|
||||
<ChatScreenForm
|
||||
disabled={hasPropsError || isEditing()}
|
||||
{initialMessage}
|
||||
isLoading={isCurrentConversationLoading}
|
||||
onFileRemove={handleFileRemove}
|
||||
onFileUpload={handleFileUpload}
|
||||
onSend={handleSendMessage}
|
||||
onStop={() => chatStore.stopGeneration()}
|
||||
onSystemPromptAdd={handleSystemPromptAdd}
|
||||
showHelperText={false}
|
||||
bind:uploadedFiles
|
||||
/>
|
||||
|
|
@ -454,7 +428,7 @@
|
|||
>
|
||||
<div class="w-full max-w-[48rem] px-4">
|
||||
<div class="mb-10 text-center" in:fade={{ duration: 300 }}>
|
||||
<h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
|
||||
<h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
|
||||
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{serverStore.props?.modalities?.audio
|
||||
|
|
@ -484,13 +458,15 @@
|
|||
{/if}
|
||||
|
||||
<div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
|
||||
<ChatForm
|
||||
<ChatScreenForm
|
||||
disabled={hasPropsError}
|
||||
{initialMessage}
|
||||
isLoading={isCurrentConversationLoading}
|
||||
onFileRemove={handleFileRemove}
|
||||
onFileUpload={handleFileUpload}
|
||||
onSend={handleSendMessage}
|
||||
onStop={() => chatStore.stopGeneration()}
|
||||
onSystemPromptAdd={handleSystemPromptAdd}
|
||||
showHelperText={true}
|
||||
bind:uploadedFiles
|
||||
/>
|
||||
|
|
@ -595,7 +571,7 @@
|
|||
contextInfo={activeErrorDialog?.contextInfo}
|
||||
onOpenChange={handleErrorDialogOpenChange}
|
||||
open={Boolean(activeErrorDialog)}
|
||||
type={activeErrorDialog?.type ?? 'server'}
|
||||
type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}
|
||||
/>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { ChatFormHelperText, ChatForm } from '$lib/components/app';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
initialMessage?: string;
|
||||
isLoading?: boolean;
|
||||
onFileRemove?: (fileId: string) => void;
|
||||
onFileUpload?: (files: File[]) => void;
|
||||
onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
|
||||
onStop?: () => void;
|
||||
onSystemPromptAdd?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
|
||||
showHelperText?: boolean;
|
||||
uploadedFiles?: ChatUploadedFile[];
|
||||
}
|
||||
|
||||
let {
|
||||
class: className,
|
||||
disabled = false,
|
||||
initialMessage = '',
|
||||
isLoading = false,
|
||||
onFileRemove,
|
||||
onFileUpload,
|
||||
onSend,
|
||||
onStop,
|
||||
onSystemPromptAdd,
|
||||
showHelperText = true,
|
||||
uploadedFiles = $bindable([])
|
||||
}: Props = $props();
|
||||
|
||||
let chatFormRef: ChatForm | undefined = $state(undefined);
|
||||
let message = $state(initialMessage);
|
||||
let previousIsLoading = $state(isLoading);
|
||||
let previousInitialMessage = $state(initialMessage);
|
||||
|
||||
// Sync message when initialMessage prop changes (e.g., after draft restoration)
|
||||
$effect(() => {
|
||||
if (initialMessage !== previousInitialMessage) {
|
||||
message = initialMessage;
|
||||
previousInitialMessage = initialMessage;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSystemPromptClick() {
|
||||
onSystemPromptAdd?.({ message, files: uploadedFiles });
|
||||
}
|
||||
|
||||
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (
|
||||
(!message.trim() && uploadedFiles.length === 0) ||
|
||||
disabled ||
|
||||
isLoading ||
|
||||
hasLoadingAttachments
|
||||
)
|
||||
return;
|
||||
|
||||
if (!chatFormRef?.checkModelSelected()) return;
|
||||
|
||||
const messageToSend = message.trim();
|
||||
const filesToSend = [...uploadedFiles];
|
||||
|
||||
message = '';
|
||||
uploadedFiles = [];
|
||||
|
||||
chatFormRef?.resetTextareaHeight();
|
||||
|
||||
const success = await onSend?.(messageToSend, filesToSend);
|
||||
|
||||
if (!success) {
|
||||
message = messageToSend;
|
||||
uploadedFiles = filesToSend;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilesAdd(files: File[]) {
|
||||
onFileUpload?.(files);
|
||||
}
|
||||
|
||||
function handleUploadedFileRemove(fileId: string) {
|
||||
onFileRemove?.(fileId);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(() => chatFormRef?.focus(), 10);
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
setTimeout(() => chatFormRef?.focus(), 10);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (previousIsLoading && !isLoading) {
|
||||
setTimeout(() => chatFormRef?.focus(), 10);
|
||||
}
|
||||
|
||||
previousIsLoading = isLoading;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative mx-auto max-w-[48rem]">
|
||||
<ChatForm
|
||||
bind:this={chatFormRef}
|
||||
bind:value={message}
|
||||
bind:uploadedFiles
|
||||
class={className}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
showMcpPromptButton={true}
|
||||
onFilesAdd={handleFilesAdd}
|
||||
{onStop}
|
||||
onSubmit={handleSubmit}
|
||||
onSystemPromptClick={handleSystemPromptClick}
|
||||
onUploadedFileRemove={handleUploadedFileRemove}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatFormHelperText show={showHelperText} />
|
||||
|
|
@ -14,12 +14,17 @@
|
|||
</script>
|
||||
|
||||
<header
|
||||
class="md:background-transparent pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end bg-background/40 p-4 backdrop-blur-xl 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-4 duration-200 ease-linear {sidebar.open
|
||||
? 'md:left-[var(--sidebar-width)]'
|
||||
: ''}"
|
||||
>
|
||||
<div class="pointer-events-auto flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm" onclick={toggleSettings}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={toggleSettings}
|
||||
class="rounded-full backdrop-blur-lg"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
let isCurrentConversationLoading = $derived(isLoading());
|
||||
let isStreaming = $derived(isChatStreaming());
|
||||
let hasProcessingData = $derived(processingState.processingState !== null);
|
||||
let processingDetails = $derived(processingState.getProcessingDetails());
|
||||
let processingDetails = $derived(processingState.getTechnicalDetails());
|
||||
|
||||
let showProcessingInfo = $derived(
|
||||
isCurrentConversationLoading || isStreaming || config().keepStatsVisible || hasProcessingData
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
<div class="chat-processing-info-container pointer-events-none" class:visible={showProcessingInfo}>
|
||||
<div class="chat-processing-info-content">
|
||||
{#each processingDetails as detail (detail)}
|
||||
<span class="chat-processing-info-detail pointer-events-auto">{detail}</span>
|
||||
<span class="chat-processing-info-detail pointer-events-auto backdrop-blur-sm">{detail}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: 1.5rem 1rem;
|
||||
padding: 0 1rem 0.75rem;
|
||||
opacity: 0;
|
||||
transform: translateY(50%);
|
||||
transition:
|
||||
|
|
@ -100,7 +100,6 @@
|
|||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--muted);
|
||||
border-radius: 0.375rem;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
AlertTriangle,
|
||||
Code,
|
||||
Monitor,
|
||||
Sun,
|
||||
Moon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Database
|
||||
|
|
@ -14,12 +12,19 @@
|
|||
import {
|
||||
ChatSettingsFooter,
|
||||
ChatSettingsImportExportTab,
|
||||
ChatSettingsFields
|
||||
ChatSettingsFields,
|
||||
McpLogo,
|
||||
McpServersSettings
|
||||
} from '$lib/components/app';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { ColorMode } from '$lib/enums/ui';
|
||||
import type { Component } from 'svelte';
|
||||
import { NUMERIC_FIELDS, POSITIVE_INTEGER_FIELDS } from '$lib/constants/settings-fields';
|
||||
import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
|
||||
import { SETTINGS_SECTION_TITLES } from '$lib/constants/settings-sections';
|
||||
import type { SettingsSectionTitle } from '$lib/constants/settings-sections';
|
||||
|
||||
interface Props {
|
||||
onSave?: () => void;
|
||||
|
|
@ -30,21 +35,17 @@
|
|||
const settingSections: Array<{
|
||||
fields: SettingsFieldConfig[];
|
||||
icon: Component;
|
||||
title: string;
|
||||
title: SettingsSectionTitle;
|
||||
}> = [
|
||||
{
|
||||
title: 'General',
|
||||
title: SETTINGS_SECTION_TITLES.GENERAL,
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
key: 'theme',
|
||||
label: 'Theme',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon }
|
||||
]
|
||||
options: SETTINGS_COLOR_MODES_CONFIG
|
||||
},
|
||||
{ key: 'apiKey', label: 'API Key', type: 'input' },
|
||||
{
|
||||
|
|
@ -81,7 +82,7 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
title: SETTINGS_SECTION_TITLES.DISPLAY,
|
||||
icon: Monitor,
|
||||
fields: [
|
||||
{
|
||||
|
|
@ -128,7 +129,7 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
title: 'Sampling',
|
||||
title: SETTINGS_SECTION_TITLES.SAMPLING,
|
||||
icon: Funnel,
|
||||
fields: [
|
||||
{
|
||||
|
|
@ -194,7 +195,7 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
title: 'Penalties',
|
||||
title: SETTINGS_SECTION_TITLES.PENALTIES,
|
||||
icon: AlertTriangle,
|
||||
fields: [
|
||||
{
|
||||
|
|
@ -240,22 +241,43 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
title: 'Import/Export',
|
||||
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
|
||||
icon: Database,
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: 'Developer',
|
||||
title: SETTINGS_SECTION_TITLES.MCP,
|
||||
icon: McpLogo,
|
||||
fields: [
|
||||
{
|
||||
key: 'agenticMaxTurns',
|
||||
label: 'Agentic loop max turns',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'agenticMaxToolPreviewLines',
|
||||
label: 'Max lines per tool preview',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'showToolCallInProgress',
|
||||
label: 'Show tool call in progress',
|
||||
type: 'checkbox'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.DEVELOPER,
|
||||
icon: Code,
|
||||
fields: [
|
||||
{
|
||||
key: 'showToolCalls',
|
||||
label: 'Show tool call labels',
|
||||
key: 'disableReasoningParsing',
|
||||
label: 'Disable reasoning content parsing',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'disableReasoningFormat',
|
||||
label: 'Show raw LLM output',
|
||||
key: 'showRawOutputSwitch',
|
||||
label: 'Enable raw output toggle',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
|
|
@ -280,7 +302,7 @@
|
|||
// }
|
||||
];
|
||||
|
||||
let activeSection = $state('General');
|
||||
let activeSection = $state<SettingsSectionTitle>(SETTINGS_SECTION_TITLES.GENERAL);
|
||||
let currentSection = $derived(
|
||||
settingSections.find((section) => section.title === activeSection) || settingSections[0]
|
||||
);
|
||||
|
|
@ -293,7 +315,7 @@
|
|||
function handleThemeChange(newTheme: string) {
|
||||
localConfig.theme = newTheme;
|
||||
|
||||
setMode(newTheme as 'light' | 'dark' | 'system');
|
||||
setMode(newTheme as ColorMode);
|
||||
}
|
||||
|
||||
function handleConfigChange(key: string, value: string | boolean) {
|
||||
|
|
@ -303,7 +325,7 @@
|
|||
function handleReset() {
|
||||
localConfig = { ...config() };
|
||||
|
||||
setMode(localConfig.theme as 'light' | 'dark' | 'system');
|
||||
setMode(localConfig.theme as ColorMode);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
|
|
@ -319,33 +341,16 @@
|
|||
|
||||
// Convert numeric strings to numbers for numeric fields
|
||||
const processedConfig = { ...localConfig };
|
||||
const numericFields = [
|
||||
'temperature',
|
||||
'top_k',
|
||||
'top_p',
|
||||
'min_p',
|
||||
'max_tokens',
|
||||
'pasteLongTextToFileLen',
|
||||
'dynatemp_range',
|
||||
'dynatemp_exponent',
|
||||
'typ_p',
|
||||
'xtc_probability',
|
||||
'xtc_threshold',
|
||||
'repeat_last_n',
|
||||
'repeat_penalty',
|
||||
'presence_penalty',
|
||||
'frequency_penalty',
|
||||
'dry_multiplier',
|
||||
'dry_base',
|
||||
'dry_allowed_length',
|
||||
'dry_penalty_last_n'
|
||||
];
|
||||
|
||||
for (const field of numericFields) {
|
||||
for (const field of NUMERIC_FIELDS) {
|
||||
if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
|
||||
const numValue = Number(processedConfig[field]);
|
||||
if (!isNaN(numValue)) {
|
||||
processedConfig[field] = numValue;
|
||||
if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
|
||||
processedConfig[field] = Math.max(1, Math.round(numValue));
|
||||
} else {
|
||||
processedConfig[field] = numValue;
|
||||
}
|
||||
} else {
|
||||
alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
|
||||
return;
|
||||
|
|
@ -484,8 +489,21 @@
|
|||
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
|
||||
</div>
|
||||
|
||||
{#if currentSection.title === 'Import/Export'}
|
||||
{#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
|
||||
<ChatSettingsImportExportTab />
|
||||
{:else if currentSection.title === SETTINGS_SECTION_TITLES.MCP}
|
||||
<div class="space-y-6">
|
||||
<ChatSettingsFields
|
||||
fields={currentSection.fields}
|
||||
{localConfig}
|
||||
onConfigChange={handleConfigChange}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
|
||||
<div class="border-t border-border/30 pt-6">
|
||||
<McpServersSettings />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<ChatSettingsFields
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { Download, Upload, Trash2 } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { DialogConversationSelection } from '$lib/components/app';
|
||||
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
|
||||
import { createMessageCountMap } from '$lib/utils';
|
||||
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
|
||||
|
||||
let exportedConversations = $state<DatabaseConversation[]>([]);
|
||||
let importedConversations = $state<DatabaseConversation[]>([]);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { getPreviewText } from '$lib/utils/text';
|
||||
import { getPreviewText } from '$lib/utils';
|
||||
import ChatSidebarActions from './ChatSidebarActions.svelte';
|
||||
|
||||
const sidebar = Sidebar.useSidebar();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { Trash2, Pencil, MoreHorizontal, Download, Loader2, Square } from '@lucide/svelte';
|
||||
import { ActionDropdown } from '$lib/components/app';
|
||||
import { DropdownMenuActions } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { getAllLoadingChats } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
|
||||
{#if renderActionsDropdown}
|
||||
<div class="actions flex items-center">
|
||||
<ActionDropdown
|
||||
<DropdownMenuActions
|
||||
triggerIcon={MoreHorizontal}
|
||||
triggerTooltip="More actions"
|
||||
bind:open={dropdownOpen}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,772 @@
|
|||
/**
|
||||
*
|
||||
* ATTACHMENTS
|
||||
*
|
||||
* Components for displaying and managing different attachment types in chat messages.
|
||||
* Supports two operational modes:
|
||||
* - **Readonly mode**: For displaying stored attachments in sent messages (DatabaseMessageExtra[])
|
||||
* - **Editable mode**: For managing pending uploads in the input form (ChatUploadedFile[])
|
||||
*
|
||||
* The attachment system uses `getAttachmentDisplayItems()` utility to normalize both
|
||||
* data sources into a unified display format, enabling consistent rendering regardless
|
||||
* of the attachment origin.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ChatAttachmentsList** - Unified display for file attachments in chat
|
||||
*
|
||||
* Central component for rendering file attachments in both ChatMessage (readonly)
|
||||
* and ChatForm (editable) contexts.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Delegates rendering to specialized thumbnail components based on attachment type
|
||||
* - Manages scroll state and navigation arrows for horizontal overflow
|
||||
* - Integrates with DialogChatAttachmentPreview for full-size viewing
|
||||
* - Validates vision modality support via `activeModelId` prop
|
||||
*
|
||||
* **Features:**
|
||||
* - Horizontal scroll with smooth navigation arrows
|
||||
* - Image thumbnails with lazy loading and error fallback
|
||||
* - File type icons for non-image files (PDF, text, audio, etc.)
|
||||
* - MCP prompt attachments with expandable content preview
|
||||
* - Click-to-preview with full-size dialog and download option
|
||||
* - "View All" button when `limitToSingleRow` is enabled and content overflows
|
||||
* - Vision modality validation to warn about unsupported image uploads
|
||||
* - Customizable thumbnail dimensions via `imageHeight`/`imageWidth` props
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <!-- Readonly mode (in ChatMessage) -->
|
||||
* <ChatAttachmentsList attachments={message.extra} readonly />
|
||||
*
|
||||
* <!-- Editable mode (in ChatForm) -->
|
||||
* <ChatAttachmentsList
|
||||
* bind:uploadedFiles
|
||||
* onFileRemove={(id) => removeFile(id)}
|
||||
* limitToSingleRow
|
||||
* activeModelId={selectedModel}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
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';
|
||||
|
||||
/**
|
||||
* Displays a stored MCP Resource attachment (from database extras) with icon,
|
||||
* name, and server info tooltip. Compact chip style matching live resource display.
|
||||
*/
|
||||
export { default as ChatAttachmentMcpResourceStored } from './ChatAttachments/ChatAttachmentMcpResourceStored.svelte';
|
||||
|
||||
/**
|
||||
* Full-size attachment preview component for dialog display. Handles different file types:
|
||||
* images (full-size display), text files (syntax highlighted), PDFs (text extraction or image preview),
|
||||
* audio (placeholder with download), and generic files (download option).
|
||||
*/
|
||||
export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachmentPreview.svelte';
|
||||
|
||||
/**
|
||||
* Displays MCP Resource attachments as a horizontal carousel.
|
||||
* Shows resource name, URI, and allows clicking to view resource content.
|
||||
*/
|
||||
export { default as ChatAttachmentMcpResources } from './ChatAttachments/ChatAttachmentMcpResources.svelte';
|
||||
|
||||
/**
|
||||
* Thumbnail for non-image file attachments. Displays file type icon based on extension,
|
||||
* file name (truncated), and file size.
|
||||
* Handles text files, PDFs, audio, and other document types.
|
||||
*/
|
||||
export { default as ChatAttachmentThumbnailFile } from './ChatAttachments/ChatAttachmentThumbnailFile.svelte';
|
||||
|
||||
/**
|
||||
* Thumbnail for image attachments with lazy loading and error fallback.
|
||||
* Displays image preview with configurable dimensions. Falls back to placeholder
|
||||
* on load error.
|
||||
*/
|
||||
export { default as ChatAttachmentThumbnailImage } from './ChatAttachments/ChatAttachmentThumbnailImage.svelte';
|
||||
|
||||
/**
|
||||
* Grid view of all attachments for "View All" dialog. Displays all attachments
|
||||
* in a responsive grid layout when there are too many to show inline.
|
||||
* Triggered by "+X more" button in ChatAttachmentsList.
|
||||
*/
|
||||
export { default as ChatAttachmentsViewAll } from './ChatAttachments/ChatAttachmentsViewAll.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* FORM
|
||||
*
|
||||
* Components for the chat input area. The form handles user input, file attachments,
|
||||
* audio recording, and MCP prompts & resources selection. It integrates with multiple stores:
|
||||
* - `chatStore` for message submission and generation control
|
||||
* - `modelsStore` for model selection and validation
|
||||
* - `mcpStore` for MCP prompt browsing and loading
|
||||
*
|
||||
* The form exposes a public API for programmatic control from parent components
|
||||
* (focus, height reset, model selector, validation).
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ChatForm** - Main chat input component with rich features
|
||||
*
|
||||
* The primary input interface for composing and sending chat messages.
|
||||
* Orchestrates text input, file attachments, audio recording, and MCP prompts.
|
||||
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Composes ChatFormTextarea, ChatFormActions, and ChatFormPromptPicker
|
||||
* - Manages file upload state via `uploadedFiles` bindable prop
|
||||
* - Integrates with ModelsSelector for model selection in router mode
|
||||
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
|
||||
*
|
||||
* **Input Handling:**
|
||||
* - IME-safe Enter key handling (waits for composition end)
|
||||
* - Shift+Enter for newline, Enter for submit
|
||||
* - Paste handler for files and long text (> {pasteLongTextToFileLen} chars → file conversion)
|
||||
* - Keyboard shortcut `/` triggers MCP prompt picker
|
||||
*
|
||||
* **Features:**
|
||||
* - Auto-resizing textarea with placeholder
|
||||
* - File upload via button dropdown (images/text/PDF), drag-drop, or paste
|
||||
* - Audio recording with WAV conversion (when model supports audio)
|
||||
* - MCP prompt picker with search and argument forms
|
||||
* - MCP reource picker with component to list attached resources at the bottom of Chat Form
|
||||
* - Model selector integration (router mode)
|
||||
* - Loading state with stop button, disabled state for errors
|
||||
*
|
||||
* **Exported API:**
|
||||
* - `focus()` - Focus the textarea programmatically
|
||||
* - `resetTextareaHeight()` - Reset textarea to default height after submit
|
||||
* - `openModelSelector()` - Open model selection dropdown
|
||||
* - `checkModelSelected(): boolean` - Validate model selection, show error if none
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatForm
|
||||
* bind:this={chatFormRef}
|
||||
* bind:value={message}
|
||||
* bind:uploadedFiles
|
||||
* {isLoading}
|
||||
* onSubmit={handleSubmit}
|
||||
* onFilesAdd={processFiles}
|
||||
* onStop={handleStop}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatForm } from './ChatForm/ChatForm.svelte';
|
||||
|
||||
/**
|
||||
* Dropdown button for file attachment selection. Opens a menu with options for
|
||||
* Images, Text Files, and PDF Files. Each option filters the file picker to
|
||||
* appropriate types. Images option is disabled when model lacks vision modality.
|
||||
*/
|
||||
export { default as ChatFormActionAttachmentsDropdown } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
|
||||
|
||||
/**
|
||||
* Audio recording button with real-time recording indicator. Records audio
|
||||
* and converts to WAV format for upload. Only visible when the active model
|
||||
* supports audio modality and setting for automatic audio input is enabled. Shows recording duration while active.
|
||||
*/
|
||||
export { default as ChatFormActionRecord } from './ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
|
||||
|
||||
/**
|
||||
* Container for chat form action buttons. Arranges file attachment, audio record,
|
||||
* and submit/stop buttons in a horizontal layout. Handles conditional visibility
|
||||
* based on model capabilities and loading state.
|
||||
*/
|
||||
export { default as ChatFormActions } from './ChatForm/ChatFormActions/ChatFormActions.svelte';
|
||||
|
||||
/**
|
||||
* Submit/stop button with loading state. Shows send icon normally, transforms
|
||||
* to stop icon during generation. Disabled when input is empty or form is disabled.
|
||||
* Triggers onSubmit or onStop callbacks based on current state.
|
||||
*/
|
||||
export { default as ChatFormActionSubmit } from './ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
|
||||
|
||||
/**
|
||||
* Hidden file input element for programmatic file selection.
|
||||
*/
|
||||
export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileInputInvisible.svelte';
|
||||
|
||||
/**
|
||||
* Helper text display below chat.
|
||||
*/
|
||||
export { default as ChatFormHelperText } from './ChatForm/ChatFormHelperText.svelte';
|
||||
|
||||
/**
|
||||
* Auto-resizing textarea with IME composition support. Automatically adjusts
|
||||
* height based on content. Handles IME input correctly (waits for composition
|
||||
* end before processing Enter key). Exposes focus() and resetHeight() methods.
|
||||
*/
|
||||
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';
|
||||
|
||||
/**
|
||||
* Header for prompt picker with search input and close button. Contains the
|
||||
* search field for filtering prompts and X button to dismiss the picker.
|
||||
* Search input is auto-focused when picker opens.
|
||||
*/
|
||||
export { default as ChatFormPromptPickerHeader } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerHeader.svelte';
|
||||
|
||||
/**
|
||||
* Scrollable list of available MCP prompts. Renders ChatFormPromptPickerListItem
|
||||
* for each prompt, grouped by server. Handles empty state when no prompts match
|
||||
* search query. Manages scroll position for keyboard navigation.
|
||||
*/
|
||||
export { default as ChatFormPromptPickerList } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerList.svelte';
|
||||
|
||||
/**
|
||||
* Single prompt item in the picker list. Displays server avatar, prompt name,
|
||||
* and description. Highlights on hover/keyboard focus. Triggers selection
|
||||
* callback on click or Enter key.
|
||||
*/
|
||||
export { default as ChatFormPromptPickerListItem } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerListItem.svelte';
|
||||
|
||||
/**
|
||||
* Skeleton loading placeholder for prompt picker items. Displays animated
|
||||
* placeholder while prompts are being fetched from MCP servers.
|
||||
* Matches dimensions of ChatFormPromptPickerListItem.
|
||||
*/
|
||||
export { default as ChatFormPromptPickerListItemSkeleton } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerListItemSkeleton.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* MESSAGES
|
||||
*
|
||||
* Components for displaying chat messages. The message system supports:
|
||||
* - **Conversation branching**: Messages can have siblings (alternative versions)
|
||||
* created by editing or regenerating. Users can navigate between branches.
|
||||
* - **Role-based rendering**: Different layouts for user, assistant, and system messages
|
||||
* - **Streaming support**: Real-time display of assistant responses as they generate
|
||||
* - **Agentic workflows**: Special rendering for tool calls and reasoning blocks
|
||||
*
|
||||
* The branching system uses `getMessageSiblings()` utility to compute sibling info
|
||||
* for each message based on the full conversation tree stored in the database.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ChatMessages** - Message list container with branching support
|
||||
*
|
||||
* Container component that renders the list of messages in a conversation.
|
||||
* Computes sibling information for each message to enable branch navigation.
|
||||
* Integrates with conversationsStore for message operations.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Fetches all conversation messages to compute sibling relationships
|
||||
* - Filters system messages based on user config (`showSystemMessage`)
|
||||
* - Delegates rendering to ChatMessage for each message
|
||||
* - Propagates all message operations to chatStore via callbacks
|
||||
*
|
||||
* **Branching Logic:**
|
||||
* - Uses `getMessageSiblings()` to find all messages with same parent
|
||||
* - Computes `siblingInfo: { currentIndex, totalSiblings, siblingIds }`
|
||||
* - Enables navigation between alternative message versions
|
||||
*
|
||||
* **Message Operations (delegated to chatStore):**
|
||||
* - Edit with branching: Creates new message branch, preserves original
|
||||
* - Edit with replacement: Modifies message in place
|
||||
* - Regenerate: Creates new assistant response as sibling
|
||||
* - Delete: Removes message and all descendants (cascade)
|
||||
* - Continue: Appends to incomplete assistant message
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatMessages
|
||||
* messages={activeMessages()}
|
||||
* onUserAction={resetAutoScroll}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
|
||||
|
||||
/**
|
||||
* **ChatMessage** - Single message display with actions
|
||||
*
|
||||
* Renders a single chat message with role-specific styling and full action
|
||||
* support. Delegates to specialized components based on message role:
|
||||
* ChatMessageUser, ChatMessageAssistant, or ChatMessageSystem.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Routes to role-specific component based on `message.type`
|
||||
* - Manages edit mode state and inline editing UI
|
||||
* - Handles action callbacks (copy, edit, delete, regenerate)
|
||||
* - Displays branching controls when message has siblings
|
||||
*
|
||||
* **User Messages:**
|
||||
* - Shows attachments via ChatAttachmentsList
|
||||
* - Displays MCP prompts if present
|
||||
* - Edit creates new branch or preserves responses
|
||||
*
|
||||
* **Assistant Messages:**
|
||||
* - Renders content via MarkdownContent or ChatMessageAgenticContent
|
||||
* - Shows model info badge (when enabled)
|
||||
* - Regenerate creates sibling with optional model override
|
||||
* - Continue action for incomplete responses
|
||||
*
|
||||
* **Features:**
|
||||
* - Inline editing with file attachments support
|
||||
* - Copy formatted content to clipboard
|
||||
* - Delete with confirmation (shows cascade delete count)
|
||||
* - Branching controls for sibling navigation
|
||||
* - Statistics display (tokens, timing)
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatMessage
|
||||
* {message}
|
||||
* {siblingInfo}
|
||||
* onEditWithBranching={handleEdit}
|
||||
* onRegenerateWithBranching={handleRegenerate}
|
||||
* onNavigateToSibling={handleNavigate}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
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/agentic` to parse markers
|
||||
* - Renders sections as CollapsibleContentBlock components
|
||||
* - Handles streaming state for progressive content display
|
||||
* - Falls back to MarkdownContent for plain text sections
|
||||
*
|
||||
* **Marker Format:**
|
||||
* - Tool calls: in constants/agentic.ts (AGENTIC_TAGS)
|
||||
* - Reasoning: in constants/agentic.ts (REASONING_TAGS)
|
||||
* - Partial markers handled gracefully during streaming
|
||||
*
|
||||
* **Execution States:**
|
||||
* - **Streaming**: Animated spinner, block expanded, auto-scroll enabled
|
||||
* - **Pending**: Waiting indicator for queued tool calls
|
||||
* - **Completed**: Static display, block collapsed by default
|
||||
*
|
||||
* **Features:**
|
||||
* - JSON arguments syntax highlighting via SyntaxHighlightedCode
|
||||
* - Tool results display with formatting
|
||||
* - Plain text sections between markers rendered as markdown
|
||||
* - Smart collapse defaults (expanded while streaming, collapsed when done)
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatMessageAgenticContent
|
||||
* content={message.content}
|
||||
* {message}
|
||||
* isStreaming={isGenerating}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatMessageAgenticContent } from './ChatMessages/ChatMessageAgenticContent.svelte';
|
||||
|
||||
/**
|
||||
* Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate
|
||||
* buttons based on message role. Includes branching controls when message has siblings.
|
||||
* Shows delete confirmation dialog with cascade delete count. Handles raw output toggle
|
||||
* for assistant messages.
|
||||
*/
|
||||
export { default as ChatMessageActions } from './ChatMessages/ChatMessageActions.svelte';
|
||||
|
||||
/**
|
||||
* Navigation controls for message siblings (conversation branches). Displays
|
||||
* prev/next arrows with current position counter (e.g., "2/5"). Enables users
|
||||
* to navigate between alternative versions of a message created by editing
|
||||
* or regenerating. Uses `conversationsStore.navigateToSibling()` for navigation.
|
||||
*/
|
||||
export { default as ChatMessageBranchingControls } from './ChatMessages/ChatMessageBranchingControls.svelte';
|
||||
|
||||
/**
|
||||
* Statistics display for assistant messages. Shows token counts (prompt/completion),
|
||||
* generation timing, tokens per second, and model name (when enabled in settings).
|
||||
* Data sourced from message.timings stored during generation.
|
||||
*/
|
||||
export { default as ChatMessageStatistics } from './ChatMessages/ChatMessageStatistics.svelte';
|
||||
|
||||
/**
|
||||
* MCP prompt display in user messages. Shows when user selected an MCP prompt
|
||||
* via ChatFormPromptPicker. Displays server name, prompt name, and expandable
|
||||
* content preview. Stored in message.extra as DatabaseMessageExtraMcpPrompt.
|
||||
*/
|
||||
export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessageMcpPrompt.svelte';
|
||||
|
||||
/**
|
||||
* Formatted content display for MCP prompt messages. Renders the full prompt
|
||||
* content with arguments in a readable format. Used within ChatMessageMcpPrompt
|
||||
* for the expanded view.
|
||||
*/
|
||||
export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessageMcpPromptContent.svelte';
|
||||
|
||||
/**
|
||||
* System message display component. Renders system messages with distinct styling.
|
||||
* Visibility controlled by `showSystemMessage` config setting.
|
||||
*/
|
||||
export { default as ChatMessageSystem } from './ChatMessages/ChatMessageSystem.svelte';
|
||||
|
||||
/**
|
||||
* User message display component. Renders user messages with right-aligned bubble styling.
|
||||
* Shows message content, attachments via ChatAttachmentsList, and MCP prompts if present.
|
||||
* Supports inline editing mode with ChatMessageEditForm integration.
|
||||
*/
|
||||
export { default as ChatMessageUser } from './ChatMessages/ChatMessageUser.svelte';
|
||||
|
||||
/**
|
||||
* Assistant message display component. Renders assistant responses with left-aligned styling.
|
||||
* Supports both plain markdown content (via MarkdownContent) and agentic content with tool calls
|
||||
* (via ChatMessageAgenticContent). Shows model info badge, statistics, and action buttons.
|
||||
* Handles streaming state with real-time content updates.
|
||||
*/
|
||||
export { default as ChatMessageAssistant } from './ChatMessages/ChatMessageAssistant.svelte';
|
||||
|
||||
/**
|
||||
* Inline message editing form. Provides textarea for editing message content with
|
||||
* attachment management. Shows save/cancel buttons and optional "Save only" button
|
||||
* for editing without regenerating responses. Used within ChatMessage components
|
||||
* when user enters edit mode.
|
||||
*/
|
||||
export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditForm.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* SCREEN
|
||||
*
|
||||
* Top-level chat interface components. ChatScreen is the main container that
|
||||
* orchestrates all chat functionality. It integrates with multiple stores:
|
||||
* - `chatStore` for message operations and generation control
|
||||
* - `conversationsStore` for conversation management
|
||||
* - `serverStore` for server connection state
|
||||
* - `modelsStore` for model capabilities (vision, audio modalities)
|
||||
*
|
||||
* The screen handles the complete chat lifecycle from empty state to active
|
||||
* conversation with streaming responses.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ChatScreen** - Main chat interface container
|
||||
*
|
||||
* Top-level component that orchestrates the entire chat interface. Manages
|
||||
* messages display, input form, file handling, auto-scroll, error dialogs,
|
||||
* and server state. Used as the main content area in chat routes.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Composes ChatMessages, ChatScreenForm, ChatScreenHeader, and dialogs
|
||||
* - Manages auto-scroll via `createAutoScrollController()` hook
|
||||
* - Handles file upload pipeline (validation → processing → state update)
|
||||
* - Integrates with serverStore for loading/error/warning states
|
||||
* - Tracks active model for modality validation (vision, audio)
|
||||
*
|
||||
* **File Upload Pipeline:**
|
||||
* 1. Files received via drag-drop, paste, or file picker
|
||||
* 2. Validated against supported types (`isFileTypeSupported()`)
|
||||
* 3. Filtered by model modalities (`filterFilesByModalities()`)
|
||||
* 4. Empty files detected and reported via DialogEmptyFileAlert
|
||||
* 5. Valid files processed to ChatUploadedFile[] format
|
||||
* 6. Unsupported files shown in error dialog with reasons
|
||||
*
|
||||
* **State Management:**
|
||||
* - `isEmpty`: Shows centered welcome UI when no conversation active
|
||||
* - `isCurrentConversationLoading`: Tracks generation state for current chat
|
||||
* - `activeModelId`: Determines available modalities for file validation
|
||||
* - `uploadedFiles`: Pending file attachments for next message
|
||||
*
|
||||
* **Features:**
|
||||
* - Messages display with smart auto-scroll (pauses on user scroll up)
|
||||
* - File drag-drop with visual overlay indicator
|
||||
* - File validation with detailed error messages
|
||||
* - Error dialog management (chat errors, model unavailable)
|
||||
* - Server loading/error/warning states with appropriate UI
|
||||
* - Conversation deletion with confirmation dialog
|
||||
* - Processing info display (tokens/sec, timing) during generation
|
||||
* - Keyboard shortcuts (Ctrl+Shift+Backspace to delete conversation)
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <!-- In chat route -->
|
||||
* <ChatScreen showCenteredEmpty={true} />
|
||||
*
|
||||
* <!-- In conversation route -->
|
||||
* <ChatScreen showCenteredEmpty={false} />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatScreen } from './ChatScreen/ChatScreen.svelte';
|
||||
|
||||
/**
|
||||
* Visual overlay displayed when user drags files over the chat screen.
|
||||
* Shows drop zone indicator to guide users where to release files.
|
||||
* Integrated with ChatScreen's drag-drop file upload handling.
|
||||
*/
|
||||
export { default as ChatScreenDragOverlay } from './ChatScreen/ChatScreenDragOverlay.svelte';
|
||||
|
||||
/**
|
||||
* Chat form wrapper within ChatScreen. Positions the ChatForm component at the
|
||||
* bottom of the screen with proper padding and max-width constraints. Handles
|
||||
* the visual container styling for the input area.
|
||||
*/
|
||||
export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte';
|
||||
|
||||
/**
|
||||
* Header bar for chat screen. Displays conversation title (or "New Chat"),
|
||||
* model selector (in router mode), and action buttons (delete conversation).
|
||||
* Sticky positioned at the top of the chat area.
|
||||
*/
|
||||
export { default as ChatScreenHeader } from './ChatScreen/ChatScreenHeader.svelte';
|
||||
|
||||
/**
|
||||
* Processing info display during generation. Shows real-time statistics:
|
||||
* tokens per second, prompt/completion token counts, and elapsed time.
|
||||
* Data sourced from slotsService polling during active generation.
|
||||
* Only visible when `isCurrentConversationLoading` is true.
|
||||
*/
|
||||
export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* SETTINGS
|
||||
*
|
||||
* Application settings components. Settings are persisted to localStorage via
|
||||
* the config store and synchronized with server `/props` endpoint for sampling
|
||||
* parameters. The settings panel uses a tabbed interface with mobile-responsive
|
||||
* horizontal scrolling tabs.
|
||||
*
|
||||
* **Parameter Sync System:**
|
||||
* Sampling parameters (temperature, top_p, etc.) can come from three sources:
|
||||
* 1. **Server Props**: Default values from `/props` endpoint
|
||||
* 2. **User Custom**: Values explicitly set by user (overrides server)
|
||||
* 3. **App Default**: Fallback when server props unavailable
|
||||
*
|
||||
* The `ChatSettingsParameterSourceIndicator` badge shows which source is active.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ChatSettings** - Application settings panel
|
||||
*
|
||||
* Comprehensive settings interface with categorized sections. Manages all
|
||||
* user preferences and sampling parameters. Integrates with config store
|
||||
* for persistence and ParameterSyncService for server synchronization.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses tabbed navigation with category sections
|
||||
* - Maintains local form state, commits on save
|
||||
* - Tracks user overrides vs server defaults for sampling params
|
||||
* - Exposes reset() method for dialog close without save
|
||||
*
|
||||
* **Categories:**
|
||||
* - **General**: API key, system message, show system messages toggle
|
||||
* - **Display**: Theme selection, message actions visibility, model info badge
|
||||
* - **Sampling**: Temperature, top_p, top_k, min_p, repeat_penalty, etc.
|
||||
* - **Penalties**: Frequency penalty, presence penalty, repeat last N
|
||||
* - **Import/Export**: Conversation backup and restore
|
||||
* - **MCP**: MCP server management (opens DialogMcpServersSettings)
|
||||
* - **Developer**: Debug options, disable auto-scroll
|
||||
*
|
||||
* **Parameter Sync:**
|
||||
* - Fetches defaults from server `/props` endpoint
|
||||
* - Shows source indicator badge (Custom/Server Props/Default)
|
||||
* - Real-time badge updates as user types
|
||||
* - Tracks which parameters user has explicitly overridden
|
||||
*
|
||||
* **Features:**
|
||||
* - Mobile-responsive layout with horizontal scrolling tabs
|
||||
* - Form validation with error messages
|
||||
* - Secure API key storage (masked input)
|
||||
* - Import/export conversations as JSON
|
||||
* - Reset to defaults option per parameter
|
||||
*
|
||||
* **Exported API:**
|
||||
* - `reset()` - Reset form fields to currently saved values (for cancel action)
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatSettings
|
||||
* bind:this={settingsRef}
|
||||
* onSave={() => dialogOpen = false}
|
||||
* onCancel={() => { settingsRef.reset(); dialogOpen = false; }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatSettings } from './ChatSettings/ChatSettings.svelte';
|
||||
|
||||
/**
|
||||
* Footer with save/cancel buttons for settings panel. Positioned at bottom
|
||||
* of settings dialog. Save button commits form state to config store,
|
||||
* cancel button triggers reset and close.
|
||||
*/
|
||||
export { default as ChatSettingsFooter } from './ChatSettings/ChatSettingsFooter.svelte';
|
||||
|
||||
/**
|
||||
* Form fields renderer for individual settings. Generates appropriate input
|
||||
* components based on field type (text, number, select, checkbox, textarea).
|
||||
* Handles validation, help text display, and parameter source indicators.
|
||||
*/
|
||||
export { default as ChatSettingsFields } from './ChatSettings/ChatSettingsFields.svelte';
|
||||
|
||||
/**
|
||||
* Import/export tab content for conversation data management. Provides buttons
|
||||
* to export all conversations as JSON file and import from JSON file.
|
||||
* Handles file download/upload and data validation.
|
||||
*/
|
||||
export { default as ChatSettingsImportExportTab } from './ChatSettings/ChatSettingsImportExportTab.svelte';
|
||||
|
||||
/**
|
||||
* Badge indicating parameter source for sampling settings. Shows one of:
|
||||
* - **Custom**: User has explicitly set this value (orange badge)
|
||||
* - **Server Props**: Using default from `/props` endpoint (blue badge)
|
||||
* - **Default**: Using app default, server props unavailable (gray badge)
|
||||
* Updates in real-time as user types to show immediate feedback.
|
||||
*/
|
||||
export { default as ChatSettingsParameterSourceIndicator } from './ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* SIDEBAR
|
||||
*
|
||||
* The sidebar integrates with ShadCN's sidebar component system
|
||||
* for consistent styling and mobile responsiveness.
|
||||
* Conversations are loaded from conversationsStore and displayed in reverse
|
||||
* chronological order (most recent first).
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ChatSidebar** - Chat Sidebar with actions menu and conversation list
|
||||
*
|
||||
* Collapsible sidebar displaying conversation history with search and
|
||||
* management actions. Integrates with ShadCN sidebar component for
|
||||
* consistent styling and mobile responsiveness.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN Sidebar.* components for structure
|
||||
* - Fetches conversations from conversationsStore
|
||||
* - Manages search state and filtered results locally
|
||||
* - Handles conversation CRUD operations via conversationsStore
|
||||
*
|
||||
* **Navigation:**
|
||||
* - Click conversation to navigate to `/chat/[id]`
|
||||
* - New chat button navigates to `/` (root)
|
||||
* - Active conversation highlighted based on route params
|
||||
*
|
||||
* **Conversation Management:**
|
||||
* - Right-click or menu button for context menu
|
||||
* - Rename: Opens inline edit dialog
|
||||
* - Delete: Shows confirmation with conversation preview
|
||||
* - Delete All: Removes all conversations with confirmation
|
||||
*
|
||||
* **Features:**
|
||||
* - Search/filter conversations by title
|
||||
* - Conversation list with message previews (first message truncated)
|
||||
* - Active conversation highlighting
|
||||
* - Mobile-responsive collapse/expand via ShadCN sidebar
|
||||
* - New chat button in header
|
||||
* - Settings button opens DialogChatSettings
|
||||
*
|
||||
* **Exported API:**
|
||||
* - `handleMobileSidebarItemClick()` - Close sidebar on mobile after item selection
|
||||
* - `activateSearchMode()` - Focus search input programmatically
|
||||
* - `editActiveConversation()` - Open rename dialog for current conversation
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatSidebar bind:this={sidebarRef} />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatSidebar } from './ChatSidebar/ChatSidebar.svelte';
|
||||
|
||||
/**
|
||||
* Action buttons for sidebar header. Contains new chat button, settings button,
|
||||
* and delete all conversations button. Manages dialog states for settings and
|
||||
* delete confirmation.
|
||||
*/
|
||||
export { default as ChatSidebarActions } from './ChatSidebar/ChatSidebarActions.svelte';
|
||||
|
||||
/**
|
||||
* Single conversation item in sidebar. Displays conversation title (truncated),
|
||||
* last message preview, and timestamp. Shows context menu on right-click with
|
||||
* rename and delete options. Highlights when active (matches current route).
|
||||
* Handles click to navigate and keyboard accessibility.
|
||||
*/
|
||||
export { default as ChatSidebarConversationItem } from './ChatSidebar/ChatSidebarConversationItem.svelte';
|
||||
|
||||
/**
|
||||
* Search input for filtering conversations in sidebar. Filters conversation
|
||||
* list by title as user types. Shows clear button when query is not empty.
|
||||
* Integrated into sidebar header with proper styling.
|
||||
*/
|
||||
export { default as ChatSidebarSearch } from './ChatSidebar/ChatSidebarSearch.svelte';
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<script lang="ts">
|
||||
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 { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
class?: string;
|
||||
icon?: Component;
|
||||
iconClass?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
isStreaming?: boolean;
|
||||
onToggle?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
class: className = '',
|
||||
icon: Icon,
|
||||
iconClass = 'h-4 w-4',
|
||||
title,
|
||||
subtitle,
|
||||
isStreaming = false,
|
||||
onToggle,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
let contentContainer: HTMLDivElement | undefined = $state();
|
||||
const autoScroll = createAutoScrollController();
|
||||
|
||||
$effect(() => {
|
||||
autoScroll.setContainer(contentContainer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Only auto-scroll when open and streaming
|
||||
autoScroll.updateInterval(open && isStreaming);
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
autoScroll.handleScroll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Collapsible.Root
|
||||
{open}
|
||||
onOpenChange={(value) => {
|
||||
open = value;
|
||||
onToggle?.();
|
||||
}}
|
||||
class={className}
|
||||
>
|
||||
<Card class="gap-0 border-muted bg-muted/30 py-0">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer items-center justify-between p-3">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
{#if Icon}
|
||||
<Icon class={iconClass} />
|
||||
{/if}
|
||||
|
||||
<span class="font-mono text-sm font-medium">{title}</span>
|
||||
|
||||
{#if subtitle}
|
||||
<span class="text-xs italic">{subtitle}</span>
|
||||
{/if}
|
||||
</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 content</span>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div
|
||||
bind:this={contentContainer}
|
||||
class="overflow-y-auto border-t border-muted px-3 pb-3"
|
||||
onscroll={handleScroll}
|
||||
style="min-height: var(--min-message-height); max-height: var(--max-message-height);"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Card>
|
||||
</Collapsible.Root>
|
||||
|
|
@ -11,53 +11,97 @@
|
|||
import type { Root as MdastRoot } from 'mdast';
|
||||
import { browser } from '$app/environment';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
|
||||
import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
|
||||
import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
|
||||
import { rehypeResolveAttachmentImages } from '$lib/markdown/resolve-attachment-images';
|
||||
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
|
||||
import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
|
||||
import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
|
||||
import {
|
||||
IMAGE_NOT_ERROR_BOUND_SELECTOR,
|
||||
DATA_ERROR_BOUND_ATTR,
|
||||
DATA_ERROR_HANDLED_ATTR,
|
||||
BOOL_TRUE_STRING
|
||||
} from '$lib/constants/markdown';
|
||||
import { UrlPrefix } from '$lib/enums';
|
||||
import { FileTypeText } from '$lib/enums/files';
|
||||
import {
|
||||
highlightCode,
|
||||
detectIncompleteCodeBlock,
|
||||
type IncompleteCodeBlock
|
||||
} from '$lib/utils/code';
|
||||
import '$styles/katex-custom.scss';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import { mode } from 'mode-watcher';
|
||||
import CodePreviewDialog from './CodePreviewDialog.svelte';
|
||||
import { ActionIconsCodeBlock, DialogCodePreview } from '$lib/components/app';
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
|
||||
interface Props {
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
content: string;
|
||||
class?: string;
|
||||
disableMath?: boolean;
|
||||
}
|
||||
|
||||
interface MarkdownBlock {
|
||||
id: string;
|
||||
html: string;
|
||||
contentHash?: string;
|
||||
}
|
||||
|
||||
let { content, class: className = '' }: Props = $props();
|
||||
let { content, attachments, class: className = '', disableMath = false }: Props = $props();
|
||||
|
||||
let containerRef = $state<HTMLDivElement>();
|
||||
let renderedBlocks = $state<MarkdownBlock[]>([]);
|
||||
let unstableBlockHtml = $state('');
|
||||
let incompleteCodeBlock = $state<IncompleteCodeBlock | null>(null);
|
||||
let previewDialogOpen = $state(false);
|
||||
let previewCode = $state('');
|
||||
let previewLanguage = $state('text');
|
||||
let streamingCodeScrollContainer = $state<HTMLDivElement>();
|
||||
|
||||
// Auto-scroll controller for streaming code block content
|
||||
const streamingAutoScroll = createAutoScrollController();
|
||||
|
||||
let pendingMarkdown: string | null = null;
|
||||
let isProcessing = false;
|
||||
|
||||
// Per-instance transform cache, avoids re-transforming stable blocks during streaming
|
||||
// Garbage collected when component is destroyed (on conversation change)
|
||||
const transformCache = new SvelteMap<string, string>();
|
||||
let previousContent = '';
|
||||
|
||||
const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
|
||||
|
||||
let processor = $derived(() => {
|
||||
return remark()
|
||||
.use(remarkGfm) // GitHub Flavored Markdown
|
||||
.use(remarkMath) // Parse $inline$ and $$block$$ math
|
||||
void attachments;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
|
||||
}
|
||||
|
||||
proc = proc
|
||||
.use(remarkBreaks) // Convert line breaks to <br>
|
||||
.use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
|
||||
.use(remarkRehype) // Convert Markdown AST to rehype
|
||||
.use(rehypeKatex) // Render math using KaTeX
|
||||
.use(rehypeHighlight) // Add syntax highlighting
|
||||
.use(remarkRehype); // Convert Markdown AST to rehype
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(rehypeKatex); // Render math using KaTeX
|
||||
}
|
||||
|
||||
return proc
|
||||
.use(rehypeHighlight, {
|
||||
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] }
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
.use(rehypeResolveAttachmentImages, { attachments })
|
||||
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
|
||||
});
|
||||
|
||||
|
|
@ -154,6 +198,61 @@
|
|||
return `${node.type}-${indexFallback}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a hash for MDAST node based on its position.
|
||||
* Used for cache lookup during incremental rendering.
|
||||
*/
|
||||
function getMdastNodeHash(node: unknown, index: number): string {
|
||||
const n = node as {
|
||||
type?: string;
|
||||
position?: { start?: { offset?: number }; end?: { offset?: number } };
|
||||
};
|
||||
|
||||
if (n.position?.start?.offset != null && n.position?.end?.offset != null) {
|
||||
return `${n.type}-${n.position.start.offset}-${n.position.end.offset}`;
|
||||
}
|
||||
|
||||
return `${n.type}-idx${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're in append-only mode (streaming).
|
||||
*/
|
||||
function isAppendMode(newContent: string): boolean {
|
||||
return previousContent.length > 0 && newContent.startsWith(previousContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a single MDAST node to HTML string with caching.
|
||||
* Runs the full remark/rehype plugin pipeline (GFM, math, syntax highlighting, etc.)
|
||||
* on an isolated single-node tree, then stringifies the resulting HAST to HTML.
|
||||
* Results are cached by node position hash for streaming performance.
|
||||
* @param processorInstance - The remark/rehype processor instance
|
||||
* @param node - The MDAST node to transform
|
||||
* @param index - Node index for hash fallback
|
||||
* @returns Object containing the HTML string and cache hash
|
||||
*/
|
||||
async function transformMdastNode(
|
||||
processorInstance: ReturnType<typeof processor>,
|
||||
node: unknown,
|
||||
index: number
|
||||
): Promise<{ html: string; hash: string }> {
|
||||
const hash = getMdastNodeHash(node, index);
|
||||
|
||||
const cached = transformCache.get(hash);
|
||||
if (cached) {
|
||||
return { html: cached, hash };
|
||||
}
|
||||
|
||||
const singleNodeRoot = { type: 'root', children: [node] };
|
||||
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
|
||||
const html = processorInstance.stringify(transformedRoot);
|
||||
|
||||
transformCache.set(hash, html);
|
||||
|
||||
return { html, hash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles click events on copy buttons within code blocks.
|
||||
* Copies the raw code content to the clipboard.
|
||||
|
|
@ -225,50 +324,126 @@
|
|||
/**
|
||||
* Processes markdown content into stable and unstable HTML blocks.
|
||||
* Uses incremental rendering: stable blocks are cached, unstable block is re-rendered.
|
||||
* Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity.
|
||||
* @param markdown - The raw markdown string to process
|
||||
*/
|
||||
async function processMarkdown(markdown: string) {
|
||||
// Early exit if content unchanged (can happen with rapid coalescing)
|
||||
if (markdown === previousContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!markdown) {
|
||||
renderedBlocks = [];
|
||||
unstableBlockHtml = '';
|
||||
incompleteCodeBlock = null;
|
||||
previousContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for incomplete code block at the end of content
|
||||
const incompleteBlock = detectIncompleteCodeBlock(markdown);
|
||||
|
||||
if (incompleteBlock) {
|
||||
// Process only the prefix (content before the incomplete code block)
|
||||
const prefixMarkdown = markdown.slice(0, incompleteBlock.openingIndex);
|
||||
|
||||
if (prefixMarkdown.trim()) {
|
||||
const normalizedPrefix = preprocessLaTeX(prefixMarkdown);
|
||||
const processorInstance = processor();
|
||||
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
|
||||
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
|
||||
const nextBlocks: MarkdownBlock[] = [];
|
||||
|
||||
// Check if we're in append mode for cache reuse
|
||||
const appendMode = isAppendMode(prefixMarkdown);
|
||||
const previousBlockCount = appendMode ? renderedBlocks.length : 0;
|
||||
|
||||
// All prefix blocks are now stable since code block is separate
|
||||
for (let index = 0; index < mdastChildren.length; index++) {
|
||||
const child = mdastChildren[index];
|
||||
|
||||
// In append mode, reuse previous blocks if unchanged
|
||||
if (appendMode && index < previousBlockCount) {
|
||||
const prevBlock = renderedBlocks[index];
|
||||
const currentHash = getMdastNodeHash(child, index);
|
||||
if (prevBlock?.contentHash === currentHash) {
|
||||
nextBlocks.push(prevBlock);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Transform this block (with caching)
|
||||
const { html, hash } = await transformMdastNode(processorInstance, child, index);
|
||||
const id = getHastNodeId(
|
||||
{ position: (child as { position?: unknown }).position } as HastRootContent,
|
||||
index
|
||||
);
|
||||
nextBlocks.push({ id, html, contentHash: hash });
|
||||
}
|
||||
|
||||
renderedBlocks = nextBlocks;
|
||||
} else {
|
||||
renderedBlocks = [];
|
||||
}
|
||||
|
||||
previousContent = prefixMarkdown;
|
||||
unstableBlockHtml = '';
|
||||
incompleteCodeBlock = incompleteBlock;
|
||||
return;
|
||||
}
|
||||
|
||||
// No incomplete code block - use standard processing
|
||||
incompleteCodeBlock = null;
|
||||
|
||||
const normalized = preprocessLaTeX(markdown);
|
||||
const processorInstance = processor();
|
||||
const ast = processorInstance.parse(normalized) as MdastRoot;
|
||||
const processedRoot = (await processorInstance.run(ast)) as HastRoot;
|
||||
const processedChildren = processedRoot.children ?? [];
|
||||
const stableCount = Math.max(processedChildren.length - 1, 0);
|
||||
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
|
||||
const stableCount = Math.max(mdastChildren.length - 1, 0);
|
||||
const nextBlocks: MarkdownBlock[] = [];
|
||||
|
||||
for (let index = 0; index < stableCount; index++) {
|
||||
const hastChild = processedChildren[index];
|
||||
const id = getHastNodeId(hastChild, index);
|
||||
const existing = renderedBlocks[index];
|
||||
// Check if we're in append mode for cache reuse
|
||||
const appendMode = isAppendMode(markdown);
|
||||
const previousBlockCount = appendMode ? renderedBlocks.length : 0;
|
||||
|
||||
if (existing && existing.id === id) {
|
||||
nextBlocks.push(existing);
|
||||
continue;
|
||||
for (let index = 0; index < stableCount; index++) {
|
||||
const child = mdastChildren[index];
|
||||
|
||||
// In append mode, reuse previous blocks if unchanged
|
||||
if (appendMode && index < previousBlockCount) {
|
||||
const prevBlock = renderedBlocks[index];
|
||||
const currentHash = getMdastNodeHash(child, index);
|
||||
if (prevBlock?.contentHash === currentHash) {
|
||||
nextBlocks.push(prevBlock);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const html = stringifyProcessedNode(
|
||||
processorInstance,
|
||||
processedRoot,
|
||||
processedChildren[index]
|
||||
// Transform this block (with caching)
|
||||
const { html, hash } = await transformMdastNode(processorInstance, child, index);
|
||||
const id = getHastNodeId(
|
||||
{ position: (child as { position?: unknown }).position } as HastRootContent,
|
||||
index
|
||||
);
|
||||
|
||||
nextBlocks.push({ id, html });
|
||||
nextBlocks.push({ id, html, contentHash: hash });
|
||||
}
|
||||
|
||||
let unstableHtml = '';
|
||||
|
||||
if (processedChildren.length > stableCount) {
|
||||
const unstableChild = processedChildren[stableCount];
|
||||
unstableHtml = stringifyProcessedNode(processorInstance, processedRoot, unstableChild);
|
||||
if (mdastChildren.length > stableCount) {
|
||||
const unstableChild = mdastChildren[stableCount];
|
||||
const singleNodeRoot = { type: 'root', children: [unstableChild] };
|
||||
const transformedRoot = (await processorInstance.run(
|
||||
singleNodeRoot as MdastRoot
|
||||
)) as HastRoot;
|
||||
|
||||
unstableHtml = processorInstance.stringify(transformedRoot);
|
||||
}
|
||||
|
||||
renderedBlocks = nextBlocks;
|
||||
previousContent = markdown;
|
||||
await tick(); // Force DOM sync before updating unstable HTML block
|
||||
unstableBlockHtml = unstableHtml;
|
||||
}
|
||||
|
|
@ -299,29 +474,50 @@
|
|||
}
|
||||
|
||||
/**
|
||||
* Converts a single HAST node to an enhanced HTML string.
|
||||
* Applies link and code block enhancements to the output.
|
||||
* @param processorInstance - The remark/rehype processor instance
|
||||
* @param processedRoot - The full processed HAST root (for context)
|
||||
* @param child - The specific HAST child node to stringify
|
||||
* @returns Enhanced HTML string representation of the node
|
||||
* Attaches error handlers to images to show fallback UI when loading fails (e.g., CORS).
|
||||
* Uses data-error-bound attribute to prevent duplicate bindings.
|
||||
*/
|
||||
function stringifyProcessedNode(
|
||||
processorInstance: ReturnType<typeof processor>,
|
||||
processedRoot: HastRoot,
|
||||
child: unknown
|
||||
) {
|
||||
const root: HastRoot = {
|
||||
...(processedRoot as HastRoot),
|
||||
children: [child as never]
|
||||
};
|
||||
function setupImageErrorHandlers() {
|
||||
if (!containerRef) return;
|
||||
|
||||
return processorInstance.stringify(root);
|
||||
const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
|
||||
|
||||
for (const img of images) {
|
||||
img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
|
||||
img.addEventListener('error', handleImageError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles image load errors by replacing the image with a fallback UI.
|
||||
* Shows a placeholder with a link to open the image in a new tab.
|
||||
*/
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
if (!img || !img.src) return;
|
||||
|
||||
// Don't handle data URLs or already-handled images
|
||||
if (
|
||||
img.src.startsWith(UrlPrefix.DATA) ||
|
||||
img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
|
||||
)
|
||||
return;
|
||||
img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
|
||||
|
||||
const src = img.src;
|
||||
// Create fallback element
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'image-load-error';
|
||||
fallback.innerHTML = getImageErrorFallbackHtml(src);
|
||||
|
||||
// Replace image with fallback
|
||||
img.parentNode?.replaceChild(fallback, img);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues markdown for processing with coalescing support.
|
||||
* Only processes the latest markdown when multiple updates arrive quickly.
|
||||
* Uses requestAnimationFrame to yield to browser paint between batches.
|
||||
* @param markdown - The markdown content to render
|
||||
*/
|
||||
async function updateRenderedBlocks(markdown: string) {
|
||||
|
|
@ -339,6 +535,12 @@
|
|||
pendingMarkdown = null;
|
||||
|
||||
await processMarkdown(nextMarkdown);
|
||||
|
||||
// Yield to browser for paint. During this, new chunks coalesce
|
||||
// into pendingMarkdown, so we always render the latest state.
|
||||
if (pendingMarkdown !== null) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to process markdown:', error);
|
||||
|
|
@ -366,12 +568,23 @@
|
|||
|
||||
if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
|
||||
setupCodeBlockActions();
|
||||
setupImageErrorHandlers();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-scroll for streaming code block
|
||||
$effect(() => {
|
||||
streamingAutoScroll.setContainer(streamingCodeScrollContainer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
streamingAutoScroll.updateInterval(incompleteCodeBlock !== null);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupEventListeners();
|
||||
cleanupHighlightTheme();
|
||||
streamingAutoScroll.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -389,9 +602,40 @@
|
|||
{@html unstableBlockHtml}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if incompleteCodeBlock}
|
||||
<div class="code-block-wrapper streaming-code-block relative">
|
||||
<div class="code-block-header">
|
||||
<span class="code-language">{incompleteCodeBlock.language || 'text'}</span>
|
||||
<ActionIconsCodeBlock
|
||||
code={incompleteCodeBlock.code}
|
||||
language={incompleteCodeBlock.language || 'text'}
|
||||
disabled={true}
|
||||
onPreview={(code, lang) => {
|
||||
previewCode = code;
|
||||
previewLanguage = lang;
|
||||
previewDialogOpen = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
bind:this={streamingCodeScrollContainer}
|
||||
class="streaming-code-scroll-container"
|
||||
onscroll={() => streamingAutoScroll.handleScroll()}
|
||||
>
|
||||
<pre class="streaming-code-pre"><code
|
||||
class="hljs language-{incompleteCodeBlock.language || 'text'}"
|
||||
>{@html highlightCode(
|
||||
incompleteCodeBlock.code,
|
||||
incompleteCodeBlock.language || 'text'
|
||||
)}</code
|
||||
></pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<CodePreviewDialog
|
||||
<DialogCodePreview
|
||||
open={previewDialogOpen}
|
||||
code={previewCode}
|
||||
language={previewLanguage}
|
||||
|
|
@ -404,9 +648,20 @@
|
|||
display: contents;
|
||||
}
|
||||
|
||||
/* Streaming code block uses .code-block-wrapper styles */
|
||||
.streaming-code-block .streaming-code-pre {
|
||||
background: transparent;
|
||||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
overflow-x: visible;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Base typography styles */
|
||||
div :global(p:not(:last-child)) {
|
||||
margin-bottom: 1rem;
|
||||
div :global(p) {
|
||||
margin-block: 1rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
|
|
@ -480,12 +735,35 @@
|
|||
'Liberation Mono', Menlo, monospace;
|
||||
}
|
||||
|
||||
div :global(pre) {
|
||||
display: inline;
|
||||
margin: 0 !important;
|
||||
overflow: hidden !important;
|
||||
background: var(--muted);
|
||||
overflow-x: auto;
|
||||
border-radius: 1rem;
|
||||
border: none;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
div :global(pre code) {
|
||||
padding: 0 !important;
|
||||
display: inline !important;
|
||||
}
|
||||
|
||||
div :global(code) {
|
||||
background: transparent;
|
||||
color: var(--code-foreground);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
div :global(a) {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: color 0.2s ease;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
div :global(a:hover) {
|
||||
|
|
@ -609,22 +887,42 @@
|
|||
margin: 1.5rem 0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
|
||||
background: var(--code-background);
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
min-height: var(--min-message-height);
|
||||
max-height: var(--max-message-height);
|
||||
}
|
||||
|
||||
:global(.dark) div :global(.code-block-wrapper) {
|
||||
border-color: color-mix(in oklch, var(--border) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Scroll container for code blocks (both streaming and completed) */
|
||||
div :global(.code-block-scroll-container),
|
||||
.streaming-code-scroll-container {
|
||||
min-height: var(--min-message-height);
|
||||
max-height: var(--max-message-height);
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 3rem 1rem 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
div :global(.code-block-header) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.5rem 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
div :global(.code-language) {
|
||||
color: var(--code-foreground);
|
||||
color: var(--color-foreground);
|
||||
font-weight: 500;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
|
||||
|
|
@ -664,26 +962,10 @@
|
|||
|
||||
div :global(.code-block-wrapper pre) {
|
||||
background: transparent;
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
div :global(pre) {
|
||||
background: var(--muted);
|
||||
margin: 1.5rem 0;
|
||||
overflow-x: auto;
|
||||
border-radius: 1rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
div :global(code) {
|
||||
background: transparent;
|
||||
color: var(--code-foreground);
|
||||
}
|
||||
|
||||
/* Mentions and hashtags */
|
||||
|
|
@ -867,4 +1149,53 @@
|
|||
background: var(--muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* Image load error fallback */
|
||||
div :global(.image-load-error) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 1.5rem 0;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--muted);
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
div :global(.image-error-content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div :global(.image-error-content svg) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
div :global(.image-error-text) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
div :global(.image-error-link) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
div :global(.image-error-link:hover) {
|
||||
background: var(--muted);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -71,13 +71,11 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}"
|
||||
class="code-preview-wrapper rounded-lg border border-border bg-muted {className}"
|
||||
style="max-height: {maxHeight}; max-width: {maxWidth};"
|
||||
>
|
||||
<!-- Needs to be formatted as single line for proper rendering -->
|
||||
<pre class="m-0 overflow-x-auto p-4"><code class="hljs text-sm leading-relaxed"
|
||||
>{@html highlightedHtml}</code
|
||||
></pre>
|
||||
<pre class="m-0"><code class="hljs text-sm leading-relaxed">{@html highlightedHtml}</code></pre>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
*
|
||||
* CONTENT RENDERING
|
||||
*
|
||||
* Components for rendering rich content: markdown, code, and previews.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **MarkdownContent** - Rich markdown renderer
|
||||
*
|
||||
* Renders markdown content with syntax highlighting, LaTeX math,
|
||||
* tables, links, and code blocks. Optimized for streaming with
|
||||
* incremental block-based rendering.
|
||||
*
|
||||
* **Features:**
|
||||
* - GFM (GitHub Flavored Markdown): tables, task lists, strikethrough
|
||||
* - LaTeX math via KaTeX (`$inline$` and `$$block$$`)
|
||||
* - Syntax highlighting (highlight.js) with language detection
|
||||
* - Code copy buttons with click feedback
|
||||
* - External links open in new tab with security attrs
|
||||
* - Image attachment resolution from message extras
|
||||
* - Dark/light theme support (auto-switching)
|
||||
* - Streaming-optimized incremental rendering
|
||||
* - Code preview dialog for large blocks
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <MarkdownContent content={message.content} attachments={message.extra} />
|
||||
* ```
|
||||
*/
|
||||
export { default as MarkdownContent } from './MarkdownContent.svelte';
|
||||
|
||||
/**
|
||||
* **SyntaxHighlightedCode** - Code syntax highlighting
|
||||
*
|
||||
* Renders code with syntax highlighting using highlight.js.
|
||||
* Supports theme switching and scrollable containers.
|
||||
*
|
||||
* **Features:**
|
||||
* - Auto language detection with fallback
|
||||
* - Dark/light theme auto-switching
|
||||
* - Scrollable container with configurable max dimensions
|
||||
* - Monospace font styling
|
||||
* - Preserves whitespace and formatting
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SyntaxHighlightedCode code={jsonString} language="json" />
|
||||
* ```
|
||||
*/
|
||||
export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte';
|
||||
|
||||
/**
|
||||
* **CollapsibleContentBlock** - Expandable content card
|
||||
*
|
||||
* Reusable collapsible card with header, icon, and auto-scroll.
|
||||
* Used for tool calls and reasoning blocks in chat messages.
|
||||
*
|
||||
* **Features:**
|
||||
* - Collapsible content with smooth animation
|
||||
* - Custom icon and title display
|
||||
* - Optional subtitle/status text
|
||||
* - Auto-scroll during streaming (pauses on user scroll)
|
||||
* - Configurable max height with overflow scroll
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <CollapsibleContentBlock
|
||||
* bind:open
|
||||
* icon={BrainIcon}
|
||||
* title="Thinking..."
|
||||
* isStreaming={true}
|
||||
* >
|
||||
* {reasoningContent}
|
||||
* </CollapsibleContentBlock>
|
||||
* ```
|
||||
*/
|
||||
export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte';
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { AlertTriangle, TimerOff } from '@lucide/svelte';
|
||||
import { ErrorDialogType } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
type: 'timeout' | 'server';
|
||||
type: ErrorDialogType;
|
||||
message: string;
|
||||
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
|
@ -12,7 +13,7 @@
|
|||
|
||||
let { open = $bindable(), type, message, contextInfo, onOpenChange }: Props = $props();
|
||||
|
||||
const isTimeout = $derived(type === 'timeout');
|
||||
const isTimeout = $derived(type === ErrorDialogType.TIMEOUT);
|
||||
const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
|
||||
const description = $derived(
|
||||
isTimeout
|
||||
|
|
@ -58,7 +59,12 @@
|
|||
<span class="font-medium">Prompt tokens:</span>
|
||||
{contextInfo.n_prompt_tokens.toLocaleString()}
|
||||
</p>
|
||||
<p><span class="font-medium">Context size:</span> {contextInfo.n_ctx.toLocaleString()}</p>
|
||||
{#if contextInfo.n_ctx}
|
||||
<p>
|
||||
<span class="font-medium">Context size:</span>
|
||||
{contextInfo.n_ctx.toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,9 +28,8 @@
|
|||
|
||||
<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-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
|
||||
style="max-width: 48rem;"
|
||||
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] max-w-4xl! flex-col gap-0 rounded-none
|
||||
p-0 md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
|
||||
>
|
||||
<ChatSettings bind:this={chatSettingsRef} onSave={handleSave} />
|
||||
</Dialog.Content>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
<iframe
|
||||
bind:this={iframeRef}
|
||||
title="Preview {language}"
|
||||
sandbox="allow-scripts"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
class="code-preview-iframe"
|
||||
></iframe>
|
||||
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import type { Component } from 'svelte';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -29,7 +30,7 @@
|
|||
}: Props = $props();
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
onConfirm();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
<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 } from '$lib/utils';
|
||||
import { MimeTypePrefix, MimeTypeIncludes } from '$lib/enums';
|
||||
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 isCode(mimeType?: string, uri?: string): boolean {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
return (
|
||||
mime.includes(MimeTypeIncludes.JSON) ||
|
||||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
|
||||
mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
|
||||
/\.(js|ts|json|yaml|yml|xml|html|css|py|rs|go|java|cpp|c|h|rb|sh|toml)$/i.test(u)
|
||||
);
|
||||
}
|
||||
|
||||
function isImage(mimeType?: string, uri?: string): boolean {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
return mime.startsWith(MimeTypePrefix.IMAGE) || /\.(png|jpg|jpeg|gif|svg|webp)$/i.test(u);
|
||||
}
|
||||
|
||||
function getLanguage(): string {
|
||||
if (extra.mimeType?.includes(MimeTypeIncludes.JSON)) return 'json';
|
||||
if (extra.mimeType?.includes(MimeTypeIncludes.JAVASCRIPT)) return 'javascript';
|
||||
if (extra.mimeType?.includes(MimeTypeIncludes.TYPESCRIPT)) return 'typescript';
|
||||
// Try to detect from URI/name
|
||||
const name = extra.name || extra.uri || '';
|
||||
return getLanguageFromFilename(name) || 'plaintext';
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!extra.content) return;
|
||||
|
||||
const blob = new Blob([extra.content], { type: extra.mimeType || 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = extra.name || 'resource.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</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 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 isImage(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 isCode(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,211 @@
|
|||
<script lang="ts">
|
||||
import { FolderOpen, Plus, Loader2 } 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 } from '$lib/stores/mcp-resources.svelte';
|
||||
import { McpResourceBrowser, McpResourcePreview } from '$lib/components/app';
|
||||
import type { MCPResourceInfo } 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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceSelect(resource: MCPResourceInfo, shiftKey: boolean = false) {
|
||||
if (shiftKey && lastSelectedUri) {
|
||||
const allResources = getAllResourcesFlat();
|
||||
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) {
|
||||
if (checked) {
|
||||
selectedResources.add(resource.uri);
|
||||
} else {
|
||||
selectedResources.delete(resource.uri);
|
||||
}
|
||||
|
||||
lastSelectedUri = resource.uri;
|
||||
}
|
||||
|
||||
function getAllResourcesFlat(): 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;
|
||||
}
|
||||
|
||||
async function handleAttach() {
|
||||
if (selectedResources.size === 0) return;
|
||||
|
||||
isAttaching = true;
|
||||
|
||||
try {
|
||||
const allResources = getAllResourcesFlat();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickAttach(resource: MCPResourceInfo) {
|
||||
isAttaching = true;
|
||||
|
||||
try {
|
||||
await mcpStore.attachResource(resource.uri);
|
||||
|
||||
onAttach?.(resource);
|
||||
|
||||
toast.success(`Resource attached: ${resource.name}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to attach resource:', error);
|
||||
} finally {
|
||||
isAttaching = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="max-h-[80vh] !max-w-4xl overflow-hidden p-0">
|
||||
<Dialog.Header class="border-b 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]">
|
||||
<div class="w-72 shrink-0 overflow-y-auto border-r p-4">
|
||||
<McpResourceBrowser
|
||||
onSelect={handleResourceSelect}
|
||||
onToggle={handleResourceToggle}
|
||||
onAttach={handleQuickAttach}
|
||||
selectedUris={selectedResources}
|
||||
expandToUri={preSelectedUri}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if selectedResources.size === 1}
|
||||
{@const allResources = getAllResourcesFlat()}
|
||||
{@const selectedResource = allResources.find((r) => selectedResources.has(r.uri))}
|
||||
|
||||
<McpResourcePreview resource={selectedResource ?? null} />
|
||||
{:else if selectedResources.size > 1}
|
||||
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{selectedResources.size} resources selected
|
||||
</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 px-6 py-4">
|
||||
<Button variant="outline" onclick={() => handleOpenChange(false)}>Cancel</Button>
|
||||
|
||||
<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>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { McpLogo, McpServersSettings } from '$lib/components/app';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
|
||||
interface Props {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let { onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
mcpStore.runHealthChecksForServers(mcpStore.getServers());
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { BadgeModality, CopyToClipboardIcon } from '$lib/components/app';
|
||||
import { BadgeModality, ActionIconCopyToClipboard } from '$lib/components/app';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
|
||||
import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
{modelName}
|
||||
</span>
|
||||
|
||||
<CopyToClipboardIcon
|
||||
<ActionIconCopyToClipboard
|
||||
text={modelName || ''}
|
||||
canCopy={!!modelName}
|
||||
ariaLabel="Copy model name to clipboard"
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
{serverProps.model_path}
|
||||
</span>
|
||||
|
||||
<CopyToClipboardIcon
|
||||
<ActionIconCopyToClipboard
|
||||
text={serverProps.model_path}
|
||||
ariaLabel="Copy model path to clipboard"
|
||||
/>
|
||||
|
|
@ -105,12 +105,21 @@
|
|||
</Table.Row>
|
||||
|
||||
<!-- Context Size -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
|
||||
<Table.Cell
|
||||
>{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{#if serverProps?.default_generation_settings?.n_ctx}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
|
||||
<Table.Cell
|
||||
>{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium text-red-500"
|
||||
>Context Size</Table.Cell
|
||||
>
|
||||
<Table.Cell class="text-red-500">Not available</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Training Context -->
|
||||
{#if modelMeta?.n_ctx_train}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,499 @@
|
|||
/**
|
||||
*
|
||||
* DIALOGS
|
||||
*
|
||||
* Modal dialog components for the chat application.
|
||||
*
|
||||
* All dialogs use ShadCN Dialog or AlertDialog components for consistent
|
||||
* styling, accessibility, and animation. They integrate with application
|
||||
* stores for state management and data access.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* SETTINGS DIALOGS
|
||||
*
|
||||
* Dialogs for application and server configuration.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **DialogChatSettings** - Settings dialog wrapper
|
||||
*
|
||||
* Modal dialog containing ChatSettings component with proper
|
||||
* open/close state management and automatic form reset on open.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Wraps ChatSettings component in ShadCN Dialog
|
||||
* - Manages open/close state via bindable `open` prop
|
||||
* - Resets form state when dialog opens to discard unsaved changes
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogChatSettings bind:open={showSettings} />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogChatSettings } from './DialogChatSettings.svelte';
|
||||
|
||||
/**
|
||||
* **DialogMcpServersSettings** - MCP servers configuration dialog
|
||||
*
|
||||
* Full-screen dialog for managing MCP Server
|
||||
* connections with add/edit/delete capabilities and health monitoring.
|
||||
* Opened from McpActiveServerAvatars or ChatFormActionAttachentsDropdown.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN Dialog
|
||||
* - Integrates with mcpStore for server CRUD operations
|
||||
* - Manages connection state and health checks
|
||||
*
|
||||
* **Features:**
|
||||
* - Add new MCP servers by URL with validation
|
||||
* - Edit existing server configuration (URL, headers)
|
||||
* - Delete servers with confirmation dialog
|
||||
* - Health check status indicators (connected/disconnected/error)
|
||||
* - Connection logs display for debugging
|
||||
* - Tools list per server showing available capabilities
|
||||
* - Enable/disable servers per conversation
|
||||
* - Custom HTTP headers support
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogMcpServersSettings bind:open={showMcpSettings} />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogMcpServersSettings } from './DialogMcpServersSettings.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* CONFIRMATION DIALOGS
|
||||
*
|
||||
* Dialogs for user action confirmations. Use AlertDialog for blocking
|
||||
* confirmations that require explicit user decision before proceeding.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **DialogConfirmation** - Generic confirmation dialog
|
||||
*
|
||||
* Reusable confirmation dialog with customizable title, description,
|
||||
* and action buttons. Supports destructive action styling and custom icons.
|
||||
* Used for delete confirmations, irreversible actions, and important decisions.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN AlertDialog
|
||||
* - Supports variant styling (default, destructive)
|
||||
* - Customizable button labels and callbacks
|
||||
*
|
||||
* **Features:**
|
||||
* - Customizable title and description text
|
||||
* - Destructive variant with red styling for dangerous actions
|
||||
* - Custom icon support in header
|
||||
* - Cancel and confirm button callbacks
|
||||
* - Keyboard accessible (Escape to cancel, Enter to confirm)
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogConfirmation
|
||||
* bind:open={showDelete}
|
||||
* title="Delete conversation?"
|
||||
* description="This action cannot be undone."
|
||||
* variant="destructive"
|
||||
* onConfirm={handleDelete}
|
||||
* onCancel={() => showDelete = false}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogConfirmation } from './DialogConfirmation.svelte';
|
||||
|
||||
/**
|
||||
* **DialogConversationTitleUpdate** - Conversation rename confirmation
|
||||
*
|
||||
* Confirmation dialog shown when editing the first user message in a conversation.
|
||||
* Asks user whether to update the conversation title to match the new message content.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN AlertDialog
|
||||
* - Shows current vs proposed title comparison
|
||||
* - Triggered by ChatMessages when first message is edited
|
||||
*
|
||||
* **Features:**
|
||||
* - Side-by-side display of current and new title
|
||||
* - "Keep Current Title" and "Update Title" action buttons
|
||||
* - Styled title previews in muted background boxes
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogConversationTitleUpdate
|
||||
* bind:open={showTitleUpdate}
|
||||
* currentTitle={conversation.name}
|
||||
* newTitle={truncatedMessageContent}
|
||||
* onConfirm={updateTitle}
|
||||
* onCancel={() => showTitleUpdate = false}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogConversationTitleUpdate } from './DialogConversationTitleUpdate.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* CONTENT PREVIEW DIALOGS
|
||||
*
|
||||
* Dialogs for previewing and displaying content in full-screen or modal views.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **DialogCodePreview** - Full-screen code/HTML preview
|
||||
*
|
||||
* Full-screen dialog for previewing HTML or code in an isolated iframe.
|
||||
* Used by MarkdownContent component for previewing rendered HTML blocks
|
||||
* from code blocks in chat messages.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN Dialog with full viewport layout
|
||||
* - Sandboxed iframe execution (allow-scripts only)
|
||||
* - Clears content when closed for security
|
||||
*
|
||||
* **Features:**
|
||||
* - Full viewport iframe preview
|
||||
* - Sandboxed execution environment
|
||||
* - Close button with mix-blend-difference for visibility over any content
|
||||
* - Automatic content cleanup on close
|
||||
* - Supports HTML preview with proper isolation
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogCodePreview
|
||||
* bind:open={showPreview}
|
||||
* code={htmlContent}
|
||||
* language="html"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogCodePreview } from './DialogCodePreview.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* ATTACHMENT DIALOGS
|
||||
*
|
||||
* Dialogs for viewing and managing file attachments. Support both
|
||||
* uploaded files (pending) and stored attachments (in messages).
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **DialogChatAttachmentPreview** - Full-size attachment preview
|
||||
*
|
||||
* Modal dialog for viewing file attachments at full size. Supports different
|
||||
* file types with appropriate preview modes: images, text files, PDFs, and audio.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Wraps ChatAttachmentPreview component in ShadCN Dialog
|
||||
* - Accepts either uploaded file or stored attachment as data source
|
||||
* - Resets preview state when dialog opens
|
||||
*
|
||||
* **Features:**
|
||||
* - Full-size image display with proper scaling
|
||||
* - Text file content with syntax highlighting
|
||||
* - PDF preview with text/image view toggle
|
||||
* - Audio file placeholder with download option
|
||||
* - File name and size display in header
|
||||
* - Download button for all file types
|
||||
* - Vision modality check for image attachments
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <!-- Preview uploaded file -->
|
||||
* <DialogChatAttachmentPreview
|
||||
* bind:open={showPreview}
|
||||
* uploadedFile={selectedFile}
|
||||
* activeModelId={currentModel}
|
||||
* />
|
||||
*
|
||||
* <!-- Preview stored attachment -->
|
||||
* <DialogChatAttachmentPreview
|
||||
* bind:open={showPreview}
|
||||
* attachment={selectedAttachment}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogChatAttachmentPreview } from './DialogChatAttachmentPreview.svelte';
|
||||
|
||||
/**
|
||||
* **DialogChatAttachmentsViewAll** - Grid view of all attachments
|
||||
*
|
||||
* Dialog showing all attachments in a responsive grid layout. Triggered by
|
||||
* "+X more" button in ChatAttachmentsList when there are too many attachments
|
||||
* to display inline.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Wraps ChatAttachmentsViewAll component in ShadCN Dialog
|
||||
* - Supports both readonly (message view) and editable (form) modes
|
||||
* - Displays total attachment count in header
|
||||
*
|
||||
* **Features:**
|
||||
* - Responsive grid layout for all attachments
|
||||
* - Thumbnail previews with click-to-expand
|
||||
* - Remove button in editable mode
|
||||
* - Configurable thumbnail dimensions
|
||||
* - Vision modality validation for images
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogChatAttachmentsViewAll
|
||||
* bind:open={showAllAttachments}
|
||||
* attachments={message.extra}
|
||||
* readonly
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogChatAttachmentsViewAll } from './DialogChatAttachmentsViewAll.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* ERROR & ALERT DIALOGS
|
||||
*
|
||||
* Dialogs for displaying errors, warnings, and alerts to users.
|
||||
* Provide context about what went wrong and recovery options.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **DialogChatError** - Chat/generation error display
|
||||
*
|
||||
* Alert dialog for displaying chat and generation errors with context
|
||||
* information. Supports different error types with appropriate styling
|
||||
* and messaging.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN AlertDialog for modal display
|
||||
* - Differentiates between timeout and server errors
|
||||
* - Shows context info when available (token counts)
|
||||
*
|
||||
* **Error Types:**
|
||||
* - **timeout**: TCP timeout with timer icon, red destructive styling
|
||||
* - **server**: Server error with warning icon, amber warning styling
|
||||
*
|
||||
* **Features:**
|
||||
* - Type-specific icons (TimerOff for timeout, AlertTriangle for server)
|
||||
* - Error message display in styled badge
|
||||
* - Context info showing prompt tokens and context size
|
||||
* - Close button to dismiss
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogChatError
|
||||
* bind:open={showError}
|
||||
* type="server"
|
||||
* message={errorMessage}
|
||||
* contextInfo={{ n_prompt_tokens: 1024, n_ctx: 4096 }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogChatError } from './DialogChatError.svelte';
|
||||
|
||||
/**
|
||||
* **DialogEmptyFileAlert** - Empty file upload warning
|
||||
*
|
||||
* Alert dialog shown when user attempts to upload empty files. Lists the
|
||||
* empty files that were detected and removed from attachments, with
|
||||
* explanation of why empty files cannot be processed.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN AlertDialog for modal display
|
||||
* - Receives list of empty file names from ChatScreen
|
||||
* - Triggered during file upload validation
|
||||
*
|
||||
* **Features:**
|
||||
* - FileX icon indicating file error
|
||||
* - List of empty file names in monospace font
|
||||
* - Explanation of what happened and why
|
||||
* - Single "Got it" dismiss button
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogEmptyFileAlert
|
||||
* bind:open={showEmptyAlert}
|
||||
* emptyFiles={['empty.txt', 'blank.md']}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogEmptyFileAlert } from './DialogEmptyFileAlert.svelte';
|
||||
|
||||
/**
|
||||
* **DialogModelNotAvailable** - Model unavailable error
|
||||
*
|
||||
* Alert dialog shown when the requested model (from URL params or selection)
|
||||
* is not available on the server. Displays the requested model name and
|
||||
* offers selection from available models.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN AlertDialog for modal display
|
||||
* - Integrates with SvelteKit navigation for model switching
|
||||
* - Receives available models list from modelsStore
|
||||
*
|
||||
* **Features:**
|
||||
* - Warning icon with amber styling
|
||||
* - Requested model name display in styled badge
|
||||
* - Scrollable list of available models
|
||||
* - Click model to navigate with updated URL params
|
||||
* - Cancel button to dismiss without selection
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogModelNotAvailable
|
||||
* bind:open={showModelError}
|
||||
* modelName={requestedModel}
|
||||
* availableModels={modelsList}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogModelNotAvailable } from './DialogModelNotAvailable.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* DATA MANAGEMENT DIALOGS
|
||||
*
|
||||
* Dialogs for managing conversation data, including import/export
|
||||
* and selection operations.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **DialogConversationSelection** - Conversation picker for import/export
|
||||
*
|
||||
* Dialog for selecting conversations during import or export operations.
|
||||
* Displays list of conversations with checkboxes for multi-selection.
|
||||
* Used by ChatSettingsImportExportTab for data management.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Wraps ConversationSelection component in ShadCN Dialog
|
||||
* - Supports export mode (select from local) and import mode (select from file)
|
||||
* - Resets selection state when dialog opens
|
||||
* - High z-index to appear above settings dialog
|
||||
*
|
||||
* **Features:**
|
||||
* - Multi-select with checkboxes
|
||||
* - Conversation title and message count display
|
||||
* - Select all / deselect all controls
|
||||
* - Mode-specific descriptions (export vs import)
|
||||
* - Cancel and confirm callbacks with selected conversations
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogConversationSelection
|
||||
* bind:open={showExportSelection}
|
||||
* conversations={allConversations}
|
||||
* messageCountMap={messageCounts}
|
||||
* mode="export"
|
||||
* onConfirm={handleExport}
|
||||
* onCancel={() => showExportSelection = false}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogConversationSelection } from './DialogConversationSelection.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* MODEL INFORMATION DIALOGS
|
||||
*
|
||||
* Dialogs for displaying model and server information.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **DialogModelInformation** - Model details display
|
||||
*
|
||||
* Dialog showing comprehensive information about the currently loaded model
|
||||
* and server configuration. Displays model metadata, capabilities, and
|
||||
* server settings in a structured table format.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN Dialog with wide layout for table display
|
||||
* - Fetches data from serverStore (props) and modelsStore (metadata)
|
||||
* - Auto-fetches models when dialog opens if not loaded
|
||||
*
|
||||
* **Information Displayed:**
|
||||
* - **Model**: Name with copy button
|
||||
* - **File Path**: Full path to model file with copy button
|
||||
* - **Context Size**: Current context window size
|
||||
* - **Training Context**: Original training context (if available)
|
||||
* - **Model Size**: File size in human-readable format
|
||||
* - **Parameters**: Parameter count (e.g., "7B", "70B")
|
||||
* - **Embedding Size**: Embedding dimension
|
||||
* - **Vocabulary Size**: Token vocabulary size
|
||||
* - **Vocabulary Type**: Tokenizer type (BPE, etc.)
|
||||
* - **Parallel Slots**: Number of concurrent request slots
|
||||
* - **Modalities**: Supported input types (text, vision, audio)
|
||||
* - **Build Info**: Server build information
|
||||
* - **Chat Template**: Full Jinja template in scrollable code block
|
||||
*
|
||||
* **Features:**
|
||||
* - Copy buttons for model name and path
|
||||
* - Modality badges with icons
|
||||
* - Responsive table layout with container queries
|
||||
* - Loading state while fetching model info
|
||||
* - Scrollable chat template display
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogModelInformation bind:open={showModelInfo} />
|
||||
* ```
|
||||
*/
|
||||
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,110 @@
|
|||
<script lang="ts">
|
||||
import { Plus, Trash2 } from '@lucide/svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { autoResizeTextarea } from '$lib/utils';
|
||||
import type { KeyValuePair } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
pairs: KeyValuePair[];
|
||||
onPairsChange: (pairs: KeyValuePair[]) => void;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
addButtonLabel?: string;
|
||||
emptyMessage?: string;
|
||||
sectionLabel?: string;
|
||||
sectionLabelOptional?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
pairs,
|
||||
onPairsChange,
|
||||
keyPlaceholder = 'Key',
|
||||
valuePlaceholder = 'Value',
|
||||
addButtonLabel = 'Add',
|
||||
emptyMessage = 'No items configured.',
|
||||
sectionLabel,
|
||||
sectionLabelOptional = true
|
||||
}: Props = $props();
|
||||
|
||||
function addPair() {
|
||||
onPairsChange([...pairs, { key: '', value: '' }]);
|
||||
}
|
||||
|
||||
function removePair(index: number) {
|
||||
onPairsChange(pairs.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function updatePairKey(index: number, key: string) {
|
||||
const newPairs = [...pairs];
|
||||
newPairs[index] = { ...newPairs[index], key };
|
||||
onPairsChange(newPairs);
|
||||
}
|
||||
|
||||
function updatePairValue(index: number, value: string) {
|
||||
const newPairs = [...pairs];
|
||||
newPairs[index] = { ...newPairs[index], value };
|
||||
onPairsChange(newPairs);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
{#if sectionLabel}
|
||||
<span class="text-xs font-medium">
|
||||
{sectionLabel}
|
||||
{#if sectionLabelOptional}
|
||||
<span class="text-muted-foreground">(optional)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onclick={addPair}
|
||||
>
|
||||
<Plus class="h-3 w-3" />
|
||||
{addButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
{#if pairs.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each pairs as pair, index (index)}
|
||||
<div class="flex items-start gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={keyPlaceholder}
|
||||
value={pair.key}
|
||||
oninput={(e) => updatePairKey(index, e.currentTarget.value)}
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
use:autoResizeTextarea
|
||||
placeholder={valuePlaceholder}
|
||||
value={pair.value}
|
||||
oninput={(e) => {
|
||||
updatePairValue(index, e.currentTarget.value);
|
||||
autoResizeTextarea(e.currentTarget);
|
||||
}}
|
||||
class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm leading-5 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
|
||||
rows="1"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1.5 shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onclick={() => removePair(index)}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">{emptyMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
|
||||
<div class="relative {className}">
|
||||
<Search
|
||||
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
|
||||
class="absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
|
||||
/>
|
||||
|
||||
<Input
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
*
|
||||
* FORMS & INPUTS
|
||||
*
|
||||
* Form-related utility components.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **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';
|
||||
|
||||
/**
|
||||
* **KeyValuePairs** - Editable key-value list
|
||||
*
|
||||
* Dynamic list of key-value pairs with add/remove functionality.
|
||||
* Used for HTTP headers, metadata, and configuration.
|
||||
*
|
||||
* **Features:**
|
||||
* - Add new pairs with button
|
||||
* - Remove individual pairs
|
||||
* - Customizable placeholders and labels
|
||||
* - Empty state message
|
||||
* - Auto-resize value textarea
|
||||
*/
|
||||
export { default as KeyValuePairs } from './KeyValuePairs.svelte';
|
||||
|
|
@ -1,75 +1,11 @@
|
|||
// Chat
|
||||
|
||||
export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
|
||||
export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
|
||||
export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
|
||||
export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
|
||||
export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
|
||||
|
||||
export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
|
||||
export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
|
||||
export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
|
||||
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
|
||||
export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
|
||||
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
|
||||
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
|
||||
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
|
||||
|
||||
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
|
||||
export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
|
||||
export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
|
||||
export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
|
||||
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
|
||||
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
|
||||
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
|
||||
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
|
||||
|
||||
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
|
||||
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
|
||||
export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
|
||||
|
||||
export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
|
||||
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
|
||||
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
|
||||
export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
|
||||
export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
|
||||
|
||||
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
|
||||
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
|
||||
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
|
||||
|
||||
// Dialogs
|
||||
|
||||
export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
|
||||
export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
|
||||
export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
|
||||
export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte';
|
||||
export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
|
||||
export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
|
||||
export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
|
||||
export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
|
||||
export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
|
||||
export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
|
||||
|
||||
// Miscellanous
|
||||
|
||||
export { default as ActionButton } from './misc/ActionButton.svelte';
|
||||
export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
|
||||
export { default as BadgeChatStatistic } from './misc/BadgeChatStatistic.svelte';
|
||||
export { default as BadgeInfo } from './misc/BadgeInfo.svelte';
|
||||
export { default as ModelBadge } from './models/ModelBadge.svelte';
|
||||
export { default as BadgeModality } from './misc/BadgeModality.svelte';
|
||||
export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
|
||||
export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelte';
|
||||
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
|
||||
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
|
||||
export { default as RemoveButton } from './misc/RemoveButton.svelte';
|
||||
export { default as SearchInput } from './misc/SearchInput.svelte';
|
||||
export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
|
||||
export { default as ModelsSelector } from './models/ModelsSelector.svelte';
|
||||
|
||||
// Server
|
||||
|
||||
export { default as ServerStatus } from './server/ServerStatus.svelte';
|
||||
export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
|
||||
export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
|
||||
export * from './actions';
|
||||
export * from './badges';
|
||||
export * from './chat';
|
||||
export * from './content';
|
||||
export * from './dialogs';
|
||||
export * from './forms';
|
||||
export * from './mcp';
|
||||
export * from './misc';
|
||||
export * from './models';
|
||||
export * from './navigation';
|
||||
export * from './server';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
<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 { getFaviconUrl } from '$lib/utils';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
|
||||
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 - 3));
|
||||
let mcpFavicons = $derived(
|
||||
healthyEnabledMcpServers
|
||||
.slice(0, 3)
|
||||
.map((s) => ({ id: s.id, url: getFaviconUrl(s.url) }))
|
||||
.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 = $state(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,108 @@
|
|||
<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 { MCPResourceInfo } from '$lib/types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { parseResourcePath } from './mcp-resource-browser';
|
||||
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;
|
||||
onAttach?: (resource: MCPResourceInfo) => void;
|
||||
selectedUris?: Set<string>;
|
||||
expandToUri?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onSelect,
|
||||
onToggle,
|
||||
onAttach,
|
||||
selectedUris = new Set(),
|
||||
expandToUri,
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
let expandedServers = new SvelteSet<string>();
|
||||
let expandedFolders = new SvelteSet<string>();
|
||||
let lastExpandedUri = $state<string | undefined>(undefined);
|
||||
|
||||
const resources = $derived(mcpResources());
|
||||
const isLoading = $derived(mcpResourcesLoading());
|
||||
|
||||
$effect(() => {
|
||||
if (expandToUri && resources.size > 0 && expandToUri !== lastExpandedUri) {
|
||||
autoExpandToResource(expandToUri);
|
||||
lastExpandedUri = 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} />
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if resources.size === 0}
|
||||
<McpResourceBrowserEmptyState {isLoading} />
|
||||
{:else}
|
||||
{#each [...resources.entries()] as [serverName, serverRes] (serverName)}
|
||||
<McpResourceBrowserServerItem
|
||||
{serverName}
|
||||
{serverRes}
|
||||
isExpanded={expandedServers.has(serverName)}
|
||||
{selectedUris}
|
||||
{expandedFolders}
|
||||
onToggleServer={() => toggleServer(serverName)}
|
||||
onToggleFolder={toggleFolder}
|
||||
{onSelect}
|
||||
{onToggle}
|
||||
{onAttach}
|
||||
/>
|
||||
{/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,30 @@
|
|||
<script lang="ts">
|
||||
import { RefreshCw, Loader2 } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
let { isLoading, onRefresh }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">Available resources</h3>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={onRefresh}
|
||||
disabled={isLoading}
|
||||
title="Refresh resources"
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{:else}
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
<script lang="ts">
|
||||
import { FolderOpen, ChevronDown, ChevronRight, Loader2 } from '@lucide/svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { MCPResourceInfo, MCPServerResources } from '$lib/types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import {
|
||||
type ResourceTreeNode,
|
||||
buildResourceTree,
|
||||
countTreeResources,
|
||||
getDisplayName,
|
||||
getResourceIcon,
|
||||
sortTreeChildren
|
||||
} from './mcp-resource-browser';
|
||||
|
||||
interface Props {
|
||||
serverName: string;
|
||||
serverRes: MCPServerResources;
|
||||
isExpanded: boolean;
|
||||
selectedUris: Set<string>;
|
||||
expandedFolders: SvelteSet<string>;
|
||||
onToggleServer: () => void;
|
||||
onToggleFolder: (folderId: string) => void;
|
||||
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
|
||||
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
|
||||
onAttach?: (resource: MCPResourceInfo) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
serverName,
|
||||
serverRes,
|
||||
isExpanded,
|
||||
selectedUris,
|
||||
expandedFolders,
|
||||
onToggleServer,
|
||||
onToggleFolder,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onAttach
|
||||
}: Props = $props();
|
||||
|
||||
const hasResources = $derived(serverRes.resources.length > 0);
|
||||
const displayName = $derived(mcpStore.getServerDisplayName(serverName));
|
||||
const favicon = $derived(mcpStore.getServerFavicon(serverName));
|
||||
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName));
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
|
||||
onSelect?.(resource, event.shiftKey);
|
||||
}
|
||||
|
||||
function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
|
||||
onToggle?.(resource, checked);
|
||||
}
|
||||
|
||||
function handleAttachClick(e: Event, resource: MCPResourceInfo) {
|
||||
e.stopPropagation();
|
||||
onAttach?.(resource);
|
||||
}
|
||||
|
||||
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)}
|
||||
{@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>
|
||||
{#if onAttach}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 px-1.5 text-xs opacity-0 transition-opacity group-hover:opacity-100 hover:opacity-100"
|
||||
onclick={(e: MouseEvent) => handleAttachClick(e, resource)}
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
{/if}
|
||||
</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}
|
||||
{#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}
|
||||
<span class="font-medium">{displayName}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({serverRes.resources.length})
|
||||
</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 !hasResources}
|
||||
<div class="py-1 text-xs text-muted-foreground">No resources</div>
|
||||
{:else}
|
||||
{#each sortTreeChildren( [...resourceTree.children.values()] ) as child (child.resource?.uri || `${serverName}:${child.name}`)}
|
||||
{@render renderTreeNode(child, 1, '')}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { Database, File, FileText, Image, Code } from '@lucide/svelte';
|
||||
import type { MCPResource, MCPResourceInfo } from '$lib/types';
|
||||
|
||||
export interface ResourceTreeNode {
|
||||
name: string;
|
||||
resource?: MCPResourceInfo;
|
||||
children: Map<string, ResourceTreeNode>;
|
||||
}
|
||||
|
||||
export function parseResourcePath(uri: string): string[] {
|
||||
try {
|
||||
const withoutProtocol = uri.replace(/^[a-z]+:\/\//, '');
|
||||
return withoutProtocol.split('/').filter((p) => p.length > 0);
|
||||
} catch {
|
||||
return [uri];
|
||||
}
|
||||
}
|
||||
|
||||
export function getDisplayName(pathPart: string): string {
|
||||
const withoutExt = pathPart.replace(/\.[^.]+$/, '');
|
||||
return withoutExt
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function buildResourceTree(
|
||||
resourceList: MCPResource[],
|
||||
serverName: string
|
||||
): ResourceTreeNode {
|
||||
const root: ResourceTreeNode = { name: 'root', children: new Map() };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 getResourceIcon(resource: MCPResourceInfo) {
|
||||
const mimeType = resource.mimeType?.toLowerCase() || '';
|
||||
const uri = resource.uri.toLowerCase();
|
||||
|
||||
if (mimeType.startsWith('image/') || /\.(png|jpg|jpeg|gif|svg|webp)$/.test(uri)) {
|
||||
return Image;
|
||||
}
|
||||
if (
|
||||
mimeType.includes('json') ||
|
||||
mimeType.includes('javascript') ||
|
||||
mimeType.includes('typescript') ||
|
||||
/\.(js|ts|json|yaml|yml|xml|html|css)$/.test(uri)
|
||||
) {
|
||||
return Code;
|
||||
}
|
||||
if (mimeType.includes('text') || /\.(txt|md|log)$/.test(uri)) {
|
||||
return FileText;
|
||||
}
|
||||
if (uri.includes('database') || uri.includes('db://')) {
|
||||
return Database;
|
||||
}
|
||||
return File;
|
||||
}
|
||||
|
||||
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,181 @@
|
|||
<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 } from '$lib/utils';
|
||||
import { MimeTypeApplication } from '$lib/enums';
|
||||
import { ActionIconCopyToClipboard } from '$lib/components/app';
|
||||
import type { MCPResourceInfo, MCPResourceContent } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
resource: MCPResourceInfo | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { resource, class: className }: Props = $props();
|
||||
|
||||
let content = $state<MCPResourceContent[] | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (resource) {
|
||||
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 getTextContent(): string {
|
||||
if (!content) return '';
|
||||
return content
|
||||
.filter((c): c is { uri: string; mimeType?: string; text: string } => 'text' in c)
|
||||
.map((c) => c.text)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function getBlobContent(): Array<{ uri: string; mimeType?: string; blob: string }> {
|
||||
if (!content) return [];
|
||||
return content.filter(
|
||||
(c): c is { uri: string; mimeType?: string; blob: string } => 'blob' in c
|
||||
);
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const text = getTextContent();
|
||||
if (!text || !resource) return;
|
||||
|
||||
const blob = new Blob([text], { type: resource.mimeType || 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = resource.name || 'resource.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</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={getTextContent()}
|
||||
canCopy={!isLoading && !!getTextContent()}
|
||||
ariaLabel="Copy content"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={handleDownload}
|
||||
disabled={isLoading || !getTextContent()}
|
||||
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">
|
||||
{#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 = getTextContent()}
|
||||
{@const blobContent = getBlobContent()}
|
||||
|
||||
{#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,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 = $state(!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 = $state(serverUrl);
|
||||
let editHeaders = $state('');
|
||||
let editUseProxy = $state(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,111 @@
|
|||
<script lang="ts">
|
||||
import { Cable, ExternalLink, Globe, Zap, Radio } from '@lucide/svelte';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
|
||||
import { MCPTransportType } from '$lib/enums';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { McpCapabilitiesBadges } from '$lib/components/app/mcp';
|
||||
|
||||
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();
|
||||
|
||||
const transportLabels: Record<MCPTransportType, string> = {
|
||||
[MCPTransportType.WEBSOCKET]: 'WebSocket',
|
||||
[MCPTransportType.STREAMABLE_HTTP]: 'HTTP',
|
||||
[MCPTransportType.SSE]: 'SSE'
|
||||
};
|
||||
|
||||
const transportIcons: Record<
|
||||
MCPTransportType,
|
||||
typeof Cable | typeof Zap | typeof Globe | typeof Radio
|
||||
> = {
|
||||
[MCPTransportType.WEBSOCKET]: Zap,
|
||||
[MCPTransportType.STREAMABLE_HTTP]: Globe,
|
||||
[MCPTransportType.SSE]: Radio
|
||||
};
|
||||
</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 = transportIcons[transportType]}
|
||||
<Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">
|
||||
{#if TransportIcon}
|
||||
<TransportIcon class="h-3 w-3" />
|
||||
{/if}
|
||||
|
||||
{transportLabels[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 { UrlPrefix } from '$lib/enums';
|
||||
import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants/mcp-form';
|
||||
|
||||
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(UrlPrefix.WEBSOCKET) ||
|
||||
url.toLowerCase().startsWith(UrlPrefix.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={true}
|
||||
/>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue