Smithery Catalog

Lists Smithery `local` MCP servers.
This commit is contained in:
Matte Noble
2025-05-02 15:49:08 -07:00
parent e5b0186ca6
commit c2452d79be
67 changed files with 2307 additions and 604 deletions

View File

@@ -32,6 +32,7 @@
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"json-schema": "^0.4.0",
"postcss": "^8.5.3",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",

8
pnpm-lock.yaml generated
View File

@@ -90,6 +90,9 @@ importers:
globals:
specifier: ^16.0.0
version: 16.0.0
json-schema:
specifier: ^0.4.0
version: 0.4.0
postcss:
specifier: ^8.5.3
version: 8.5.3
@@ -1424,6 +1427,9 @@ packages:
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -3382,6 +3388,8 @@ snapshots:
json-schema-traverse@0.4.1: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:

324
src-tauri/Cargo.lock generated
View File

@@ -15,6 +15,7 @@ dependencies = [
"log-panics",
"markdown",
"pin-project-lite",
"reqwest",
"rmcp",
"serde",
"serde_json",
@@ -666,9 +667,9 @@ dependencies = [
"bitflags 2.9.0",
"block",
"cocoa-foundation",
"core-foundation",
"core-foundation 0.10.0",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"libc",
"objc",
]
@@ -681,7 +682,7 @@ checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d"
dependencies = [
"bitflags 2.9.0",
"block",
"core-foundation",
"core-foundation 0.10.0",
"core-graphics-types",
"libc",
"objc",
@@ -748,6 +749,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.0"
@@ -771,9 +782,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.9.0",
"core-foundation",
"core-foundation 0.10.0",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -784,7 +795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.9.0",
"core-foundation",
"core-foundation 0.10.0",
"libc",
]
@@ -1126,6 +1137,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.0"
@@ -1288,6 +1308,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1295,7 +1324,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1309,6 +1338,12 @@ dependencies = [
"syn 2.0.99",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1761,6 +1796,25 @@ dependencies = [
"syn 2.0.99",
]
[[package]]
name = "h2"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75249d144030531f8dee69fe9cea04d3edf809a017ae445e2abdff6629e86633"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.7.1",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1910,6 +1964,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
@@ -1938,6 +1993,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.10"
@@ -2546,6 +2617,23 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2941,6 +3029,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
dependencies = [
"bitflags 2.9.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.99",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3610,24 +3742,28 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.12.12"
version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da"
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
@@ -3639,7 +3775,9 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -3650,7 +3788,7 @@ dependencies = [
"wasm-streams",
"web-sys",
"webpki-roots",
"windows-registry 0.2.0",
"windows-registry 0.4.0",
]
[[package]]
@@ -3886,6 +4024,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3925,6 +4072,29 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.22.0"
@@ -4210,7 +4380,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"js-sys",
"log",
"objc2 0.5.2",
@@ -4585,6 +4755,27 @@ dependencies = [
"windows 0.57.0",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4605,7 +4796,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63c8b1020610b9138dd7b1e06cf259ae91aa05c30f3bd0d6b42a03997b92dec1"
dependencies = [
"bitflags 2.9.0",
"core-foundation",
"core-foundation 0.10.0",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -5172,6 +5363,16 @@ dependencies = [
"syn 2.0.99",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.2"
@@ -5918,7 +6119,7 @@ dependencies = [
"windows-interface 0.59.0",
"windows-link",
"windows-result 0.3.1",
"windows-strings 0.3.1",
"windows-strings",
]
[[package]]
@@ -5993,13 +6194,13 @@ dependencies = [
[[package]]
name = "windows-registry"
version = "0.2.0"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets 0.52.6",
"windows-result 0.3.1",
"windows-strings",
"windows-targets 0.53.0",
]
[[package]]
@@ -6010,7 +6211,7 @@ checksum = "6c44a98275e31bfd112bb06ba96c8ab13c03383a3753fdddd715406a1824c7e0"
dependencies = [
"windows-link",
"windows-result 0.3.1",
"windows-strings 0.3.1",
"windows-strings",
]
[[package]]
@@ -6022,15 +6223,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.1"
@@ -6040,16 +6232,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.3.1"
@@ -6134,13 +6316,29 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b"
dependencies = [
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
"windows_i686_gnullvm 0.53.0",
"windows_i686_msvc 0.53.0",
"windows_x86_64_gnu 0.53.0",
"windows_x86_64_gnullvm 0.53.0",
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows-version"
version = "0.1.3"
@@ -6168,6 +6366,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -6186,6 +6390,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -6204,12 +6414,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -6228,6 +6450,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -6246,6 +6474,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -6264,6 +6498,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -6282,6 +6522,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.5.40"

View File

@@ -37,6 +37,7 @@ tauri-plugin-log = "2"
tauri-plugin-fs = "2.2.1"
pin-project-lite = "0.2.16"
tauri-plugin-opener = "2"
reqwest = "0.12.15"
[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.26.0"

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use rmcp::model::Tool;
use tauri::AppHandle;
@@ -7,21 +9,32 @@ use crate::State;
macro_rules! ok_or_err {
($expr:expr) => {
match $expr {
Ok(e) => Ok(e),
Err(_) => Err(()),
Ok(r) => Ok(r),
Err(e) => Err(e.to_string()),
}
};
}
#[tauri::command]
pub async fn get_metadata(command: String, app: AppHandle) -> Result<String, ()> {
ok_or_err!(mcp::peer_info(command, app).await)
pub async fn get_metadata(
command: String,
args: Vec<String>,
env: HashMap<String, String>,
app: AppHandle,
) -> Result<String, String> {
ok_or_err!(mcp::peer_info(command, args, env, app).await)
}
#[tauri::command]
pub async fn start_mcp_server(session_id: i32, command: String, app: AppHandle) -> Result<(), ()> {
pub async fn start_mcp_server(
session_id: i32,
command: String,
args: Vec<String>,
env: HashMap<String, String>,
app: AppHandle,
) -> Result<(), String> {
println!("-> start_mcp_server({}, {})", session_id, command);
ok_or_err!(mcp::start(session_id, command, app).await)
ok_or_err!(mcp::start(session_id, command, args, env, app).await)
}
#[tauri::command]
@@ -29,7 +42,7 @@ pub async fn stop_mcp_server(
session_id: i32,
name: String,
state: tauri::State<'_, State>,
) -> Result<(), ()> {
) -> Result<(), String> {
println!("-> stop_mcp_server({}, {})", session_id, name);
ok_or_err!(mcp::stop(session_id, name, state).await)
}
@@ -38,7 +51,7 @@ pub async fn stop_mcp_server(
pub async fn get_mcp_tools(
session_id: i32,
state: tauri::State<'_, State>,
) -> Result<Vec<Tool>, ()> {
) -> Result<Vec<Tool>, String> {
ok_or_err!(mcp::get_tools(session_id, state).await)
}
@@ -48,13 +61,13 @@ pub async fn call_mcp_tool(
name: String,
arguments: serde_json::Map<String, serde_json::Value>,
state: tauri::State<'_, State>,
) -> Result<String, ()> {
) -> Result<String, String> {
Ok(mcp::call_tool(session_id, name, arguments, state)
.await
.unwrap())
}
#[tauri::command]
pub async fn stop_session(session_id: i32, state: tauri::State<'_, State>) -> Result<(), ()> {
pub async fn stop_session(session_id: i32, state: tauri::State<'_, State>) -> Result<(), String> {
ok_or_err!(mcp::stop_session(session_id, state).await)
}

102
src-tauri/src/http.rs Normal file
View File

@@ -0,0 +1,102 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use tauri::http::{HeaderMap, HeaderName, HeaderValue};
#[derive(Deserialize)]
enum HttpMethod {
GET,
POST,
PUT,
DELETE,
HEAD,
}
#[derive(Deserialize)]
pub struct ProxyOptions {
method: HttpMethod,
body: Option<String>,
headers: Option<HashMap<String, String>>,
}
#[derive(Serialize)]
pub struct HTTPResponse {
status: u16,
status_text: String,
headers: HashMap<String, String>,
body: String,
}
#[tauri::command]
pub async fn fetch(url: String, options: ProxyOptions) -> Result<HTTPResponse, String> {
let client = reqwest::Client::new();
let request = match options.method {
HttpMethod::GET => client.get(&url),
HttpMethod::POST => client.post(&url),
HttpMethod::PUT => client.put(&url),
HttpMethod::DELETE => client.delete(&url),
HttpMethod::HEAD => client.head(&url),
};
let mut headers = HeaderMap::new();
headers.insert("Origin", "tauri://runebook.ai".parse().unwrap());
headers.insert("Content-Type", "application/json".parse().unwrap());
if let Some(h) = options.headers {
for (k, v) in h.iter() {
headers.insert(
HeaderName::from_bytes(k.as_bytes()).unwrap(),
HeaderValue::from_bytes(v.as_bytes()).unwrap(),
);
}
}
let request = request.headers(headers);
let request = if let Some(json) = options.body {
request.body(json)
} else {
request
};
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
let status = response.status().into();
let status_text = response
.status()
.canonical_reason()
.unwrap_or("")
.to_string();
let headers = response
.headers()
.iter()
.map(|(k, v)| {
(
k.to_string(),
v.to_str()
.unwrap_or("Failed to build response: invalid UTF-8 in headers")
.to_string(),
)
})
.collect::<HashMap<_, _>>();
let body = match options.method {
HttpMethod::HEAD => String::new(),
_ => response
.text()
.await
.map_err(|e| format!("Failed to build response: failed to read body: {}", e))?,
};
Ok(HTTPResponse {
status,
status_text,
headers,
body,
})
}

View File

@@ -4,6 +4,7 @@
mod commands;
mod deeplink;
mod http;
mod mcp;
mod migrations;
mod process;
@@ -73,6 +74,7 @@ fn main() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
crate::http::fetch,
// MCP
commands::get_metadata,
commands::get_mcp_tools,

View File

@@ -1,6 +1,8 @@
pub(crate) mod process;
pub(crate) mod server;
use std::collections::HashMap;
use crate::state::State;
use anyhow::Result;
@@ -41,10 +43,16 @@ pub async fn bootstrap(app: AppHandle) -> Result<()> {
// To deal with this, we collect child pids before we launch the server and child pids after it's
// launched. We track those, then explicitly kill them all when in `stop`.
//
pub async fn start(session_id: i32, command: String, app: AppHandle) -> Result<()> {
pub async fn start(
session_id: i32,
command: String,
args: Vec<String>,
env: HashMap<String, String>,
app: AppHandle,
) -> Result<()> {
let handle = app.clone();
let state = handle.state::<State>();
let server = McpServer::start(command, app).await?;
let server = McpServer::start(command, args, env, app).await?;
let mut sessions = state.sessions.lock().await;
let mut session = sessions.remove(&session_id).unwrap_or_default();
@@ -130,8 +138,13 @@ pub async fn call_tool(
server.call_tool(tool_call).await
}
pub async fn peer_info(command: String, app: AppHandle) -> Result<String> {
let server = McpServer::start(command, app).await?;
pub async fn peer_info(
command: String,
args: Vec<String>,
env: HashMap<String, String>,
app: AppHandle,
) -> Result<String> {
let server = McpServer::start(command, args, env, app).await?;
let peer_info = server.peer_info();
server.kill()?;
Ok(serde_json::to_string(&peer_info)?)

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use anyhow::{anyhow, Result};
use rmcp::service::ServiceRole;
use rmcp::transport::IntoTransport;
@@ -15,22 +17,23 @@ pub(crate) struct McpProcess {
}
impl McpProcess {
pub fn start(command: String, app: AppHandle) -> Result<Self> {
let args: Vec<&str> = command.split(" ").collect();
let main = *args.first().unwrap();
let args = args.clone().drain(1..).collect::<Vec<&str>>();
let main = match main {
pub fn start(
command: String,
args: Vec<String>,
env: HashMap<String, String>,
app: AppHandle,
) -> Result<Self> {
let command = match &*command {
"uvx" => app.path().resolve("uvx", BaseDirectory::Resource)?,
"npx" => app.path().resolve("npx", BaseDirectory::Resource)?,
s => return Err(anyhow!("{} servers not supported", s)),
};
let mut cmd = Command::new(main);
let mut cmd = Command::new(command);
let cmd = cmd.args(args);
cmd.kill_on_drop(true)
.envs(env)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped());

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::process::Process;
use anyhow::Result;
@@ -19,8 +21,13 @@ pub struct McpServer {
}
impl McpServer {
pub async fn start(command: String, app: AppHandle) -> Result<Self> {
let proc = McpProcess::start(command, app)?;
pub async fn start(
command: String,
args: Vec<String>,
env: HashMap<String, String>,
app: AppHandle,
) -> Result<Self> {
let proc = McpProcess::start(command, args, env, app)?;
let pid = proc.pid();
let service = ().serve(proc).await?;
Ok(Self { service, pid })

View File

@@ -122,5 +122,23 @@ ALTER TABLE messages ADD COLUMN response_id INTEGER REFERENCES messages(id);
"#,
kind: MigrationKind::Up,
},
Migration {
version: 8,
description: "expand_mcp_servers",
sql: r#"
ALTER TABLE mcp_servers ADD COLUMN args JSON NOT NULL DEFAULT "[]";
ALTER TABLE mcp_servers ADD COLUMN env JSON NOT NULL DEFAULT "{}";
"#,
kind: MigrationKind::Up,
},
Migration {
version: 9,
description: "add_explicit_name_to_mcp_servers",
sql: r#"
ALTER TABLE mcp_servers ADD COLUMN name TEXT NOT NULL DEFAULT "Unknown";
UPDATE mcp_servers SET name = json_extract(metadata, '$.serverInfo.name');
"#,
kind: MigrationKind::Up,
},
]
}

View File

@@ -9,7 +9,9 @@
--color-green: #c3e88d;
--color-yellow: #ffc777;
--border-color-light: #171717;
--border-color-light: #191919;
--border-color-medium: #0c0c0c;
--border-color-dark: #0b0b0b;
--background-color-light: #191919;
--background-color-medium: #0c0c0c;
@@ -38,17 +40,6 @@
src: url("/fonts/PlusJakartaSans-Italic-VariableFont_wght.ttf");
}
* {
/* Hide scrollbar */
-ms-overflow-style: none;
scrolbar-width: none;
}
/* Hide scrollbar */
*::-webkit-scrollbar {
display: none;
}
html,
body {
background: #0b0b0b;

9
src/app.d.ts vendored
View File

@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
@@ -10,16 +12,19 @@ declare global {
}
// Utility type for plain, unknown-value, JS Objects
type Obj = { [key: string]: any }; // eslint-disable-line
type Obj = { [key: string]: any };
interface ObjectConstructor {
compact<T>(o: Obj): T;
without<T>(o: Obj, keys: string[]): T;
remove<T>(o: Obj, key: string): T | undefined;
map<T>(o: Obj, fn: (key: string, value: any) => any): T;
}
interface Array<T> {
sortBy(key: string): Array<T>;
sortBy<T extends Obj>(key: string): Array<T>;
findBy<T extends Obj>(key: string, value: any): T | undefined;
compact(): Array<T>;
}
interface CheckboxEvent extends Event {

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
const { children, class: cls = '', ...rest }: HTMLButtonAttributes = $props();
</script>
<button
class={twMerge('rounded-md border p-2 px-6 text-sm hover:cursor-pointer', cls?.toString())}
{...rest}
>
{@render children?.()}
</button>

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import Scrollable from './Scrollable.svelte';
import Flex from '$components/Flex.svelte';
import Message from '$components/Message.svelte';
import { dispatch } from '$lib/dispatch';
@@ -18,6 +20,8 @@
// DOM elements used via `bind:this`
let input: HTMLTextAreaElement;
// svelte-ignore non_reactive_update
let content: HTMLDivElement;
// Full history of chat messages in this session
@@ -84,27 +88,25 @@
});
</script>
<Flex class="h-content w-full flex-col p-8 pb-0">
<!-- Chat Log -->
<div
bind:this={content}
class:opacity-25={!Model.exists(model)}
class="bg-medium relative mb-8 h-full w-full overflow-auto px-2"
>
{#each messages as message (message.id)}
<Flex class="w-full flex-col items-start">
<!-- Svelte hack: ensure chat is always scrolled to the bottom when a new message is added -->
<div use:scrollToBottom class="hidden"></div>
<Message {message} />
</Flex>
{/each}
<Flex class="h-content w-full flex-col p-8 pr-2 pb-0">
<Scrollable bind:ref={content} class="mb-8">
<!-- Chat Log -->
<div class:opacity-25={!Model.exists(model)} class="bg-medium relative h-full w-full px-2">
{#each messages as message (message.id)}
<Flex id="messages" class="w-full flex-col items-start">
<!-- Svelte hack: ensure chat is always scrolled to the bottom when a new message is added -->
<div use:scrollToBottom class="hidden"></div>
<Message {message} />
</Flex>
{/each}
{#if loading}
<Flex class="border-light h-12 w-24 rounded-lg text-center">
<div id="loading" class="m-auto"></div>
</Flex>
{/if}
</div>
{#if loading}
<Flex class="border-light h-12 w-24 rounded-lg text-center">
<div id="loading" class="m-auto"></div>
</Flex>
{/if}
</div>
</Scrollable>
<!-- Input Box -->
<textarea

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import Flex from './Flex.svelte';
const { children, ondelete } = $props();
let open = $state(false);
let width = $derived(open ? 'w-24' : 'w-0');
function onwheel(e: WheelEvent) {
if (e.deltaX < 0) {
open = true;
} else if (e.deltaX > 0) {
open = false;
}
}
</script>
<Flex {onwheel} class="h-full w-full">
<div class="grow">
{@render children?.()}
</div>
<button
onclick={ondelete}
class={`${width} bg-red transition-[width 1s linear]
h-full justify-center overflow-hidden text-xs font-semibold
tracking-wider text-white uppercase duration-150 hover:cursor-pointer`}
>
Delete
</button>
</Flex>

View File

@@ -1,10 +1,14 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
import type { HTMLAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
const { children, class: cls, ...rest }: SvelteHTMLElements['div'] = $props();
interface Props extends HTMLAttributes<HTMLDivElement> {
ref?: HTMLDivElement;
}
let { children, ref = $bindable(), class: cls, ...rest }: Props = $props();
</script>
<div class={twMerge('flex flex-row items-center', cls?.toString())} {...rest}>
<div bind:this={ref} class={twMerge('flex flex-row items-center', cls?.toString())} {...rest}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<svg
id="fi_2875045"
enable-background="new 0 0 48 48"
height="100%"
viewBox="0 0 48 48"
width="100%"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<g>
<path
d="m33.572 48c-.027 0-.054-.002-.081-.002-.707-.04-1.287-.578-1.377-1.28l-4.715-36.491-5.154 24.357c-.138.65-.696 1.129-1.362 1.163-.689.027-1.271-.384-1.473-1.018l-5.143-15.991-3.658 8.167c-.237.528-.761.868-1.341.868h-7.799c-.811.001-1.469-.656-1.469-1.466 0-.812.658-1.47 1.469-1.47h6.847l4.835-10.798c.249-.555.815-.889 1.421-.867.608.034 1.133.438 1.318 1.018l4.625 14.377 5.798-27.401c.147-.7.761-1.201 1.496-1.164.715.029 1.307.569 1.398 1.28l4.802 37.182 3.073-12.699c.158-.659.75-1.124 1.427-1.124h8.021c.812 0 1.47.658 1.47 1.469s-.658 1.47-1.47 1.47h-6.862l-4.669 19.295c-.16.662-.754 1.125-1.427 1.125z"
></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 870 B

View File

@@ -0,0 +1,31 @@
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="70.72 300.83 634.25 467.76"
width="100%"
height="100%"
>
<g>
<path
fill="#80350B"
transform="scale(0.78125 1)"
d="M398.006 343.355C407.196 334.657 416.38 324.018 425.476 314.838C428.882 311.4 432.24 307.705 436.078 304.748C439.729 301.935 442.869 301.26 447.393 301.077C458.675 300.618 470.141 301.016 481.437 301.027L545.114 301.034L741.615 301.026L847.247 301.065L875.393 300.962C880.452 300.938 885.928 300.517 890.933 301.3C894.414 301.845 897.551 303.124 899.612 306.102C901.107 308.262 901.765 310.809 901.994 313.397C902.705 321.454 901.877 330.173 901.885 338.3C901.892 345.547 903.023 372.202 901.814 377.303C901.183 379.968 899.785 382.164 897.71 383.944C893.722 387.363 888.202 389.596 883.542 391.984C871.992 397.902 859.627 403.282 848.499 409.9L848.073 410.157L738.736 465.592L695.547 487.185C687.28 491.325 678.652 495.199 670.71 499.919C664.916 518.373 660.554 537.457 655.651 556.169C653.713 563.563 652.127 571.537 649.466 578.688C653.06 584.966 667.316 597.326 673.383 603.24Q695.288 624.778 717.412 646.093C724.913 653.371 732.94 662.461 740.906 668.946C745.727 675.878 761.741 686.104 765.031 693.546C765.799 695.283 765.878 697.581 766.001 699.471C766.746 710.904 766.187 722.699 766.194 734.18C766.198 741.676 767.155 751.371 765.781 758.526C765.361 760.714 764.074 762.606 762.603 764.227C759.676 767.451 754.723 768.136 750.563 768.365C742.248 768.822 733.748 768.451 725.414 768.438L679.483 768.413L535.035 768.338L407.834 768.383L366.999 768.376C359.767 768.37 352.284 768.701 345.077 768.208C342.102 768.005 339.267 767.052 337.073 764.965C333.282 761.36 333.078 757.439 333.038 752.532C332.959 742.859 331.815 698.83 334.302 692.826C336.232 688.166 436.064 590.453 447.539 578.778Q424.034 541.238 400.079 503.983C386.56 503.13 373.142 502.198 359.735 500.21C268.635 486.703 199.609 447.05 129.303 389.52C121.593 383.211 96.6913 363.709 91.9741 357.005C90.7682 355.292 90.2229 352.751 90.6946 350.701C91.4452 347.438 94.1393 345.732 96.8104 344.108C108.413 343.721 120.084 343.954 131.697 343.946L192.547 343.9L398.006 343.355Z"
></path>
<path
fill="#EA580C"
transform="scale(0.78125 1)"
d="M446.373 302.25L830.155 302.663C836.52 302.663 890.584 302.002 892.026 302.63C896.309 304.495 897.495 305.256 900.294 308.952C900.307 318.967 902.163 372.798 900.089 378.97C899.228 381.533 895.965 383.806 893.814 385.193C886.17 390.121 877.071 393.781 868.929 397.886Q843.449 410.664 818.246 423.98L731.002 468.053C712.034 477.553 691.964 486.543 673.561 496.985C671.576 498.112 669.766 499.217 668.229 500.942C639.622 501.823 610.837 501.177 582.205 501.177L438.806 501.185L438.688 500.335C436.869 486.894 437.985 468.716 437.982 454.624L437.983 365.793C437.984 360.397 437.514 305.492 438.513 304.442C440.279 302.585 443.959 302.554 446.373 302.25Z"
></path>
<path
fill="#EA580C"
transform="scale(0.78125 1)"
d="M448.528 578.261C477.404 577.594 506.481 578.256 535.377 578.267C571.021 578.281 606.721 577.767 642.356 578.161C643.582 578.174 646.125 578.051 647.132 578.688C650.325 580.706 654.137 586.05 657.021 588.937L686.682 618.148C703.105 634.41 719.882 650.406 736.493 666.485C744.02 673.771 751.752 680.894 759.043 688.42C760.835 690.269 763.735 692.978 764.402 695.484C765.809 700.779 764.889 709.856 764.889 715.539Q764.959 737.209 764.727 758.877C763.82 760.43 762.917 762.081 761.622 763.35C760.108 764.835 757.92 766.183 755.798 766.514C747.092 767.874 733.038 766.875 723.684 766.875L655.026 766.867L654.998 695.298L640.005 680.333C635.648 675.883 631.344 671.04 626.591 667.026L528.906 667.004C510.258 667.001 491.575 666.754 472.932 667.119C469.439 669.957 466.388 673.702 463.199 676.896C457.43 682.676 451.344 688.161 445.954 694.306L445.99 766.863C411.397 766.874 376.689 767.494 342.11 766.721C338.59 764.815 336.342 762.935 335.005 759.061C332.875 752.89 334.318 738.619 334.286 731.536C334.251 723.765 333.026 699.896 335.241 694.21C336.495 690.99 340.183 687.533 342.525 685.047C349.459 677.687 357.06 670.841 364.222 663.683L427.583 600.731C433.544 594.767 445.04 585.538 448.528 578.261Z"
></path>
<path
fill="#EA580C"
transform="scale(0.78125 1)"
d="M388.873 344.75Q394.189 344.761 399.504 344.63L399.569 502.409C338.237 501.265 275.411 481.512 221.814 452.294C207.162 444.307 193.555 435.016 179.416 426.248C157.071 409.953 135.549 393.124 114.282 375.426C107.455 369.744 98.4219 363.592 93.1983 356.471C91.9835 354.814 91.4477 353.164 91.901 351.133C92.5286 348.32 95.3443 346.545 97.6458 345.247L302.403 345.232L360.341 345.197C369.535 345.186 379.836 346.102 388.873 344.75Z"
></path>
</g>
<g></g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import Flex from '$components/Flex.svelte';
interface Props extends HTMLInputAttributes {
ref?: HTMLInputElement;
label: string | boolean;
required?: boolean;
validate?: (value: string) => boolean;
}
let {
label,
name,
class: cls = '',
required = false,
ref = $bindable(),
value = $bindable(),
validate = () => true,
...rest
}: Props = $props();
let valid = $state(true);
</script>
<Flex class="w-full flex-col items-start">
{#if label}
<label class="text-medium mt-2 mb-1 ml-2" for={name}>
{label}
{#if required}
<span class="text-red">*</span>
{/if}
</label>
{/if}
<input
class={twMerge(
'border-light w-full rounded-md border p-2 px-4 outline-none',
cls?.toString()
)}
class:border-red={!valid}
onkeyup={() => (valid = validate(value))}
onblur={() => (valid = validate(value))}
autocorrect="off"
autocomplete="off"
{name}
bind:value
{...rest}
/>
</Flex>

View File

@@ -26,7 +26,7 @@
</Titlebar>
<section
class="w-content-minus-nav fixed top-[var(--height-titlebar)] left-0 ml-[var(--width-nav)] h-full min-h-screen overflow-y-scroll"
class="w-content-minus-nav fixed top-[var(--height-titlebar)] left-0 ml-[var(--width-nav)] h-full min-h-screen"
>
{@render children?.()}
</section>

View File

@@ -1,9 +1,10 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
import type { HTMLAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
type Props = SvelteHTMLElements['a'] & {
type Props = HTMLAttributes<HTMLAnchorElement> & {
href: string;
prefix?: string;
activeClass?: string;
@@ -11,20 +12,22 @@
const { children, class: cls = '', href, prefix, activeClass, ...rest }: Props = $props();
const path = page.url.pathname;
const isActive = path == href || (prefix && path.startsWith(prefix));
let isActive = $state(false);
afterNavigate(() => {
const path = page.url.pathname;
const prefixMatch = path.startsWith(href);
const explicitPrefixMatch = path.startsWith(prefix as string);
const pathMatch = path == href;
isActive = pathMatch || prefixMatch || explicitPrefixMatch;
});
</script>
<a
{...rest}
{href}
class={twMerge('border-l border-transparent', cls?.toString(), isActive ? activeClass : '')}
class={twMerge('block duration-300', cls?.toString(), isActive ? activeClass : '')}
data-sveltekit-preload-data="off"
>
{@render children?.()}
</a>
<style>
a {
transition: all 0.3s linear;
}
</style>

View File

@@ -0,0 +1,40 @@
<script lang="ts" generics="Item">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import Flex from './Flex.svelte';
interface Props extends HTMLAttributes<HTMLDivElement> {
items: Item[];
itemView: Snippet<[Item]>;
borderless?: boolean;
title?: string;
titleClass?: string;
}
const {
items,
itemView,
class: cls = '',
titleClass = '',
borderless = false,
title,
}: Props = $props();
let css = borderless ? '' : 'border-b-light border-b';
</script>
<Flex class={twMerge('w-full flex-col items-start', cls?.toString())}>
{#if title}
<p class={twMerge('text-medium text-sm', titleClass?.toString())}>
{title}
</p>
{/if}
{#each items as item, i (i)}
<div class={twMerge('w-full', css)}>
{@render itemView(item)}
</div>
{/each}
</Flex>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { constantCase } from 'change-case';
import Flex from '$components/Flex.svelte';
import Input from '$components/Input.svelte';
import Svg from '$components/Svg.svelte';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
interface Props {
server: IMcpServer;
}
interface Env {
key: string;
value: string;
}
let { server }: Props = $props();
let command: string = $state('');
let env: Env[] = $state([]);
let newKey: string = $state('');
let newValue: string = $state('');
// Derive the computed `command` and mutable `env` from `server`. Must
// happen before the UI renders (via `$effect.raw`) so that we use the most
// recently set `server`.
$effect.pre(() => {
command = `${server.command} ${server.args.join(' ')}`.trim();
env = Object.entries(server.env).map(([key, value]) => ({
key: constantCase(key),
value,
}));
});
async function save() {
const cmd = command.split(' ');
server.command = cmd[0];
server.args = cmd.slice(1);
server.env = Object.fromEntries(env.map((e) => [constantCase(e.key), e.value]));
server = await McpServer.save(server);
}
async function addEnv() {
env.push({ key: newKey, value: newValue });
await save();
newKey = '';
newValue = '';
}
async function remove(key: string) {
env = env.filter((e) => e.key !== key);
await save();
}
</script>
<Flex class="w-full flex-col items-start">
<h1 class="text-purple mb-4 ml-4 text-2xl">{server.name}</h1>
<h2 class="text-medium mt-8 mb-4 ml-4 text-xl">Command</h2>
<Input
bind:value={command}
label={false}
name="command"
class="w-full"
onchange={save}
placeholder="uvx | npx COMMAND [args]"
/>
<h2 class="text-medium mt-8 mb-4 ml-4 text-xl">Env</h2>
<Flex class="grid w-full auto-cols-max auto-rows-max grid-cols-2 gap-4">
{#each env as entry (entry.key)}
<Input
onchange={save}
label={false}
placeholder="Key"
bind:value={entry.key}
class="uppercase"
/>
<Flex>
<Input onchange={save} label={false} placeholder="Value" bind:value={entry.value} />
<button
class="text-dark hover:text-red ml-4 transition duration-300 hover:cursor-pointer"
onclick={() => remove(entry.key)}
>
<Svg name="Delete" class="h-4 w-4" />
</button>
</Flex>
{/each}
<Input bind:value={newKey} label={false} placeholder="Key" class="uppercase" />
<Flex>
<Input bind:value={newValue} onchange={addEnv} label={false} placeholder="Value" />
<div class="ml-4 h-4 w-4"></div>
</Flex>
</Flex>
</Flex>

View File

@@ -15,7 +15,7 @@
<Thought thought={message.thought} />
{/if}
<Flex class="text-medium mb-6 w-full justify-between p-2 text-xs">
<Flex class="assistant text-medium mb-6 w-full justify-between p-2 text-xs">
<p class="message markdown-body text-sm whitespace-normal">
{@html markdown.render(message.content)}
</p>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import closables from '$lib/closables';
interface Props extends HTMLAttributes<HTMLDivElement> {
close: () => void | Promise<void>;
}
const { children, close, class: cls = '' }: Props = $props();
let ref: HTMLDivElement;
$effect(() => {
closables.register(ref, close);
return () => closables.unregister(ref);
});
</script>
<div class="fixed top-0 left-0 z-40 h-screen w-screen bg-black/90"></div>
<div
bind:this={ref}
class={twMerge(
'border-light bg-dark fixed top-[50%] left-[50%] z-50 max-h-3/4 w-[400px] -translate-[50%] overflow-y-scroll rounded-xl border p-8',
cls?.toString()
)}
>
{@render children?.()}
</div>

View File

@@ -18,17 +18,16 @@
This link MUST NOT preload data on hover, since it redirects when it does.
Which means we'd navigate on hover instead of click.
-->
<Link
href="/chat/latest"
aria-label="chat"
prefix="/chat"
activeClass="text-purple"
data-sveltekit-preload-data="off"
>
<Link href="/chat/latest" aria-label="chat" prefix="/chat" activeClass="text-purple">
<Svg name="Chat" />
</Link>
<Link href="/mcp-servers" aria-label="mcp-servers" activeClass="text-purple">
<Link
href="/mcp-servers"
aria-label="mcp-servers"
prefix="/mcp-servers"
activeClass="text-purple"
>
<Svg name="MCP" />
</Link>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import Flex from '$components/Flex.svelte';
interface Props extends HTMLAttributes<HTMLDivElement> {
ref?: HTMLDivElement;
}
let { children, ref = $bindable(), class: cls = '' }: Props = $props();
</script>
<Flex bind:ref class={twMerge('h-full w-full items-start overflow-auto', cls?.toString())}>
<div class="h-full w-full">
{@render children?.()}
</div>
<!--
Scrollbars, by default, are _inside_ the padding of an element. Which
means it will always overlap the content within. This looks shitty, so force
the scrollable view to have extra space on the right by shoving a div in
there.
-->
<div class="h-full w-6"></div>
</Flex>

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import Box from '$components/Box.svelte';
import Flex from '$components/Flex.svelte';
import Svg from '$components/Svg.svelte';
import type { CompactServer } from '$lib/smithery';
interface Props {
server: CompactServer;
onInstall: (server: CompactServer) => void | Promise<void>;
}
const { server, onInstall }: Props = $props();
let close: HTMLButtonElement;
function trunc(text: string, chars: number) {
return text.substring(0, chars - 3) + '...';
}
async function install(e: MouseEvent) {
e.stopPropagation();
await onInstall(server);
}
</script>
<Box class="h-full flex-col items-start px-6">
<a href={`https://smithery.ai/server/${server.qualifiedName}`} target="_blank">
<h2>{server.displayName}</h2>
<p class="text-medium">{trunc(server.description, 80)}</p>
<div class="grow"></div>
<Flex class="mt-2 w-full justify-between">
<Flex title="Use Count" class="text-medium text-xs">
<Svg name="Pulse" class="text-yellow mr-2 h-4 w-4" />
{server.useCount}
</Flex>
<button
bind:this={close}
onclick={install}
class="border-light hover:text-purple hover:border-purple self-end
rounded-md border p-2 px-6 text-sm transition duration-300
hover:cursor-pointer"
>
Install
</button>
</Flex>
</a>
</Box>

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import Button from '$components/Button.svelte';
import Flex from '$components/Flex.svelte';
import Input from '$components/Input.svelte';
import Modal from '$components/Modal.svelte';
import { debug, info } from '$lib/logger';
import type { McpConfig } from '$lib/mcp';
import type { Config, ConfigSchema, Server } from '$lib/smithery';
interface Props {
server: Server;
config: ConfigSchema;
onCancel: () => void | Promise<void>;
onInstall: (config: McpConfig) => void | Promise<void>;
}
const { server, config: _config, onCancel, onInstall }: Props = $props();
let config: Config[] = $state(generateConfig(_config));
let srcdoc: string = $state('');
let optionalIsOpen = $state(false);
function generateSrcdoc(server: Server, config: Config[]) {
const { connections } = server;
const fn = connections.findBy('type', 'stdio')?.stdioFunction || '() => ({})';
return `<script>
const fn = ${fn};
const config = fn(${serialize(config)});
const message = { type: 'SmitheryInstall', config: config };
window.parent.postMessage(message, '*');
<\/script>`; // eslint-disable-line
}
// Transform a raw JSONSchema object from Smithery into a simpler object we
// can use in the UI.
//
function generateConfig(config: ConfigSchema): Config[] {
if (!config.properties || Object.keys(config.properties).length == 0) {
return [];
}
return Object.entries(config.properties)
.sort(([key, _]) => (config.required.includes(key) ? -1 : 1))
.map(([key, prop]) => ({
name: key,
required: config.required?.includes(key),
description: prop.description,
value: String(prop.default || ''),
valid: true,
}));
}
function install() {
validateAll();
if (isValid()) {
srcdoc = generateSrcdoc(server, config);
}
}
function isValid() {
return config.every((prop) => prop.valid);
}
function validateAll() {
config
.filter((c) => c.required)
.forEach((prop) => {
prop.valid = validate(prop.value);
});
}
function validate(value: string): boolean {
return value !== '';
}
// JSON representation of the object we pass to the Smithery configuration
// function.
//
function serialize(config: Config[]) {
return JSON.stringify(convert(config));
}
// Convert a `Config` to a plain `{key: value}` we can pass to the Smithery
// configuration function.
//
function convert(config: Config[]) {
const entries = config.map(({ name, value }) => [name, value]);
return Object.fromEntries(entries);
}
function toggleOptional() {
optionalIsOpen = optionalIsOpen ? false : true;
}
// Event handler for when the Smithery config function is ran inside the
// iFrame.
//
// The only way to communicate up the chain, from an iframe, is via
// messages. The iframe does this once it figures out the config.
//
async function onMessage(event: MessageEvent) {
info(event);
const config = event.data;
if (config.type !== 'SmitheryInstall') {
return;
}
await onInstall(event.data.config);
}
</script>
<svelte:window onmessage={onMessage} />
{#if srcdoc}
<iframe title="stdioFunction" class="hidden h-0 w-0" sandbox="allow-scripts" allow="" {srcdoc}>
</iframe>
{/if}
{#if server && config}
<Modal close={onCancel} class="!p-0 !pb-8">
<Flex class="w-full flex-col items-start">
<h2 class="p-8 py-4">{server.displayName}</h2>
{#if config.length > 0}
<div class="w-full px-8">
{#each config.filter((p) => p.required) as prop (prop.name)}
<Input
label={prop.name}
name={prop.name}
bind:value={prop.value}
required={prop.required}
{validate}
/>
{/each}
</div>
{#if config.some((p) => !p.required)}
<Flex class="mt-8 w-full flex-col items-start">
<button
onclick={() => toggleOptional()}
class="text-medium text-sm-full w-full px-8 pb-2 text-left hover:cursor-pointer"
>
{optionalIsOpen ? '⏷' : '⏵'} &nbsp; Optional
</button>
{#if optionalIsOpen}
<div class="w-full px-8">
{#each config.filter((p) => !p.required) as prop (prop.name)}
<Input
label={prop.name}
name={prop.name}
bind:value={prop.value}
/>
{/each}
</div>
{/if}
</Flex>
{/if}
{/if}
<Flex class="mt-8 mr-8 self-end">
<Button onclick={onCancel} class="border-medium text-medium mr-4">Cancel</Button>
<Button onclick={install} class="border-purple text-purple">Install</Button>
</Flex>
</Flex>
</Modal>
{/if}

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge';
const { class: cls = '' } = $props();
</script>
<div class={twMerge('spinner', cls?.toString())}></div>
<style>
.spinner {
width: 24px;
height: 24px;
border-radius: 50%;
position: relative;
animation: rotate 1s linear infinite;
}
.spinner::before {
content: '';
box-sizing: border-box;
position: absolute;
inset: 0px;
border-radius: 50%;
border: 5px solid #fff;
animation: prixClipFix 2s linear infinite;
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes prixClipFix {
0% {
clip-path: polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0);
}
25% {
clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0);
}
50% {
clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%);
}
75% {
clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%);
}
100% {
clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0);
}
}
</style>

View File

@@ -1,26 +0,0 @@
<script lang="ts">
import Svg from './Svg.svelte';
const { thought } = $props();
let isOpen = $state(false);
function toggle() {
isOpen = isOpen ? false : true;
}
</script>
<thought class="text-medium border-light mb-4 w-full rounded-xl border">
<button
onclick={() => toggle()}
class="flex w-full items-center gap-4 p-2 px-4 hover:cursor-pointer"
>
<Svg name="Lightbulb" class="h-6 w-6" />
Model Thoughts
</button>
{#if isOpen}
<p class="p-4 px-6 whitespace-pre-wrap">
{thought}
</p>
{/if}
</thought>

View File

@@ -1,10 +1,11 @@
import '$lib/ext';
import type { ClientInit } from "@sveltejs/kit";
import type { ClientInit, HandleClientError } from "@sveltejs/kit";
import { goto } from '$app/navigation';
import { WELCOME_AGREED } from "$lib/const";
import Config from "$lib/config";
import * as llm from '$lib/llm';
import { OllamaClient } from '$lib/llm';
import { info } from '$lib/logger';
import App from "$lib/models/app";
import McpServer from "$lib/models/mcp-server";
@@ -26,7 +27,7 @@ export const init: ClientInit = async () => {
info('[green]✔ database synced');
const client = new llm.Client();
const client = new OllamaClient();
await startup.addCheck(
StartupCheck.Ollama,
@@ -44,3 +45,7 @@ export const init: ClientInit = async () => {
async () => await Config.get(WELCOME_AGREED) === true,
);
}
export const handleError: HandleClientError = async () => {
goto('/error');
}

View File

@@ -2,8 +2,7 @@
type Closable = (() => Promise<void>) | (() => void);
// List of callables to invoke
// eslint-disable-next-line
const closables: Array<[any, Closable]> = [];
let closables: Array<[Node, Closable]> = [];
// Manage a list of functions that should be called when the window is clicked.
//
@@ -13,13 +12,17 @@ const closables: Array<[any, Closable]> = [];
// Triggered by `routes/+layout.svelte`
//
export default {
register(ele: any, fn: Closable) { // eslint-disable-line
register(ele: Node, fn: Closable) {
closables.push([ele, fn]);
},
unregister(ele: Node) {
closables = closables.filter(([e, _]) => ele !== e);
},
close(e: Event) {
closables.forEach(([ele, fn]) => {
if (e.target !== ele) {
if (!ele.contains?.(e.target as Node)) {
fn();
}
});

View File

@@ -1,13 +1,14 @@
import { invoke } from "@tauri-apps/api/core";
import * as llm from "$lib/llm";
import { type LlmOptions, OllamaClient } from "./llm";
import App from "$lib/models/app";
import Message, { type IMessage } from "$lib/models/message";
import Session, { type ISession } from "$lib/models/session";
export async function dispatch(session: ISession, model: string, prompt?: string): Promise<IMessage> {
const app = App.find(session.appId as number);
const client = new llm.Client();
const client = new OllamaClient();
if (!app) {
throw "Missing app";
@@ -27,7 +28,7 @@ export async function dispatch(session: ISession, model: string, prompt?: string
tool_calls: m.toolCalls,
}));
const options: llm.Options = {
const options: LlmOptions = {
num_ctx: session.config.contextWindow,
temperature: session.config.temperature,
};

View File

@@ -1,48 +1,55 @@
// Javascript stdlib extensions
//
// This file should only include functions added to standard Javascript
// objects. Each function needs a cooresponding type definition in
// `src/app.d.ts`.
/* eslint-disable @typescript-eslint/no-explicit-any */
// Return the Object without k/v pairs where the value is explicitly undefined.
//
// Typically you'll use this after mapping a bunch of values where some may be
// undefined.
//
// @example
// ```ts
// const compacted = Object.compact({ a: 1, b: true, c: undefined });
// assertEq(compacted, { a: 1, b: true });
// ```
//
/**
* Javascript stdlib extensions
*
* This file should only include functions added to standard Javascript
* objects. Each function needs a cooresponding type definition in
* `src/app.d.ts`.
*/
/**
* Return the Object without k/v pairs where the value is explicitly undefined.
*
* Typically you'll use this after mapping a bunch of values where some may be
* undefined.
*
* @example
* ```ts
* const compacted = Object.compact({ a: 1, b: true, c: undefined });
* assertEq(compacted, { a: 1, b: true });
* ```
*/
Object.compact = function <T>(o: Obj): T {
return Object.fromEntries(
Object.entries(o).filter(([_, v]) => v !== undefined),
) as T;
}
// Return the Object without specific k/v pairs, specified by key.
//
// @example
// ```ts
// const subobj = Object.without({a: 1, b: 2}, ['b']);
// assertEq(subobj, {a: 1})
// ```
//
/**
* Return the Object without specific k/v pairs, specified by key.
*
* @example
* ```ts
* const subobj = Object.without({a: 1, b: 2}, ['b']);
* assertEq(subobj, {a: 1})
* ```
*/
Object.without = function <T>(o: Obj, keys: string[]): T {
return Object.fromEntries(
Object.entries(o).filter(([k, _]) => !keys.includes(k)),
) as T;
}
// Remove, and return, a value by key
//
// @example
// ```ts
// const value = Object.remove({a: 1, b: 2}, 'b');
// assertEq(value, 2);
// ```
//
/**
* Remove, and return, a value by key
*
* @example
* ```ts
* const value = Object.remove({a: 1, b: 2}, 'b');
* assertEq(value, 2);
* ```
*/
Object.remove = function <T>(o: Obj, key: string): T | undefined {
if (Object.hasOwn(o, key)) {
const value = o[key];
@@ -51,15 +58,23 @@ Object.remove = function <T>(o: Obj, key: string): T | undefined {
}
}
// Sort an array of objects by a specific key.
//
// @example
// ```ts
// const array = [{color: 'red'}, {color: 'blue'}];
// const sorted = array.sortBy('color');
// assertEq(sorted, [{color: 'blue'}, {color: 'red'}]);
// ```
//
/**
* Map over the entries of an object
*/
Object.map = function <T>(o: Obj, fn: (key: string, value: any) => any): T {
return Object.fromEntries(Object.entries(o).map(([k, v]) => fn(k, v))) as T;
}
/**
* Sort an array of objects by a specific key.
*
* @example
* ```ts
* const array = [{color: 'red'}, {color: 'blue'}];
* const sorted = array.sortBy('color');
* assertEq(sorted, [{color: 'blue'}, {color: 'red'}]);
* ```
*/
Array.prototype.sortBy = function <T extends Obj>(this: T[], key: string): T[] {
return this.sort((a, b) => {
if (a[key] < b[key]) return -1;
@@ -67,3 +82,29 @@ Array.prototype.sortBy = function <T extends Obj>(this: T[], key: string): T[] {
return 0;
});
}
/**
* Find by a specific key, in an array of objects.
*
* @example
* ```ts
* const items = [{n: 1}, {n: 2}, {n: 3}];
* assertEq(items.findBy('n', 2), {n: 2});
* ```
*/
Array.prototype.findBy = function <T extends Obj>(this: T[], key: string, value: any): T | undefined {
return this.find(item => item[key] == value);
}
/**
* Remove undefined's from an array
*
* @example
* ```ts
* const items = [1, undefined, 2, 3];
* assertEq(items.compact(), [1, 2, 3]);
* ```
*/
Array.prototype.compact = function <T>(this: T[]): T[] {
return this.filter(i => i !== undefined);
}

View File

@@ -1,3 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { info } from "$lib/logger";
type Response<T> = globalThis.Response | T | undefined;
@@ -63,13 +65,15 @@ export abstract class HttpClient {
}
async request(uri: string, options: Options = {}): Promise<globalThis.Response> {
let response;
let response: globalThis.Response | undefined;
const url = Object.remove(options, 'url') || this.url;
const opt = { ...this.options, ...options };
try {
response = await fetch(`${url}${uri}`, opt);
const resp: globalThis.Response = await invoke('fetch', { url: `${url}${uri}`, options: opt });
const { body, ...init } = resp;
response = new globalThis.Response(body, init);
} catch (err) {
if (typeof err == 'string') {
info(`${opt.method} ${url}${uri}: ${err}`);

26
src/lib/llm.d.ts vendored
View File

@@ -1,4 +1,4 @@
export interface Request {
export interface OllamaRequest {
model: string;
stream: boolean;
tools: Tool[];
@@ -6,7 +6,7 @@ export interface Request {
messages: Message[];
}
export interface Response {
export interface OllamaResponse {
created_at: string;
done: boolean;
done_reason: string;
@@ -15,7 +15,7 @@ export interface Response {
load_duration: number;
message: {
content: string;
role: Role;
role: LlmMessageRole;
tool_calls: ToolCall[];
};
model: string;
@@ -24,12 +24,12 @@ export interface Response {
total_duration: number;
}
export interface Options {
export interface LlmOptions {
num_ctx?: number;
temperature?: number;
}
export interface Model {
export interface OllamaModel {
name: string;
modifiedAt: string;
size: number;
@@ -68,21 +68,21 @@ export interface Model {
capabilities?: string[];
}
export type Tag = Pick<Model, 'name' | 'modifiedAt' | 'size' | 'digest' | 'details'>;
export type Role = 'system' | 'tool' | 'user' | 'assistant';
export type OllamaTag = Pick<OllamaModel, 'name' | 'modifiedAt' | 'size' | 'digest' | 'details'>;
export type LlmMessageRole = 'system' | 'tool' | 'user' | 'assistant';
export interface Tags {
export interface OllamaTags {
models: Tag[];
}
export interface Message {
role: Role;
export interface LlmMessage {
role: LlmMessageRole;
content: string;
name: string;
tool_calls?: Record<string, any>; // eslint-disable-line
}
export interface ToolCall {
export interface LlmToolCall {
function: {
name: string;
// We have no way of knowing what the LLM will pass
@@ -91,7 +91,7 @@ export interface ToolCall {
}
}
export interface Tool {
export interface LlmTool {
type: string;
function: {
name: string;
@@ -104,7 +104,7 @@ export interface Tool {
}
}
interface Property {
interface LlmProperty {
type: string;
description: string;
}

View File

@@ -1,12 +1,20 @@
import { info } from "./logger";
import type { IMessage } from "./models/message";
import { HttpClient } from "$lib/http";
import type { Message, Model, Options, Response, Tags, Tool } from "$lib/llm.d";
import type {
LlmMessage,
LlmOptions,
LlmTool,
OllamaModel,
OllamaResponse,
OllamaTags
} from "$lib/llm.d";
import Setting from "$lib/models/setting";
export * from '$lib/llm.d';
export class Client extends HttpClient {
export class OllamaClient extends HttpClient {
options: RequestInit = {
signal: AbortSignal.timeout(30000),
headers: {
@@ -18,7 +26,7 @@ export class Client extends HttpClient {
return Setting.OllamaUrl;
}
async chat(model: string, messages: Message[], tools: Tool[] = [], options: Options = {}): Promise<IMessage> {
async chat(model: string, messages: LlmMessage[], tools: LlmTool[] = [], options: LlmOptions = {}): Promise<IMessage> {
const body = JSON.stringify({
model,
messages,
@@ -27,7 +35,7 @@ export class Client extends HttpClient {
stream: false,
});
const response = await this.post('/api/chat', { body }) as Response;
const response = await this.post('/api/chat', { body }) as OllamaResponse;
let thought: string | undefined;
let content: string = response
@@ -53,18 +61,18 @@ export class Client extends HttpClient {
};
}
async list(): Promise<Model[]> {
async list(): Promise<OllamaModel[]> {
return (
await this.get('/api/tags') as Tags
).models as Model[];
await this.get('/api/tags') as OllamaTags
).models as OllamaModel[];
}
async info(name: string): Promise<Model> {
async info(name: string): Promise<OllamaModel> {
const body = JSON.stringify({ name });
return (
await this.post('/api/show', { body })
) as Model;
) as OllamaModel;
}
async connected(): Promise<boolean> {
@@ -74,8 +82,12 @@ export class Client extends HttpClient {
}
async hasModels(): Promise<boolean> {
if (!await this.connected()) {
return false;
}
return (
await this.get('/api/tags', { raise: false }) as Tags
)?.models.length > 0;
await this.get('/api/tags') as OllamaTags
).models.length > 0;
}
}

18
src/lib/mcp.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
export interface McpConfig {
command: string;
args: string[];
env: Record<string, string>;
}
export interface McpTool {
name: string;
description: string;
inputSchema: McpInputSchema;
}
export interface McpInputSchema {
type: string;
title: string;
properties: { [k: string]: any; }; // eslint-disable-line
required: string[];
}

View File

@@ -1,27 +1,17 @@
import { invoke } from "@tauri-apps/api/core";
import * as llm from '$lib/llm';
import type { LlmTool } from "$lib/llm";
import type { McpTool } from "$lib/mcp.d";
import type { ISession } from "$lib/models/session";
export interface Tool {
name: string;
description: string;
inputSchema: InputSchema;
}
export interface InputSchema {
type: string;
title: string;
properties: { [k: string]: any; }; // eslint-disable-line
required: string[];
}
export * from '$lib/mcp.d';
// Retrieve, and transform, tools from the MCP server, into `tools` object we
// can send to the LLM.
//
export async function getMCPTools(session: ISession): Promise<llm.Tool[]> {
export async function getMCPTools(session: ISession): Promise<LlmTool[]> {
return (
await invoke<Tool[]>('get_mcp_tools', { sessionId: session.id })
await invoke<McpTool[]>('get_mcp_tools', { sessionId: session.id })
).map(tool => {
return {
type: 'function',

24
src/lib/models.ts Normal file
View File

@@ -0,0 +1,24 @@
import { info } from '$lib/logger';
import App from '$lib/models/app';
import McpServer from '$lib/models/mcp-server';
import Message from '$lib/models/message';
import Model from '$lib/models/model.svelte';
import Session from '$lib/models/session';
import Setting from '$lib/models/setting';
export { default as App, type IApp } from '$lib/models/app';
export { type IMcpServer, default as McpServer } from '$lib/models/mcp-server';
export { type IMessage, default as Message } from '$lib/models/message';
export { type IModel, default as Model } from '$lib/models/model.svelte';
export { type ISession, default as Session } from '$lib/models/session';
export { type ISetting, default as Setting } from '$lib/models/setting';
export async function resync() {
await App.sync();
await Session.sync();
await Message.sync();
await McpServer.sync();
await Setting.sync();
await Model.sync();
info('[green]✔ resynced');
}

View File

@@ -47,17 +47,15 @@ export enum Interface {
export default class App extends Model<IApp, Row>('apps') {
static default(): IApp {
return {
name: 'Unknown',
description: '',
readme: '',
image: '',
interface: Interface.Chat,
nodes: [],
mcpServers: [],
}
}
static defaults = {
name: 'Unknown',
description: '',
readme: '',
image: '',
interface: Interface.Chat,
nodes: [],
mcpServers: [],
};
static hasContext(app: IApp): boolean {
return app.nodes?.find(n => n.type == NodeType.Context) !== undefined;

View File

@@ -3,167 +3,192 @@ import Database from "@tauri-apps/plugin-sql";
import { DATABASE_URL } from "$lib/const";
import { info } from "$lib/logger";
// Database connection
/**
* Database connection
*/
export let db: Database;
// SQL rows should never include reserved columns.
/**
* SQL rows should never include reserved columns.
*/
export type ToSqlRow<Row> = Omit<Row, 'id' | 'created' | 'modified'>;
// Columns that should never be included in an UPDATE or INSERT query.
/**
* Columns that should never be included in an UPDATE or INSERT query.
*/
export const ReservedColumns = ['id', 'created', 'modified'];
// # Model
//
// Base of all database models. This class supplies the ORM functions required
// to interact with the database and the "repo" pass-through layer.
//
// Model functionality is split by reads and writes. All reads are done from
// the repo, while writes are done directory to the database, then synced
// to the repo.
//
// ## Static Methods All the Way Down
//
// Since Svelte's reactivity only works on basic data structures more or
// less we can't pass around instances of models. Instead, functions that
// require an "instance" of a model needs to accept a object as an argument.
//
// NOTE: I'm a bit unsure of a ststic class interface is the right choice for
// models. An alternative would be to build plain JS objects. This would remove
// the need for all the `static` non-sense. :shrug:
//
// ## `Instance` Interface
//
// The `Instance` interface represent the object you pass around the app. They
// are simple JS objects.
//
// `Instance` properties shouild reflect the interface you want to use in the
// app. Meaning, camelCase keys and complex types (if needed).
//
// Foreign key properties should be optional to allow new `Instance`s to be
// created where you don't know the associations at instantiation.
//
// ## `Row` Interface
//
// The `Row` interface represents a database row. Meaning, property types
// should match database types as closely as possible.
//
// For example, if you have a datetime column, it would be returned from the
// database as a string, so declare that property with `string`, `JSON` columns
// are represented as a `string`, etc.
//
// ## Serielization / Deserialization
//
// [De]Serialization is handled through two functions `fromSql` and `toSql`
// that you need to implement on your model.
//
// ### `fromSql`
//
// Converts a database row (`Row`) into an instance (`Instance`). This is where
// you should convert fields like dates from a `string` to a `DateTime`, JSON
// columns from a `string` to a "real" object, etc.
//
// This is called when objects are retrieved from the database.
//
// ### `toSql`
//
// Convert an instance (`Instance`) to a database row (`Row`). This is where
// you should convert your complex types into simple database types. For
// example, an object into the JSON stringify'ed version of itself.
//
// `toSql` should EXCLUDE properties for columns that are set automatically by
// the database, like `id`, `created`, or `modified`.
//
// ## Lifecycle Callbacks
//
// You may implement `beforeCreate`, `afterCreate`, `beforeUpdate`, and
// `afterUpdate`. See the documentation for these functions for more specific
// information.
//
// ## Usage
//
// `Model` is a function. It takes two generic types and the name of the table
// records reside within.
//
// ## Example
//
// ```ts
// export interface IMessage {
// userId: string;
// content: string;
// }
//
// interface Row {
// user_id: string;
// content: string;
// }
//
// class Message extends Model<Interface, Row>('messages') {
// static function fromSql(row: Row): Promise<IMessage> {
// return {
// id: row.id,
// userId: row.user_id,
// content: row.content,
// created: moment.utc(row.created),
// modified: moment.utc(row.modified),
// }
// }
//
// static function toSql(message: IMessage): Promise<ToSqlRow<Row>> {
// return {
// user_id: message.rowId,
// content: message.content,
// }
// }
// }
// ```
//
/**
* # Model
*
* Base of all database models. This class supplies the ORM functions required
* to interact with the database and the "repo" pass-through layer.
*
* Model functionality is split by reads and writes. All reads are done from
* the repo, while writes are done directory to the database, then synced
* to the repo.
*
* ## Static Methods All the Way Down
*
* Since Svelte's reactivity only works on basic data structures more or
* less we can't pass around instances of models. Instead, functions that
* require an "instance" of a model needs to accept a object as an argument.
*
* NOTE: I'm a bit unsure of a ststic class interface is the right choice for
* models. An alternative would be to build plain JS objects. This would remove
* the need for all the `static` non-sense. :shrug:
*
* ## `Instance` Interface
*
* The `Instance` interface represent the object you pass around the app. They
* are simple JS objects.
*
* `Instance` properties shouild reflect the interface you want to use in the
* app. Meaning, camelCase keys and complex types (if needed).
*
* Foreign key properties should be optional to allow new `Instance`s to be
* created where you don't know the associations at instantiation.
*
* ## `Row` Interface
*
* The `Row` interface represents a database row. Meaning, property types
* should match database types as closely as possible.
*
* For example, if you have a datetime column, it would be returned from the
* database as a string, so declare that property with `string`, `JSON` columns
* are represented as a `string`, etc.
*
* ## Serielization / Deserialization
*
* [De]Serialization is handled through two functions `fromSql` and `toSql`
* that you need to implement on your model.
*
* ### `fromSql`
*
* Converts a database row (`Row`) into an instance (`Instance`). This is where
* you should convert fields like dates from a `string` to a `DateTime`, JSON
* columns from a `string` to a "real" object, etc.
*
* This is called when objects are retrieved from the database.
*
* ### `toSql`
*
* Convert an instance (`Instance`) to a database row (`Row`). This is where
* you should convert your complex types into simple database types. For
* example, an object into the JSON stringify'ed version of itself.
*
* `toSql` should EXCLUDE properties for columns that are set automatically by
* the database, like `id`, `created`, or `modified`.
*
* ## Lifecycle Callbacks
*
* You may implement `beforeCreate`, `afterCreate`, `beforeUpdate`, and
* `afterUpdate`. See the documentation for these functions for more specific
* information.
*
* ## Usage
*
* `Model` is a function. It takes two generic types and the name of the table
* records reside within.
*
* @example
*
* ```ts
* export interface IMessage {
* userId: string;
* content: string;
* }
*
* interface Row {
* user_id: string;
* content: string;
* }
*
* class Message extends Model<Interface, Row>('messages') {
* static function fromSql(row: Row): Promise<IMessage> {
* return {
* id: row.id,
* userId: row.user_id,
* content: row.content,
* created: moment.utc(row.created),
* modified: moment.utc(row.modified),
* }
* }
*
* static function toSql(message: IMessage): Promise<ToSqlRow<Row>> {
* return {
* user_id: message.rowId,
* content: message.content,
* }
* }
* }
* ```
*/
export default function Model<Interface extends Obj, Row extends Obj>(table: string) {
let repo: Interface[] = $state([]);
return class Model {
// Reload records from the database and populate the Repository.
//
static defaults = {};
/**
* Reload records from the database and populate the Repository.
*/
static async sync(): Promise<void> {
repo = [];
(await this.query(`SELECT * FROM ${table}`))
.forEach(record => this.syncOne(record));
info(`[green]✔ synced ${table}`);
}
// Create an empty, default, object.
//
// Use this instead of the `new Whatever()` syntax, as we need to
// always be passing around plain old JS objects for Svelte's
// reactivity to work properly.
//
static default(): Interface {
return {} as Interface;
/**
* Create an empty, default, object.
*
* Use this instead of the `new Whatever()` syntax, as we need to
* always be passing around plain old JS objects for Svelte's
* reactivity to work properly.
*/
static default(defaults: Partial<Interface> = {}): Interface {
defaults = typeof this.defaults == 'function'
? { ...this.defaults(), ...defaults }
: { ...this.defaults, ...defaults };
return defaults as Interface;
}
// Does a record with specific params exist.
//
/**
* Does a record with specific params exist.
*/
static exists(params: Partial<Interface>): boolean {
return this.findBy(params).length > 0;
return this.where(params).length > 0;
}
// Retrieve all records.
//
/**
* Retrieve all records.
*/
static all(): Interface[] {
return repo;
}
// Find an individual record by `id`.
//
/**
* Find an individual record by`id`.
*/
static find(id: number | string): Interface {
return this.all().find(m => m.id == Number(id)) as Interface;
}
// Find a collection of records by a set of the model's columns.
//
// Note, this expects `params` to match database columns, NOT
// properties of a associated interface.
//
static findBy(params: Partial<Interface>): Interface[] {
/**
* Find the first occurence by a subset of the model's properties.
*/
static findBy(params: Partial<Interface>): Interface {
return this.where(params)[0];
}
/**
* Find a collection of records by a set of the model's properties.
*/
static where(params: Partial<Interface>): Interface[] {
return repo.filter(m => {
return Object
.entries(params)
@@ -173,22 +198,25 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
});
}
// Find the first record
//
/**
* Find the first record
*/
static first(): Interface {
return repo[0];
}
// Find the last record
//
/**
* Find the last record
*/
static last(): Interface {
return repo[repo.length - 1];
}
// Update or Create a record.
//
// If `params` contains `id`, it will update, otherwise create.
//
/**
* Update or Create a record.
*
* If `params` contains `id`, it will update, otherwise create.
*/
static async save(params: Interface): Promise<Interface> {
if (params.id) {
return await this.update(params);
@@ -197,14 +225,15 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
}
}
// Create a new record.
//
// You may pass a subset of properties the `Interface` expects and the
// missing properties will be filled via the `default` function.
//
// `id`, `created`, and `modified` values are ALWAYS ignored, since
// they are garaunteed to be automatically set by the database.
//
/**
* Create a new record.
*
* You may pass a subset of properties the `Interface` expects and the
* missing properties will be filled via the `default ` function.
*
* `id`, `created`, and `modified` values are ALWAYS ignored, since
* they are garaunteed to be automatically set by the database.
*/
static async create(_params: Partial<Interface>): Promise<Interface> {
let row = await this.toSql(
this.exclude(
@@ -224,7 +253,7 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
const values = Object.values(row);
let instance = (await this.query(
`INSERT INTO ${table} (${columns}) VALUES (${binds}) RETURNING *`,
`INSERT INTO ${table} (${columns}) VALUES(${binds}) RETURNING * `,
values,
))[0];
@@ -237,10 +266,11 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
return instance;
}
// Update a record.
//
// Only pass the columns you intend to change.
//
/**
* Update a record.
*
* Only pass the columns you intend to change.
*/
static async update(_params: Interface): Promise<Interface> {
let row = await this.toSql(
this.exclude(_params, ReservedColumns)
@@ -251,10 +281,10 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
const setters = this.setters(row).join(', ');
const values = [...Object.values(row), _params.id];
const idBind = `$${values.length}`;
const idBind = `$${values.length} `;
let instance = (await this.query(
`UPDATE ${table} SET ${setters} WHERE id = ${idBind} RETURNING *`,
`UPDATE ${table} SET ${setters} WHERE id = ${idBind} RETURNING * `,
values,
))[0];
@@ -267,8 +297,9 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
return instance;
}
// Delete a record, by `id`.
//
/**
* Delete a record, by`id`.
*/
static async delete(id: number): Promise<boolean> {
const result = (
await (await this.db()).execute(
@@ -283,18 +314,21 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
return result;
}
/**
* Delete a record by a subset of columns
*/
static async deleteBy(params: Partial<Row>): Promise<boolean> {
const conditions = this.setters(params).join(' AND ');
const values = Object.values(params);
const instances = await this.query(
`SELECT * FROM ${table} WHERE ${conditions}`,
`SELECT * FROM ${table} WHERE ${conditions} `,
values,
);
const success = (
await (await this.db()).execute(
`DELETE FROM ${table} WHERE ${conditions}`,
`DELETE FROM ${table} WHERE ${conditions} `,
values,
)
).rowsAffected >= 1;
@@ -308,8 +342,9 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
return success;
}
// Run a query in the database, returning an object implementing `Instance`.
//
/**
* Run a query in the database, returning an object implementing`Instance`.
*/
protected static async query(sql: string, values: unknown[] = []): Promise<Interface[]> {
const result: Row[] = (
await (await this.db()).select<Row[]>(sql, values)
@@ -322,8 +357,9 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
);
}
// Memoized database connection.
//
/**
* Memoized database connection.
*/
protected static async db(): Promise<Database> {
if (!db) {
db = await Database.load(DATABASE_URL);
@@ -331,8 +367,9 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
return db;
}
// Update, or Add, a single record
//
/**
* Update, or Add, a single record
*/
private static syncOne(record: Interface) {
const existing = this.find(record.id);
@@ -344,32 +381,35 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
}
}
// Remove an instance from the repo
//
/**
* Remove an instance from the repo
*/
private static syncRemove(instance: Interface) {
repo = repo.filter(
i => i.id !== instance.id
);
}
// Remove ephemeral instances from the repo.
//
// Pages will often push an "empty" instance into a list of models, to
// so that it renders in a list and the user can configure it.
//
// We need to remove those "ephemeral" instances when we save a record,
// otherwise both would show up and appear to be duplicate.
//
// This leaves only persisted records (ones with an `id`).
//
/**
* Remove ephemeral instances from the repo.
*
* Pages will often push an "empty" instance into a list of models, to
* so that it renders in a list and the user can configure it.
*
* We need to remove those "ephemeral" instances when we save a record,
* otherwise both would show up and appear to be duplicate.
*
* This leaves only persisted records(ones with an`id`).
*/
private static removeEphemeralInstances() {
repo = repo.filter(
record => record.id !== undefined
);
}
// Exclude k/v pairs in an object, by a list of keys.
//
/**
* Exclude k / v pairs in an object, by a list of keys.
*/
private static exclude<T extends Obj>(params: T, exclude: string[]): T {
return Object.fromEntries(
Object
@@ -378,50 +418,57 @@ export default function Model<Interface extends Obj, Row extends Obj>(table: str
) as T;
}
// Retrieve the list of columns from `params`.
//
// Mostly just a more descriptive name for the operation.
//
/**
* Retrieve the list of columns from`params`.
*
* Mostly just a more descriptive name for the operation.
*/
private static columns<P extends Obj>(params: P): string[] {
return Object.keys(params);
}
// Generate a list of `$k = $#` statements from `params`.
//
// `$k` is the name of the column and `$#` is the bind parameter.
//
/**
* Generate a list of `$k = $#` statements from`params`.
*
* `$k` is the name of the column and `$#` is the bind parameter.
*/
private static setters<P extends Obj>(params: P): string[] {
return Object.keys(params).map((k, i) => `${k} = $${i + 1}`);
return Object.keys(params).map((k, i) => `${k} = $${i + 1} `);
}
// Individual numeric bind statements, like `['$1', '$2']`.
//
/**
* Individual numeric bind statements, like`['$1', '$2']`.
*/
private static binds<P extends Obj>(params: P): string[] {
return Object.values(params).map((_, i) => `$${i + 1}`);
return Object.values(params).map((_, i) => `$${i + 1} `);
}
// Transform a raw database row into an `Interface` object.
//
/**
* Transform a raw database row into an `Interface` object.
*/
protected static async fromSql(_: Row): Promise<Interface> {
throw "NotImplementedError";
}
// Transform an `Interface` object into a `Row` of database compatiable
// values.
//
/**
* Transform an `Interface` object into a `Row` of database compatiable
* values.
*/
protected static async toSql(_: ToSqlRow<Interface>): Promise<ToSqlRow<Row>> {
throw "NotImplementedError";
}
// Transform the `Row` object before it's used to generate a query.
//
/**
* Transform the `Row` object before it's used to generate a query.
*/
protected static async beforeSave(row: ToSqlRow<Row>): Promise<ToSqlRow<Row>> {
return row;
}
// Transform the `Instance` object after it's created/updated/retrieved
// from the database.
//
/**
* Transform the `Instance` object after it's created/updated/retrieved
* from the database.
*/
protected static async afterSave(instance: Interface): Promise<Interface> {
return instance;
}

View File

@@ -6,14 +6,20 @@ import Model, { type ToSqlRow } from '$lib/models/base.svelte';
export interface IMcpServer {
id?: number;
name: string;
command: string;
metadata?: Metadata;
args: string[];
env: Record<string, string>;
}
interface Row {
id: number;
name: string;
command: string;
metadata: string;
args: string;
env: string;
}
interface Metadata {
@@ -28,21 +34,22 @@ interface Metadata {
}
export default class McpServer extends Model<IMcpServer, Row>('mcp_servers') {
static default(): IMcpServer {
return {
command: '',
metadata: {
protocolVersion: '',
capabilities: {
tools: {},
},
serverInfo: {
name: undefined,
version: '',
}
static defaults = {
name: 'Unknown',
command: '',
metadata: {
protocolVersion: '',
capabilities: {
tools: {},
},
}
}
serverInfo: {
name: undefined,
version: '',
}
},
args: [],
env: {},
};
static async forApp(appId: number): Promise<IMcpServer[]> {
return await this.query(
@@ -59,6 +66,8 @@ export default class McpServer extends Model<IMcpServer, Row>('mcp_servers') {
await invoke('start_mcp_server', {
sessionId: session.id,
command: server.command,
args: server.args,
env: server.env,
});
}
@@ -71,11 +80,21 @@ export default class McpServer extends Model<IMcpServer, Row>('mcp_servers') {
static async afterCreate(server: IMcpServer): Promise<IMcpServer> {
const metadata: Metadata = JSON.parse(
await invoke('get_metadata', { command: server.command })
await invoke('get_metadata', {
command: server.command,
args: server.args,
env: server.env,
})
);
const name = metadata
.serverInfo
?.name
?.replace('mcp-server/', '') as string;
return await this.update({
...server,
name,
metadata,
});
}
@@ -83,15 +102,21 @@ export default class McpServer extends Model<IMcpServer, Row>('mcp_servers') {
static async fromSql(row: Row): Promise<IMcpServer> {
return {
id: row.id,
name: row.name,
command: row.command,
metadata: JSON.parse(row.metadata),
args: JSON.parse(row.args),
env: JSON.parse(row.env),
};
}
static async toSql(server: IMcpServer): Promise<ToSqlRow<Row>> {
return {
name: server.name,
command: server.command,
metadata: JSON.stringify(server.metadata),
args: JSON.stringify(server.args),
env: JSON.stringify(server.env),
}
}
}

View File

@@ -1,12 +1,12 @@
import moment from "moment";
import type { Role } from '$lib/llm.d';
import type { LlmMessageRole } from '$lib/llm.d';
import Model, { type ToSqlRow } from '$lib/models/base.svelte';
import Session, { type ISession } from "$lib/models/session";
export interface IMessage {
id?: number;
role: Role;
role: LlmMessageRole;
content: string;
thought?: string;
model: string;
@@ -33,15 +33,13 @@ interface Row {
}
export default class Message extends Model<IMessage, Row>('messages') {
static default(): IMessage {
return {
role: 'user',
content: '',
model: '',
name: '',
toolCalls: [],
}
}
static defaults = {
role: 'user',
content: '',
model: '',
name: '',
toolCalls: [],
};
static session(message: IMessage): ISession {
return Session.find(message.sessionId as number);
@@ -56,7 +54,7 @@ export default class Message extends Model<IMessage, Row>('messages') {
protected static async fromSql(row: Row): Promise<IMessage> {
return {
id: row.id,
role: row.role as Role,
role: row.role as LlmMessageRole,
content: row.content,
thought: row.thought,
model: row.model,

View File

@@ -1,6 +1,6 @@
import * as llm from '$lib/llm';
import { OllamaClient, type OllamaModel } from "$lib/llm";
export type IModel = llm.Model;
export type IModel = OllamaModel;
export interface Details {
parentModel: string;
@@ -15,7 +15,7 @@ let repo: IModel[] = $state([]);
export default class Model {
static async sync(): Promise<void> {
const client = new llm.Client();
const client = new OllamaClient();
const models: IModel[] = await client.list();
repo = await Promise.all(

View File

@@ -1,6 +1,6 @@
import moment from "moment";
import * as llm from '$lib/llm';
import { type LlmTool, OllamaClient } from "$lib/llm";
import { getMCPTools } from '$lib/mcp';
import App, { type IApp } from '$lib/models/app';
import Base, { type ToSqlRow } from '$lib/models/base.svelte';
@@ -33,17 +33,15 @@ interface Row {
}
export default class Session extends Base<ISession, Row>('sessions') {
static default(): ISession {
return {
summary: DEFAULT_SUMMARY,
config: {
model: Model.default().name,
contextWindow: 4096,
temperature: 0.8,
enabledMcpServers: [],
}
static defaults = () => ({
summary: DEFAULT_SUMMARY,
config: {
model: Model.default().name,
contextWindow: 4096,
temperature: 0.8,
enabledMcpServers: [],
}
}
});
static app(session: ISession): IApp | undefined {
if (!session.appId) return;
@@ -52,10 +50,10 @@ export default class Session extends Base<ISession, Row>('sessions') {
static messages(session: ISession): IMessage[] {
if (!session.id) return [];
return Message.findBy({ sessionId: session.id });
return Message.where({ sessionId: session.id });
}
static async tools(session: ISession): Promise<llm.Tool[]> {
static async tools(session: ISession): Promise<LlmTool[]> {
const model = Model.find(session.config.model);
if (!Model.supportsTools(model)) {
@@ -105,7 +103,7 @@ export default class Session extends Base<ISession, Row>('sessions') {
return;
}
const client = new llm.Client();
const client = new OllamaClient();
const message: IMessage = await client.chat(
model,
[

View File

@@ -1,5 +1,5 @@
import { OLLAMA_URL_CONFIG_KEY } from '$lib/const';
import * as llm from '$lib/llm';
import { OllamaClient } from '$lib/llm';
import Model, { type ToSqlRow } from '$lib/models/base.svelte';
import LLMModel from '$lib/models/model.svelte';
@@ -19,14 +19,12 @@ interface Row {
export default class Setting extends Model<ISetting, Row>('settings') {
static get OllamaUrl(): string {
return (
this.findBy({ key: OLLAMA_URL_CONFIG_KEY })
)[0].value as string;
return this.findBy({ key: OLLAMA_URL_CONFIG_KEY }).value as string;
}
static async validate(setting: ISetting): Promise<boolean> {
if (setting.key == OLLAMA_URL_CONFIG_KEY) {
const client = new llm.Client({ url: setting.value as string })
const client = new OllamaClient({ url: setting.value as string })
return await client.connected();
}
return true;

67
src/lib/smithery.d.ts vendored Normal file
View File

@@ -0,0 +1,67 @@
import jsonSchema from 'json-schema';
const { JSONSchema7 } = jsonSchema;
export interface ServerList {
servers: CompactServer[];
pagination: {
currentPage: number;
pageSize: number;
totalPages: number;
totalCount: number;
};
}
export interface CompactServer {
qualifiedName: string;
displayName: string;
description: string;
homepage: string;
useCount: string;
isDeployed: boolean
createdAt: string;
}
export interface Server {
qualifiedName: string;
displayName: string;
iconUrl: string | null;
deploymentUrl: string;
connections: Connection[];
security?: {
scanPassed: boolean;
};
tools?: Array<{
name: string;
description: string | null;
inputSchema: {
type: "object";
properties?: object;
};
}>;
}
export interface Connection {
type: 'stdio' | 'ws' | 'http';
configSchema: ConfigSchema;
stdioFunction: string;
}
export interface ConfigSchema {
type: 'object';
required: string[];
properties: {
[key: string]: {
type: 'string';
default?: any; // eslint-disable-line
description: string;
};
};
}
export interface Config {
name: string;
required: boolean;
description: string;
value: string;
valid: boolean;
}

1
src/lib/smithery.ts Normal file
View File

@@ -0,0 +1 @@
export * from '$lib/smithery.d';

View File

@@ -0,0 +1,38 @@
import { HttpClient } from "$lib/http";
import type { CompactServer, Server, ServerList } from '$lib/smithery.d';
export * from '$lib/smithery.d';
export class Client extends HttpClient {
options: RequestInit = {
signal: AbortSignal.timeout(30000),
headers: {
'Authorization': 'Bearer 4f91ed5c-d3ae-4ba6-9169-10455db2e626',
}
};
get url() {
return 'https://registry.smithery.ai';
}
async servers(page: number = 1): Promise<CompactServer[]> {
return (
await this.get(`/servers?q=is:local&pageSize=24&page=${page}`) as ServerList
).servers;
}
async server(name: string): Promise<Server> {
return (
await this.get(`/servers/${name}`) as Server
)
}
async search(query: string): Promise<CompactServer[]> {
const q = encodeURIComponent(query)
.replace(/%20/g, '+');
return (
await this.get(`/servers?q=is:local+${q}`) as ServerList
).servers;
}
}

227
src/lib/smithery/command.ts Normal file
View File

@@ -0,0 +1,227 @@
import ts from 'typescript';
import type { Server } from '$lib/smithery';
const IS_CONFIG = /^(\s*config\.)(.+)$/;
// A Node is an object that knows how to be evaluated using a `config` object
// which may contain values to be interpolated.
//
abstract class Node {
text: string;
abstract eval(config: Record<string, string>): string;
constructor(text: string) {
this.text = text.trim().replaceAll("'", '');
}
}
// No interpolation, just a regular string.
//
// `text` is the value.
//
class StringNode extends Node {
eval(_: Record<string, string>) {
return this.text;
}
}
// Gets its value from the config object passed in.
//
// `text` is the name of the key in `config`.
//
class ConfigNode extends Node {
eval(config: Record<string, string>): string {
return config[this.text];
}
}
// Parent object that wraps a sets of `Node`s and can give us the final
// `Config` using those `Node`s.
//
export class Config {
command: StringNode;
args: Node[];
env: Record<string, Node>;
static from(server: Server) {
return parse(server);
}
constructor() {
this.command = new StringNode('');
this.args = [];
this.env = {};
}
eval(config: Record<string, string>) {
return {
command: this.command.eval(config),
args: this.args.map(a => a.eval(config)),
env: Object.fromEntries(
Object.entries(this.env).map(([k, v]) => [k, v.eval(config)])
),
}
}
}
// Parse out the start command of an MCP server, from Smithery's configuration
// file syntax.
//
// Smithery's JSON schema for MCP servers involves specifying a JS function
// that evaluates into the final config object used to start the server
// process.
//
// ```
// startCommand:
// ...
// commandFunction:
// |-
// (config) => ({
// "command" => "npx",
// "args" => ["an-mcp-server", "--apiKey", config.apiKey],
// "env": {
// OTHER_THING: config.otherThing
// }
// })
// ```
//
// This seems like a supremely bad idea, since it means `eval`ing arbitrary,
// user-supplied, code. Likely on a server, since it's the parts necessary to
// spawn a process. I could totally be missing something, though. so :shrug:
//
// In any case, we're not going to `eval` anything. Instead, we build an AST of
// this code and parse out the nodes we care about. We build a `Config` object
// that will then be "evaluated" later, when we have the values we need to
// interpolate.
//
// This approach avoids any dangers of executing arbitrary code.
//
function parse(server: Server): Config {
const stdio = server.connections.find(c => c.type == 'stdio');
const cmd = stdio?.stdioFunction || '';
const config: Config = new Config();
// Build an AST from the function declaration
const source = ts.createSourceFile(
"source.ts",
cmd,
ts.ScriptTarget.Latest,
true,
);
// Relevant tokens to eval
const tokens: string[] = [];
// Take the next token unconditionally.
let take = false;
// Collect the tokens we care about from the AST
visit(source);
// For each token, transform them into `Node`s.
tokens.forEach((token, i) => {
if (token === 'command') {
config.command = command(tokens[i + 1]);
} else if (token === 'args') {
config.args = args(tokens[i + 1]);
} else if (token === 'env') {
config.env = env(tokens[i + 1]);
}
});
return config;
// Visit a Typescript AST node and track relevant ones.
//
// We only care about two types of nodes `Identifier` (that has a text
// value of "command", "args", or "env") and next node.
//
function visit(node: ts.Node) {
// Previous token was an identifier that we care about. If that's the
// case, also track this node as it's the value of one of those objects
if (take) {
tokens.push(node.getText());
}
// Reset this so we only record N(Identifier)+1 nodes.
take = false;
// If we care about this, track it.
if (node.kind == ts.SyntaxKind.Identifier && isNeeded(node.getText())) {
tokens.push(node.getText());
take = true;
}
// Keep on truck'n.
ts.forEachChild(node, (c) => visit(c));
}
// Command is always just a plain string.
//
function command(text: string) {
return new StringNode(text);
}
// Parse the list of `args` into a list of `Node`s.
//
// An `arg` can either just be a plain string "--verbose" which will end
// up as a `StringNode`.
//
// Or it can be a `config` variable "config.apiKey" which will end up
// as a `ConfigNode` and be evaluated later for the final value.
//
function args(text: string): Node[] {
text = text.replace('[', '').replace(']', '');
if (text == '') {
return [];
}
return text
.split(',')
.map(a => (
IS_CONFIG.test(a)
? new ConfigNode(a.trim().replace('config.', ''))
: new StringNode(a)
))
.compact() as Node[];
}
// Parse the list of `env` vars into a list of `Node`s.
//
// `env` entries will always have a plain string key, but can have a string
// or config value.
//
// Does the same as `args` and tracks the corresponding `Node` to be
// evaluated later.
//
function env(text: string): Record<string, Node> {
text = text.replace('{', '').replace('}', '');
if (text === '') {
return {};
}
return Object.fromEntries(
text
.split(',')
.map(kv => {
let [k, v] = kv.split(':'); // eslint-disable-line
let value: Node = new StringNode(v);
if (IS_CONFIG.test(v)) {
value = new ConfigNode(v.replace('config.', ''));
}
return [k, value];
})
);
}
// We only care about these three keys
//
function isNeeded(text: string) {
return ['command', 'args', 'env'].includes(text);
}
}

View File

@@ -23,6 +23,7 @@ export default {
// things like sync the database, load models, etc.
//
async addCheck(check: StartupCheck, condition: Condition, onSuccess?: OnSuccess) {
// Immediately run the check to see if it's already satisfied.
if (!await condition()) {
info(`[red] startup check failed:[default] ${check}`);
checks.push([check, condition, onSuccess]);

View File

@@ -1,14 +0,0 @@
export enum Interface {
Voice = "Voice",
Chat = "Chat",
Dashboard = "Dashboard",
Daemon = "Daemon",
}
export enum NodeType {
Context = "Context",
}
export interface CheckboxEvent extends Event {
currentTarget: EventTarget & HTMLInputElement;
}

42
src/lib/util.svelte.ts Normal file
View File

@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Delay the execution of something until the event that triggers it, stops.
//
// Useful for keypress events you want to run when the user stops typing for
// some period of time.
//
// @example
// ```ts
// <input onkeyup={debounce(doThing)} />
// ```
//
export function debounce(fn: (...args: any[]) => any, timeout = 250) {
let timer: number | undefined;
return (...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => {
fn(...args);
}, timeout) as unknown as number;
}
}
// Run a function when a specific key is pressed.
//
// @example
// ```ts
// function submit(e: KeyboardEvent) {
// coolSubmitStuff();
// }
//
// <input onkeypress={onkey('Enter', submit)} />
// ```
//
export function onkey(key: string, fn: (e: KeyboardEvent) => any) {
return (e: KeyboardEvent) => {
if (e.key == key) {
fn(e);
}
}
}

View File

@@ -1,15 +1,28 @@
<!-- App-wide event handlers -->
<script lang="ts">
// App-wide event handlers
import closables from '$lib/closables';
import { resync } from '$lib/models';
const { children } = $props();
async function onWindowClick(e: Event) {
function onclick(e: Event) {
closables.close(e);
}
function onkeypress(e: KeyboardEvent) {
if (e.key == 'Escape') {
closables.close(e);
}
}
async function onkeydown(e: KeyboardEvent) {
// manually reload data from database
if (e.metaKey && e.key == 'r') {
await resync();
}
}
</script>
<svelte:window onclick={(e) => onWindowClick(e)} />
<svelte:window {onclick} {onkeypress} {onkeydown} />
{@render children?.()}

View File

@@ -5,6 +5,7 @@
import { CHAT_APP_ID } from '$lib/const';
import Chat from '$components/Chat.svelte';
import Deleteable from '$components/Deleteable.svelte';
import Flex from '$components/Flex.svelte';
import Layout from '$components/Layouts/Default.svelte';
import Menu from '$components/Menu.svelte';
@@ -102,35 +103,27 @@
{/snippet}
<Layout {titlebar}>
<Flex class="h-full">
<Flex class="border-light bg-medium h-full w-[300px] flex-col border-r">
<Flex class="h-full items-start">
<Flex
class="border-light bg-medium h-content w-[300px] flex-col overflow-y-scroll border-r"
>
{#each sessions as sess (sess.id)}
<Flex
class={`group text-medium border-b-light w-full justify-between border-b
border-l-transparent pr-4 text-sm
${sess.id == session?.id ? '!border-l-purple border-l' : ''}`}
border-l-transparent text-sm ${sess.id == session?.id ? '!border-l-purple border-l' : ''}`}
>
<a
href={`/chat/${sess.id}`}
class:text-purple={sess.id == session.id}
class="w-full max-w-[220px] py-3 pl-8 text-left hover:cursor-pointer"
data-sveltekit-preload-data="off"
>
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
{sess.summary}
</p>
</a>
<Menu
items={[
{
icon: 'Delete',
label: 'Delete',
onclick: async () => await deleteSession(sess),
style: 'text-red',
},
]}
/>
<Deleteable ondelete={() => deleteSession(sess)}>
<a
href={`/chat/${sess.id}`}
class:text-purple={sess.id == session.id}
class="block w-full max-w-[220px] py-3 pl-8 text-left hover:cursor-pointer"
data-sveltekit-preload-data="off"
>
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
{sess.summary}
</p>
</a>
</Deleteable>
</Flex>
{/each}
</Flex>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import Layout from '$components/Layouts/Default.svelte';
import Modal from '$components/Modal.svelte';
</script>
<Layout>
<Modal close={() => {}}>
<h1 class="text-red">Whoops. Something went wrong.</h1>
<a href="/">Go Back</a>
</Modal>
</Layout>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import Deleteable from '$components/Deleteable.svelte';
import Flex from '$components/Flex.svelte';
import Layout from '$components/Layouts/Default.svelte';
import Link from '$components/Link.svelte';
import List from '$components/List.svelte';
import Svg from '$components/Svg.svelte';
import Titlebar from '$components/Titlebar.svelte';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
interface Registry {
name: string;
icon: string;
}
const { children } = $props();
let mcpServers: IMcpServer[] = $derived(McpServer.all());
let registries: Registry[] = [
{
name: 'Smithery',
icon: 'Smithery',
},
];
async function destroy(server: IMcpServer) {
await McpServer.delete(server.id as number);
}
</script>
{#snippet titlebar()}
<Titlebar class="h-[60px] w-full">
<Flex
class=" border-r-light h-full w-[300px] items-center justify-between border-r px-8 pr-4"
>
<h1 class="font-[500]">MCP Servers</h1>
<a
href="/mcp-servers/new"
class="border-light h-8 w-8 rounded-md border hover:cursor-pointer"
>
<p class="h-8 w-8 text-center !leading-[22px] font-[10px]">+</p>
</a>
</Flex>
</Titlebar>
{/snippet}
{#snippet McpServerView(server: IMcpServer)}
<Deleteable ondelete={() => destroy(server)}>
<Link
href={`/mcp-servers/${server.name}`}
class="w-full py-3 pl-8 text-sm hover:cursor-pointer"
activeClass="text-purple border-l border-l-purple"
>
{server.name}
</Link>
</Deleteable>
{/snippet}
{#snippet RegistryView(registry: Registry)}
<Link
href={`/mcp-servers/${registry.name.toLowerCase()}`}
class="mb-4 flex h-full w-full items-center pl-8"
activeClass="text-purple"
>
<Svg name={registry.icon} class="mr-4 h-6 w-6" />
{registry.name}
</Link>
{/snippet}
<Layout {titlebar}>
<Flex class="h-full items-start">
<Flex class="border-r-light h-full w-[300px] flex-col items-start border-r">
<List items={mcpServers} itemView={McpServerView} />
<List
items={registries}
itemView={RegistryView}
borderless
title="Registries"
class="mt-4"
titleClass="pl-8 my-4"
/>
</Flex>
<Flex class="h-full w-[calc(100%-300px)] items-start p-8">
{@render children?.()}
</Flex>
</Flex>
</Layout>

View File

@@ -1,68 +1,13 @@
<script lang="ts">
import Box from '$components/Box.svelte';
import Flex from '$components/Flex.svelte';
import Layout from '$components/Layouts/Default.svelte';
import Svg from '$components/Svg.svelte';
import Titlebar from '$components/Titlebar.svelte';
import { goto } from '$app/navigation';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
let mcpServers: IMcpServer[] = $derived(McpServer.all());
const server: IMcpServer = McpServer.first();
function addServer() {
mcpServers.push(McpServer.default());
}
async function save(server: IMcpServer) {
await McpServer.save({
...server,
metadata: server.metadata,
});
}
async function destroy(server: IMcpServer) {
if (!server.id) return;
await McpServer.delete(server.id);
if (server) {
goto(`/mcp-servers/${server.name}`);
} else {
goto('/mcp-servers/smithery');
}
</script>
{#snippet titlebar()}
<Titlebar class="h-[60px] w-full">
<Flex
class=" border-r-light h-full w-[300px] items-center justify-between border-r px-8 pr-4"
>
<h1 class="font-[500]">MCP Servers</h1>
<button
class="border-light h-8 w-8 rounded-md border hover:cursor-pointer"
onclick={() => addServer()}
>
<p class="h-8 w-8 text-center !leading-[22px] font-[10px]">+</p>
</button>
</Flex>
</Titlebar>
{/snippet}
<Layout {titlebar}>
<Flex class="w-full flex-col gap-4 p-8">
{#each mcpServers as server, i (i)}
<Box class="m-0 w-full">
<input
bind:value={server.command}
onchange={() => save(server)}
type="text"
autocorrect="off"
autocomplete="off"
spellcheck={false}
placeholder="uvx | npx COMMAND [args]"
class="focus:border-purple/50 text-light w-full rounded-md px-2 outline-none"
/>
<button
onclick={() => destroy(server)}
class="text-dark hover:text-red ml-4 h-4 w-4 transition duration-300 hover:cursor-pointer"
>
<Svg name="Delete" />
</button>
</Box>
{/each}
</Flex>
</Layout>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import McpServerView from '$components/McpServer.svelte';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
const server: IMcpServer = $derived(McpServer.findBy({ name: page.params.name }));
</script>
<McpServerView {server} />

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import McpServerView from '$components/McpServer.svelte';
import McpServer, { type IMcpServer } from '$lib/models/mcp-server';
const server: IMcpServer = $state(McpServer.default());
</script>
<McpServerView {server} />

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import type { PageProps } from './$types';
import Button from '$components/Button.svelte';
import Flex from '$components/Flex.svelte';
import Input from '$components/Input.svelte';
import Scrollable from '$components/Scrollable.svelte';
import Card from '$components/Smithery/Card.svelte';
import Configuration from '$components/Smithery/Configuration.svelte';
import type { McpConfig } from '$lib/mcp';
import McpServer from '$lib/models/mcp-server';
import type { CompactServer, ConfigSchema, Server } from '$lib/smithery';
import { Client } from '$lib/smithery/client';
import { debounce } from '$lib/util.svelte';
const { data }: PageProps = $props();
let servers = $state(data.servers);
let page: number = $state(1);
let query: string = $state('');
let serverToInstall: Server | null = $state(null);
function closeConfigure() {
serverToInstall = null;
}
async function loadNextPage() {
page += 1;
servers = servers.concat(await new Client().servers(page));
}
async function search() {
const client = new Client();
if (query !== '') {
servers = await client.search(query);
} else {
servers = await client.servers();
}
}
function configSchemaFor(server: Server): ConfigSchema {
return server.connections.findBy('type', 'stdio')?.configSchema || {};
}
async function configure(_server: CompactServer) {
serverToInstall = await new Client().server(_server.qualifiedName);
}
async function install(config: McpConfig) {
serverToInstall = null;
await McpServer.create(config);
}
</script>
{#if serverToInstall}
<Configuration
server={serverToInstall}
config={configSchemaFor(serverToInstall)}
onCancel={closeConfigure}
onInstall={install}
/>
{/if}
<Scrollable class="!h-content pr-2">
<Flex class="w-full">
<Input
bind:value={query}
class="mb-8"
label={false}
name="search"
onkeyup={debounce(search)}
placeholder="Search Smithery..."
/>
</Flex>
<Flex class="grid w-full auto-cols-max auto-rows-max grid-cols-3 items-start gap-4">
{#each servers as server (server.qualifiedName)}
<Card {server} onInstall={configure} />
{/each}
</Flex>
<Flex class="w-full justify-center">
<Button onclick={loadNextPage} class="border-light text-medium m-auto mt-8">
Load More
</Button>
</Flex>
</Scrollable>

View File

@@ -0,0 +1,16 @@
import type { PageLoad } from './$types';
import type { CompactServer } from '$lib/smithery';
import { Client } from '$lib/smithery/client';
interface Response {
servers: CompactServer[];
}
export const load: PageLoad = async (): Promise<Response> => {
const client = new Client();
return {
servers: await client.servers(),
}
}

View File

@@ -13,8 +13,12 @@ const config = {
prerender: {
'entries': [
"/",
"/error",
"/chat/[session_id]",
"/mcp-servers",
"/mcp-servers/new",
"/mcp-servers/[name]",
"/mcp-servers/smithery",
"/models",
"/settings",
],

View File

@@ -3,4 +3,7 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
build: {
sourcemap: true,
}
});