mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
feat(mcp): add React manager extension
This commit is contained in:
parent
6e69c5c6ba
commit
e01fce4691
20 changed files with 5043 additions and 23 deletions
18
eslint.config.js
Normal file
18
eslint.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config({
|
||||
files: ['src/scripts/extensions/**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
reactHooks.configs.flat.recommended,
|
||||
jsxA11y.flatConfigs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
});
|
||||
23
package.json
23
package.json
|
|
@ -9,16 +9,18 @@
|
|||
"ios": "node scripts/tauri-app.mjs --prepare-frontend ios",
|
||||
"build": "pnpm run tauri:build",
|
||||
"dev": "pnpm run tauri:dev",
|
||||
"check": "pnpm run check:frontend && pnpm run check:types && pnpm run check:logging-boundaries && pnpm run check:rust-boundaries && pnpm test && pnpm run check:rust:clippy",
|
||||
"check": "pnpm run check:frontend && pnpm run check:types && pnpm run check:lint && pnpm run check:logging-boundaries && pnpm run check:rust-boundaries && pnpm test && pnpm run check:rust:clippy",
|
||||
"check:frontend": "node scripts/check-frontend-guardrails.mjs",
|
||||
"check:frontend:baseline": "node scripts/check-frontend-guardrails.mjs --update-baseline",
|
||||
"check:types": "tsc -p tsconfig.host.json",
|
||||
"check:types": "tsc -p tsconfig.host.json && tsc -p tsconfig.ui.json",
|
||||
"check:lint": "eslint \"src/scripts/extensions/**/*.{ts,tsx}\"",
|
||||
"check:logging-boundaries": "node scripts/check-logging-boundaries.mjs",
|
||||
"check:rust-boundaries": "node scripts/check-rust-crate-boundaries.mjs",
|
||||
"check:rust:dev": "cargo check --manifest-path src-tauri/Cargo.toml --workspace --no-default-features",
|
||||
"check:rust:clippy": "cargo clippy --manifest-path src-tauri/Cargo.toml --workspace --all-targets -- -D warnings",
|
||||
"test": "pnpm run test:contracts && pnpm run test:rust",
|
||||
"test": "pnpm run test:contracts && pnpm run test:ui && pnpm run test:rust",
|
||||
"test:contracts": "pnpm run web:build && node --test --test-concurrency=1 \"tests/**/*.test.mjs\"",
|
||||
"test:ui": "rstest --config rstest.config.mjs",
|
||||
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml --workspace --no-default-features",
|
||||
"web:build": "rspack build --config rspack.config.js",
|
||||
"web:dev": "rspack build --config rspack.config.js --watch",
|
||||
|
|
@ -42,17 +44,28 @@
|
|||
"devDependencies": {
|
||||
"@rspack/cli": "^2.0.8",
|
||||
"@rspack/core": "^2.0.8",
|
||||
"@rstest/core": "^0.11.6",
|
||||
"@tauri-apps/cli": "^2.10.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.3",
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/jquery-cropper": "^1.0.4",
|
||||
"@types/jquery.transit": "^0.9.33",
|
||||
"@types/jqueryui": "^1.12.24",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^20.19.43",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/select2": "^4.0.63",
|
||||
"@types/toastr": "^2.1.43",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"happy-dom": "^20.11.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.66.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.4",
|
||||
|
|
@ -75,6 +88,8 @@
|
|||
"lodash": "^4.18.1",
|
||||
"moment": "^2.30.1",
|
||||
"morphdom": "^2.7.7",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"seedrandom": "^3.0.5",
|
||||
"showdown": "^2.1.0",
|
||||
"vue": "^3.5.33",
|
||||
|
|
|
|||
2853
pnpm-lock.yaml
generated
2853
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -25,22 +25,27 @@ const libraryCacheInputs = [
|
|||
|
||||
const agentSystemCacheInputs = [
|
||||
...commonCacheInputs,
|
||||
...listJavaScriptFiles('src/scripts/extensions/agent-system/src'),
|
||||
...listJavaScriptFiles('src/scripts/tauritavern/agent'),
|
||||
...listScriptFiles('src/scripts/extensions/agent-system/src'),
|
||||
...listScriptFiles('src/scripts/tauritavern/agent'),
|
||||
];
|
||||
|
||||
const mcpManagerCacheInputs = [
|
||||
...commonCacheInputs,
|
||||
...listScriptFiles('src/scripts/extensions/mcp-manager/src'),
|
||||
];
|
||||
|
||||
const tauriSettingUiCacheInputs = [
|
||||
...commonCacheInputs,
|
||||
...listJavaScriptFiles('src/scripts/tauri/setting/settings-app'),
|
||||
...listJavaScriptFiles('src/scripts/tauri/setting/dev-logs-app'),
|
||||
...listJavaScriptFiles('src/scripts/tauri/setting/sync-app'),
|
||||
...listScriptFiles('src/scripts/tauri/setting/settings-app'),
|
||||
...listScriptFiles('src/scripts/tauri/setting/dev-logs-app'),
|
||||
...listScriptFiles('src/scripts/tauri/setting/sync-app'),
|
||||
];
|
||||
|
||||
function resolveRepoPath(file) {
|
||||
return path.resolve(__dirname, file);
|
||||
}
|
||||
|
||||
function listJavaScriptFiles(relativeDir) {
|
||||
function listScriptFiles(relativeDir) {
|
||||
const root = resolveRepoPath(relativeDir);
|
||||
const results = [];
|
||||
const stack = [root];
|
||||
|
|
@ -55,7 +60,7 @@ function listJavaScriptFiles(relativeDir) {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && path.extname(entry.name) === '.js') {
|
||||
if (entry.isFile() && ['.js', '.ts', '.tsx'].includes(path.extname(entry.name))) {
|
||||
results.push(path.relative(__dirname, fullPath).replace(/\\/g, '/'));
|
||||
}
|
||||
}
|
||||
|
|
@ -94,7 +99,7 @@ function createPersistentCache(name, inputFiles) {
|
|||
}
|
||||
|
||||
const sharedResolve = {
|
||||
extensions: ['.js'],
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
alias: {
|
||||
'/lib.js': path.resolve(__dirname, 'src/lib.js'),
|
||||
'/script.js': path.resolve(__dirname, 'src/script.js'),
|
||||
|
|
@ -152,6 +157,31 @@ function createVueDefinePlugin() {
|
|||
});
|
||||
}
|
||||
|
||||
function createReactModule() {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
type: 'javascript/auto',
|
||||
use: {
|
||||
loader: 'builtin:swc-loader',
|
||||
options: {
|
||||
detectSyntax: 'auto',
|
||||
jsc: {
|
||||
transform: {
|
||||
react: {
|
||||
runtime: 'automatic',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const coreConfig = {
|
||||
name: 'vendor-libs',
|
||||
mode: 'production',
|
||||
|
|
@ -205,6 +235,31 @@ const agentSystemConfig = {
|
|||
],
|
||||
};
|
||||
|
||||
const mcpManagerConfig = {
|
||||
name: 'mcp-manager',
|
||||
mode: 'production',
|
||||
bail: true,
|
||||
target: ['web', 'es2020'],
|
||||
cache: createPersistentCache('mcp-manager', mcpManagerCacheInputs),
|
||||
entry: {
|
||||
index: './src/scripts/extensions/mcp-manager/src/index.tsx',
|
||||
},
|
||||
output: {
|
||||
filename: '[name].bundle.js',
|
||||
path: path.resolve(__dirname, 'src/scripts/extensions/mcp-manager/dist'),
|
||||
module: true,
|
||||
library: {
|
||||
type: 'module'
|
||||
},
|
||||
clean: true,
|
||||
},
|
||||
module: createReactModule(),
|
||||
resolve: sharedResolve,
|
||||
optimization: sharedOptimization,
|
||||
performance: sharedPerformance,
|
||||
stats: sharedStats,
|
||||
};
|
||||
|
||||
const tauriTavernSettingsConfig = {
|
||||
name: 'tauritavern-settings',
|
||||
dependencies: ['vendor-libs'],
|
||||
|
|
@ -235,4 +290,4 @@ const tauriTavernSettingsConfig = {
|
|||
],
|
||||
};
|
||||
|
||||
export default [coreConfig, agentSystemConfig, tauriTavernSettingsConfig];
|
||||
export default [coreConfig, agentSystemConfig, mcpManagerConfig, tauriTavernSettingsConfig];
|
||||
|
|
|
|||
17
rstest.config.mjs
Normal file
17
rstest.config.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from '@rstest/core';
|
||||
|
||||
export default defineConfig({
|
||||
include: ['src/scripts/extensions/**/*.test.tsx'],
|
||||
testEnvironment: 'happy-dom',
|
||||
tools: {
|
||||
swc: {
|
||||
jsc: {
|
||||
transform: {
|
||||
react: {
|
||||
runtime: 'automatic',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -50,6 +50,7 @@ const ENABLED_SYSTEM_EXTENSIONS: &[&str] = &[
|
|||
"vectors",
|
||||
"tauritavern-version",
|
||||
"agent-system",
|
||||
"mcp-manager",
|
||||
"translate",
|
||||
"tts",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -67,6 +67,29 @@ fn test_http_clients() -> Arc<HttpClientPool> {
|
|||
Arc::new(HttpClientPool::new(TEST_USER_AGENT))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovery_includes_the_first_party_mcp_manager() {
|
||||
let (root, user_extensions_dir, global_extensions_dir, source_store_root) = setup_paths().await;
|
||||
let repository = FileExtensionRepository::new(
|
||||
user_extensions_dir,
|
||||
global_extensions_dir,
|
||||
source_store_root,
|
||||
test_http_clients(),
|
||||
);
|
||||
|
||||
let extensions = repository
|
||||
.discover_extensions()
|
||||
.await
|
||||
.expect("discover extensions");
|
||||
assert!(
|
||||
extensions
|
||||
.iter()
|
||||
.any(|extension| extension.name == "mcp-manager")
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).await.expect("cleanup temp root");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_install_version_and_update_round_trip_over_smart_http() {
|
||||
let (root, user_extensions_dir, global_extensions_dir, source_store_root) = setup_paths().await;
|
||||
|
|
|
|||
48
src/scripts/extensions/mcp-manager/i18n/zh-cn.json
Normal file
48
src/scripts/extensions/mcp-manager/i18n/zh-cn.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"mcp_manager.active": "已启用",
|
||||
"mcp_manager.activate": "启用",
|
||||
"mcp_manager.activateHttpNote": "该端点使用未加密的 HTTP。同一网络中的其他设备可能观察或修改 MCP 流量。启用服务器不会授予任何工具权限。",
|
||||
"mcp_manager.activateNote": "启用后可以向这个确切端点手动发起发现请求,但不会授予任何工具权限。",
|
||||
"mcp_manager.activateTitle": "启用这个 MCP 服务器?",
|
||||
"mcp_manager.addServer": "添加服务器",
|
||||
"mcp_manager.addServerTitle": "添加 MCP 服务器",
|
||||
"mcp_manager.adding": "正在添加…",
|
||||
"mcp_manager.cancel": "取消",
|
||||
"mcp_manager.configuredToolsMissing": "本次发现未提供的工具",
|
||||
"mcp_manager.diagnostics": "发现说明",
|
||||
"mcp_manager.discoverTools": "发现工具",
|
||||
"mcp_manager.discovering": "正在发现工具…",
|
||||
"mcp_manager.discoveryIdentity": "{implementation} · MCP {protocol}",
|
||||
"mcp_manager.displayName": "名称",
|
||||
"mcp_manager.displayNamePlaceholder": "本地工具",
|
||||
"mcp_manager.emptyHint": "添加 Streamable HTTP 端点,查看它提供的工具。",
|
||||
"mcp_manager.emptyTitle": "还没有 MCP 服务器",
|
||||
"mcp_manager.endpoint": "端点",
|
||||
"mcp_manager.endpointHint": "仅支持 Streamable HTTP。端点是注册身份的一部分,创建后不可更改。",
|
||||
"mcp_manager.endpointInvalid": "请输入有效的 http:// 或 https:// 地址。",
|
||||
"mcp_manager.hostApiUnavailable": "TauriTavern MCP Host API 不可用",
|
||||
"mcp_manager.mcp": "MCP",
|
||||
"mcp_manager.nameRequired": "请输入名称。",
|
||||
"mcp_manager.newServerNote": "新服务器默认暂停,发现的每个工具默认关闭。",
|
||||
"mcp_manager.paused": "已暂停",
|
||||
"mcp_manager.pausedHint": "已暂停——启用后即可发现该服务器的工具。",
|
||||
"mcp_manager.permissionAllow": "允许",
|
||||
"mcp_manager.permissionAsk": "询问",
|
||||
"mcp_manager.permissionFor": "{name} 的权限",
|
||||
"mcp_manager.permissionOff": "关闭",
|
||||
"mcp_manager.popupUnavailable": "SillyTavern Popup API 不可用",
|
||||
"mcp_manager.refreshTools": "刷新工具",
|
||||
"mcp_manager.remove": "移除",
|
||||
"mcp_manager.removeNote": "该注册及其保存的全部工具权限都会被移除。",
|
||||
"mcp_manager.removeTitle": "移除这个 MCP 服务器?",
|
||||
"mcp_manager.rename": "重命名",
|
||||
"mcp_manager.renameTitle": "重命名服务器",
|
||||
"mcp_manager.retry": "重试",
|
||||
"mcp_manager.schemaDetails": "Schema 与提示",
|
||||
"mcp_manager.serverCount": "服务器 · {count}",
|
||||
"mcp_manager.setOff": "设为关闭",
|
||||
"mcp_manager.storageIssues": "需要处理的注册文件",
|
||||
"mcp_manager.toolCount": "{count} 个工具",
|
||||
"mcp_manager.toggleTools": "显示或隐藏工具",
|
||||
"mcp_manager.unknownError": "未知错误"
|
||||
}
|
||||
48
src/scripts/extensions/mcp-manager/i18n/zh-tw.json
Normal file
48
src/scripts/extensions/mcp-manager/i18n/zh-tw.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"mcp_manager.active": "已啟用",
|
||||
"mcp_manager.activate": "啟用",
|
||||
"mcp_manager.activateHttpNote": "此端點使用未加密的 HTTP。同一網路中的其他裝置可能觀察或修改 MCP 流量。啟用伺服器不會授予任何工具權限。",
|
||||
"mcp_manager.activateNote": "啟用後可以向這個確切端點手動發起探索請求,但不會授予任何工具權限。",
|
||||
"mcp_manager.activateTitle": "啟用這個 MCP 伺服器?",
|
||||
"mcp_manager.addServer": "新增伺服器",
|
||||
"mcp_manager.addServerTitle": "新增 MCP 伺服器",
|
||||
"mcp_manager.adding": "正在新增…",
|
||||
"mcp_manager.cancel": "取消",
|
||||
"mcp_manager.configuredToolsMissing": "本次探索未提供的工具",
|
||||
"mcp_manager.diagnostics": "探索說明",
|
||||
"mcp_manager.discoverTools": "探索工具",
|
||||
"mcp_manager.discovering": "正在探索工具…",
|
||||
"mcp_manager.discoveryIdentity": "{implementation} · MCP {protocol}",
|
||||
"mcp_manager.displayName": "名稱",
|
||||
"mcp_manager.displayNamePlaceholder": "本機工具",
|
||||
"mcp_manager.emptyHint": "新增 Streamable HTTP 端點,查看它提供的工具。",
|
||||
"mcp_manager.emptyTitle": "還沒有 MCP 伺服器",
|
||||
"mcp_manager.endpoint": "端點",
|
||||
"mcp_manager.endpointHint": "僅支援 Streamable HTTP。端點是註冊身份的一部分,建立後不可更改。",
|
||||
"mcp_manager.endpointInvalid": "請輸入有效的 http:// 或 https:// 位址。",
|
||||
"mcp_manager.hostApiUnavailable": "TauriTavern MCP Host API 不可用",
|
||||
"mcp_manager.mcp": "MCP",
|
||||
"mcp_manager.nameRequired": "請輸入名稱。",
|
||||
"mcp_manager.newServerNote": "新伺服器預設暫停,探索到的每個工具預設關閉。",
|
||||
"mcp_manager.paused": "已暫停",
|
||||
"mcp_manager.pausedHint": "已暫停——啟用後即可探索此伺服器的工具。",
|
||||
"mcp_manager.permissionAllow": "允許",
|
||||
"mcp_manager.permissionAsk": "詢問",
|
||||
"mcp_manager.permissionFor": "{name} 的權限",
|
||||
"mcp_manager.permissionOff": "關閉",
|
||||
"mcp_manager.popupUnavailable": "SillyTavern Popup API 不可用",
|
||||
"mcp_manager.refreshTools": "重新整理工具",
|
||||
"mcp_manager.remove": "移除",
|
||||
"mcp_manager.removeNote": "該註冊及其儲存的全部工具權限都會被移除。",
|
||||
"mcp_manager.removeTitle": "移除這個 MCP 伺服器?",
|
||||
"mcp_manager.rename": "重新命名",
|
||||
"mcp_manager.renameTitle": "重新命名伺服器",
|
||||
"mcp_manager.retry": "重試",
|
||||
"mcp_manager.schemaDetails": "Schema 與提示",
|
||||
"mcp_manager.serverCount": "伺服器 · {count}",
|
||||
"mcp_manager.setOff": "設為關閉",
|
||||
"mcp_manager.storageIssues": "需要處理的註冊檔案",
|
||||
"mcp_manager.toolCount": "{count} 個工具",
|
||||
"mcp_manager.toggleTools": "顯示或隱藏工具",
|
||||
"mcp_manager.unknownError": "未知錯誤"
|
||||
}
|
||||
16
src/scripts/extensions/mcp-manager/manifest.json
Normal file
16
src/scripts/extensions/mcp-manager/manifest.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"display_name": "MCP",
|
||||
"loading_order": 26,
|
||||
"requires": [],
|
||||
"optional": [],
|
||||
"js": "dist/index.bundle.js",
|
||||
"css": "style.css",
|
||||
"i18n": {
|
||||
"zh-cn": "i18n/zh-cn.json",
|
||||
"zh-tw": "i18n/zh-tw.json"
|
||||
},
|
||||
"author": "TauriTavern",
|
||||
"version": "0.1.0",
|
||||
"description": "First-party MCP server registration, discovery, and permission manager.",
|
||||
"homePage": "https://github.com/Darkatse/TauriTavern"
|
||||
}
|
||||
283
src/scripts/extensions/mcp-manager/src/McpManagerApp.test.tsx
Normal file
283
src/scripts/extensions/mcp-manager/src/McpManagerApp.test.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test } from '@rstest/core';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
McpManagerApp,
|
||||
type McpManagerActions,
|
||||
type McpManagerInitial,
|
||||
} from './McpManagerApp';
|
||||
import { openAddServerDialog, tr } from './host';
|
||||
|
||||
const SERVER_ID = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
function server(state: TauriTavernMcpServerState = 'paused'): TauriTavernMcpServer {
|
||||
return {
|
||||
id: SERVER_ID,
|
||||
displayName: 'Local tools',
|
||||
endpoint: 'http://127.0.0.1:3000/mcp',
|
||||
state,
|
||||
toolPermissions: {},
|
||||
};
|
||||
}
|
||||
|
||||
function discovery(): TauriTavernMcpDiscoveryResult {
|
||||
return {
|
||||
registrationId: SERVER_ID,
|
||||
protocolVersion: '2026-07-28',
|
||||
serverName: 'Local MCP',
|
||||
tools: [{
|
||||
id: `mcp/${SERVER_ID}:search`,
|
||||
nativeName: 'search',
|
||||
title: 'Search files',
|
||||
description: 'Search local files by name.',
|
||||
inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
|
||||
annotations: {},
|
||||
permission: 'off',
|
||||
}],
|
||||
diagnostics: [],
|
||||
staleTools: [],
|
||||
};
|
||||
}
|
||||
|
||||
function unexpected(name: string): Promise<never> {
|
||||
return Promise.reject(new Error(`Unexpected MCP Manager action: ${name}`));
|
||||
}
|
||||
|
||||
function actions(overrides: Partial<McpManagerActions> = {}): McpManagerActions {
|
||||
return {
|
||||
addServer: () => unexpected('addServer'),
|
||||
renameServer: () => unexpected('renameServer'),
|
||||
setState: () => unexpected('setState'),
|
||||
remove: () => unexpected('remove'),
|
||||
discover: () => unexpected('discover'),
|
||||
setPermission: () => unexpected('setPermission'),
|
||||
confirmActivate: () => Promise.resolve(true),
|
||||
confirmRemove: () => Promise.resolve(true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function initial(servers: TauriTavernMcpServer[] = []): McpManagerInitial {
|
||||
return { servers, storageIssues: [] };
|
||||
}
|
||||
|
||||
type TestPopupOptions = {
|
||||
onOpen?: () => void;
|
||||
onClosing?: (popup: TestPopup) => Promise<boolean>;
|
||||
};
|
||||
|
||||
class TestPopup {
|
||||
static current: TestPopup | undefined;
|
||||
|
||||
result: unknown;
|
||||
private resolve: ((value: unknown) => void) | undefined;
|
||||
|
||||
constructor(
|
||||
readonly content: Element,
|
||||
type: number,
|
||||
inputValue: string,
|
||||
private readonly options: TestPopupOptions,
|
||||
) {
|
||||
void type;
|
||||
void inputValue;
|
||||
TestPopup.current = this;
|
||||
}
|
||||
|
||||
show(): Promise<unknown> {
|
||||
document.body.append(this.content);
|
||||
this.options.onOpen?.();
|
||||
return new Promise(resolve => {
|
||||
this.resolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async close(result: unknown): Promise<boolean> {
|
||||
this.result = result;
|
||||
if (this.options.onClosing && !await this.options.onClosing(this)) {
|
||||
return false;
|
||||
}
|
||||
this.content.remove();
|
||||
this.resolve?.(result);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function installPopupHost(): void {
|
||||
Object.defineProperty(window, 'SillyTavern', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getContext: () => ({
|
||||
Popup: TestPopup,
|
||||
POPUP_TYPE: { CONFIRM: 1, INPUT: 2 },
|
||||
POPUP_RESULT: { AFFIRMATIVE: 1 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
TestPopup.current?.content.remove();
|
||||
TestPopup.current = undefined;
|
||||
Reflect.deleteProperty(window, 'SillyTavern');
|
||||
});
|
||||
|
||||
test('adds a server through the dialog action and lists it paused', async () => {
|
||||
const created = server('paused');
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<McpManagerApp
|
||||
initial={initial()}
|
||||
tr={tr}
|
||||
actions={actions({ addServer: () => Promise.resolve(created) })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add server' }));
|
||||
|
||||
expect(await screen.findByText('Local tools')).toBeTruthy();
|
||||
expect(screen.getByText('http://127.0.0.1:3000/mcp')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Paused' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('discovers tools on expand and persists an explicit permission choice', async () => {
|
||||
const permissionCalls: Array<{
|
||||
registrationId: string;
|
||||
nativeName: string;
|
||||
permission: TauriTavernMcpToolPermission;
|
||||
}> = [];
|
||||
let discoverCalls = 0;
|
||||
const activeServer = server('active');
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<McpManagerApp
|
||||
initial={initial([activeServer])}
|
||||
tr={tr}
|
||||
actions={actions({
|
||||
discover: () => {
|
||||
discoverCalls += 1;
|
||||
return Promise.resolve(discovery());
|
||||
},
|
||||
setPermission: input => {
|
||||
permissionCalls.push(input);
|
||||
return Promise.resolve({
|
||||
...activeServer,
|
||||
toolPermissions: { [input.nativeName]: 'allow' },
|
||||
});
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show or hide tools' }));
|
||||
expect(await screen.findByText('Search files')).toBeTruthy();
|
||||
expect(discoverCalls).toBe(1);
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Allow' }));
|
||||
|
||||
await waitFor(() => expect(permissionCalls).toEqual([{
|
||||
registrationId: SERVER_ID,
|
||||
nativeName: 'search',
|
||||
permission: 'allow',
|
||||
}]));
|
||||
expect(screen.getByRole<HTMLInputElement>('radio', { name: 'Allow' }).checked).toBe(true);
|
||||
});
|
||||
|
||||
test('does not discover while paused and explains the state instead', async () => {
|
||||
let discoverCalls = 0;
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<McpManagerApp
|
||||
initial={initial([server('paused')])}
|
||||
tr={tr}
|
||||
actions={actions({
|
||||
discover: () => {
|
||||
discoverCalls += 1;
|
||||
return Promise.resolve(discovery());
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show or hide tools' }));
|
||||
|
||||
expect(await screen.findByText(/Paused — activate to discover/)).toBeTruthy();
|
||||
expect(discoverCalls).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps a discovery error while an unrelated action is cancelled', async () => {
|
||||
let resolveRename!: (value: TauriTavernMcpServer | null) => void;
|
||||
const renameResult = new Promise<TauriTavernMcpServer | null>(resolve => {
|
||||
resolveRename = resolve;
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<McpManagerApp
|
||||
initial={initial([server('active')])}
|
||||
tr={tr}
|
||||
actions={actions({
|
||||
discover: () => Promise.reject(new Error('discovery failed')),
|
||||
renameServer: () => renameResult,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show or hide tools' }));
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('discovery failed');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rename' }));
|
||||
expect(screen.queryByText('Discovering tools…')).toBeNull();
|
||||
expect(screen.getByRole('alert').textContent).toBe('discovery failed');
|
||||
|
||||
await act(async () => {
|
||||
resolveRename(null);
|
||||
await renameResult;
|
||||
});
|
||||
expect(screen.getByRole('alert').textContent).toBe('discovery failed');
|
||||
});
|
||||
|
||||
test('add-server popup validates in place, preserves failures, and returns the created server', async () => {
|
||||
installPopupHost();
|
||||
const drafts: Array<{ displayName: string; endpoint: string }> = [];
|
||||
const opened = openAddServerDialog(draft => {
|
||||
drafts.push(draft);
|
||||
if (drafts.length === 1) {
|
||||
return Promise.reject(new Error('endpoint host is not allowed'));
|
||||
}
|
||||
return Promise.resolve(server());
|
||||
});
|
||||
const popup = TestPopup.current;
|
||||
if (!popup) {
|
||||
throw new Error('Add-server popup was not created');
|
||||
}
|
||||
|
||||
const name = screen.getByLabelText<HTMLInputElement>('Name');
|
||||
const endpoint = screen.getByLabelText<HTMLInputElement>('Endpoint');
|
||||
expect(document.activeElement).toBe(name);
|
||||
|
||||
expect(await popup.close(1)).toBe(false);
|
||||
expect(screen.getByRole('alert').textContent).toBe('Enter a name.');
|
||||
expect(drafts).toEqual([]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(name, ' Local tools ');
|
||||
await user.type(endpoint, 'file:///etc/passwd');
|
||||
expect(await popup.close(1)).toBe(false);
|
||||
expect(screen.getByRole('alert').textContent).toBe('Enter a valid http:// or https:// URL.');
|
||||
expect(drafts).toEqual([]);
|
||||
|
||||
await user.clear(endpoint);
|
||||
await user.type(endpoint, ' http://127.0.0.1:3000/mcp ');
|
||||
expect(await popup.close(1)).toBe(false);
|
||||
expect(screen.getByRole('alert').textContent).toBe('endpoint host is not allowed');
|
||||
expect(name.value).toBe(' Local tools ');
|
||||
expect(endpoint.value).toBe(' http://127.0.0.1:3000/mcp ');
|
||||
|
||||
expect(await popup.close(1)).toBe(true);
|
||||
expect(await opened).toEqual(server());
|
||||
expect(drafts).toEqual([
|
||||
{ displayName: 'Local tools', endpoint: 'http://127.0.0.1:3000/mcp' },
|
||||
{ displayName: 'Local tools', endpoint: 'http://127.0.0.1:3000/mcp' },
|
||||
]);
|
||||
});
|
||||
288
src/scripts/extensions/mcp-manager/src/McpManagerApp.tsx
Normal file
288
src/scripts/extensions/mcp-manager/src/McpManagerApp.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { errorText, type McpTranslator } from './host';
|
||||
import { ServerRow } from './ServerRow';
|
||||
|
||||
export type McpManagerInitial = Awaited<ReturnType<TauriTavernMcpApi['servers']['list']>>;
|
||||
|
||||
export type McpManagerActions = {
|
||||
/** Opens the Add-server dialog; resolves the created server, or null when cancelled. */
|
||||
addServer: () => Promise<TauriTavernMcpServer | null>;
|
||||
/** Prompts for a new name and renames; resolves the updated server, or null when unchanged. */
|
||||
renameServer: (server: TauriTavernMcpServer) => Promise<TauriTavernMcpServer | null>;
|
||||
setState: TauriTavernMcpApi['servers']['setState'];
|
||||
remove: TauriTavernMcpApi['servers']['remove'];
|
||||
discover: TauriTavernMcpApi['servers']['discover'];
|
||||
setPermission: TauriTavernMcpApi['tools']['setPermission'];
|
||||
confirmActivate: (server: TauriTavernMcpServer) => Promise<boolean>;
|
||||
confirmRemove: (server: TauriTavernMcpServer) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type McpManagerAppProps = {
|
||||
initial: McpManagerInitial;
|
||||
actions: McpManagerActions;
|
||||
tr: McpTranslator;
|
||||
};
|
||||
|
||||
type ServerStatus = {
|
||||
activity: 'discover' | 'mutate' | undefined;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function McpManagerApp({ initial, actions, tr }: McpManagerAppProps) {
|
||||
const [servers, setServers] = useState(initial.servers);
|
||||
const [discoveries, setDiscoveries] = useState<Record<string, TauriTavernMcpDiscoveryResult>>({});
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [statuses, setStatuses] = useState<Record<string, ServerStatus>>({});
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [panelError, setPanelError] = useState('');
|
||||
|
||||
const sortedServers = [...servers].sort((left, right) => (
|
||||
left.displayName.localeCompare(right.displayName) || left.id.localeCompare(right.id)
|
||||
));
|
||||
|
||||
function updateStatus(id: string, patch: Partial<ServerStatus>): void {
|
||||
setStatuses(current => ({
|
||||
...current,
|
||||
[id]: { activity: undefined, error: '', ...current[id], ...patch },
|
||||
}));
|
||||
}
|
||||
|
||||
function replaceServer(updated: TauriTavernMcpServer): void {
|
||||
setServers(current => current.map(server => (server.id === updated.id ? updated : server)));
|
||||
}
|
||||
|
||||
async function runServerAction(
|
||||
id: string,
|
||||
activity: NonNullable<ServerStatus['activity']>,
|
||||
operation: () => Promise<boolean>,
|
||||
): Promise<void> {
|
||||
updateStatus(id, activity === 'discover' ? { activity, error: '' } : { activity });
|
||||
try {
|
||||
if (await operation()) {
|
||||
updateStatus(id, { error: '' });
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus(id, { error: errorText(error, tr('unknownError')) });
|
||||
} finally {
|
||||
updateStatus(id, { activity: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
async function addServer(): Promise<void> {
|
||||
setPanelError('');
|
||||
setAdding(true);
|
||||
try {
|
||||
const server = await actions.addServer();
|
||||
if (server) {
|
||||
setServers(current => [...current, server]);
|
||||
}
|
||||
} catch (error) {
|
||||
setPanelError(errorText(error, tr('unknownError')));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameServer(server: TauriTavernMcpServer): Promise<void> {
|
||||
await runServerAction(server.id, 'mutate', async () => {
|
||||
const updated = await actions.renameServer(server);
|
||||
if (!updated) {
|
||||
return false;
|
||||
}
|
||||
replaceServer(updated);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleServer(server: TauriTavernMcpServer): Promise<void> {
|
||||
const nextState: TauriTavernMcpServerState = server.state === 'active' ? 'paused' : 'active';
|
||||
await runServerAction(server.id, 'mutate', async () => {
|
||||
if (nextState === 'active' && !await actions.confirmActivate(server)) {
|
||||
return false;
|
||||
}
|
||||
replaceServer(await actions.setState({ registrationId: server.id, state: nextState }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function removeServer(server: TauriTavernMcpServer): Promise<void> {
|
||||
await runServerAction(server.id, 'mutate', async () => {
|
||||
if (!await actions.confirmRemove(server)) {
|
||||
return false;
|
||||
}
|
||||
await actions.remove(server.id);
|
||||
setServers(current => current.filter(item => item.id !== server.id));
|
||||
setDiscoveries(current => {
|
||||
const next = { ...current };
|
||||
delete next[server.id];
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function discover(server: TauriTavernMcpServer): Promise<void> {
|
||||
await runServerAction(server.id, 'discover', async () => {
|
||||
const discovery = await actions.discover(server.id);
|
||||
setDiscoveries(current => ({ ...current, [server.id]: discovery }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExpand(server: TauriTavernMcpServer): void {
|
||||
const next = !expanded[server.id];
|
||||
setExpanded(current => ({ ...current, [server.id]: next }));
|
||||
// Expanding an active server means "show me its tools" — discover right away.
|
||||
// A previous failure stays visible instead of refiring on every toggle.
|
||||
const status = statuses[server.id];
|
||||
if (next && server.state === 'active' && !discoveries[server.id] && !status?.error && !status?.activity) {
|
||||
void discover(server);
|
||||
}
|
||||
}
|
||||
|
||||
async function setPermission(
|
||||
server: TauriTavernMcpServer,
|
||||
tool: TauriTavernMcpTool,
|
||||
permission: TauriTavernMcpToolPermission,
|
||||
): Promise<void> {
|
||||
await runServerAction(server.id, 'mutate', async () => {
|
||||
replaceServer(await actions.setPermission({
|
||||
registrationId: server.id,
|
||||
nativeName: tool.nativeName,
|
||||
permission,
|
||||
}));
|
||||
setDiscoveries(current => {
|
||||
const discovery = current[server.id];
|
||||
if (!discovery) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[server.id]: {
|
||||
...discovery,
|
||||
tools: discovery.tools.map(item => (
|
||||
item.id === tool.id ? { ...item, permission } : item
|
||||
)),
|
||||
},
|
||||
};
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function clearStalePermission(server: TauriTavernMcpServer, nativeName: string): Promise<void> {
|
||||
await runServerAction(server.id, 'mutate', async () => {
|
||||
replaceServer(await actions.setPermission({
|
||||
registrationId: server.id,
|
||||
nativeName,
|
||||
permission: 'off',
|
||||
}));
|
||||
setDiscoveries(current => {
|
||||
const discovery = current[server.id];
|
||||
if (!discovery) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[server.id]: {
|
||||
...discovery,
|
||||
staleTools: discovery.staleTools.filter(tool => tool.nativeName !== nativeName),
|
||||
},
|
||||
};
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="mcp_manager_settings" className="tt-mcp-root">
|
||||
<div className="inline-drawer">
|
||||
<div className="inline-drawer-toggle inline-drawer-header tt-mcp-drawer-header">
|
||||
<span className="tt-mcp-title">
|
||||
<i className="fa-solid fa-plug-circle-bolt" aria-hidden="true" />
|
||||
<b>{tr('mcp')}</b>
|
||||
</span>
|
||||
<div className="inline-drawer-icon fa-solid fa-circle-chevron-down down" />
|
||||
</div>
|
||||
|
||||
<div className="inline-drawer-content">
|
||||
{initial.storageIssues.length > 0 && (
|
||||
<section className="tt-mcp-alert" role="status">
|
||||
<b>
|
||||
<i className="fa-solid fa-triangle-exclamation" aria-hidden="true" />
|
||||
{tr('storageIssues')}
|
||||
</b>
|
||||
<ul>
|
||||
{initial.storageIssues.map(issue => (
|
||||
<li key={issue.fileName}>
|
||||
<code>{issue.fileName}</code>
|
||||
<span>{issue.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{panelError && <p className="tt-mcp-error" role="alert">{panelError}</p>}
|
||||
|
||||
{servers.length === 0 ? (
|
||||
<div className="tt-mcp-empty">
|
||||
<i className="fa-solid fa-plug-circle-bolt" aria-hidden="true" />
|
||||
<b>{tr('emptyTitle')}</b>
|
||||
<span>{tr('emptyHint')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="menu_button menu_button_icon"
|
||||
disabled={adding}
|
||||
onClick={() => void addServer()}
|
||||
>
|
||||
<i className={`fa-solid ${adding ? 'fa-circle-notch fa-spin' : 'fa-plus'}`} aria-hidden="true" />
|
||||
<span>{tr('addServer')}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="tt-mcp-toolbar">
|
||||
<span className="tt-mcp-count">{tr('serverCount', { count: servers.length })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="menu_button menu_button_icon"
|
||||
disabled={adding}
|
||||
onClick={() => void addServer()}
|
||||
>
|
||||
<i className={`fa-solid ${adding ? 'fa-circle-notch fa-spin' : 'fa-plus'}`} aria-hidden="true" />
|
||||
<span>{tr('addServer')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="tt-mcp-servers">
|
||||
{sortedServers.map(server => {
|
||||
const status = statuses[server.id];
|
||||
return (
|
||||
<ServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
discovery={discoveries[server.id]}
|
||||
expanded={expanded[server.id] === true}
|
||||
busy={status?.activity !== undefined}
|
||||
discovering={status?.activity === 'discover'}
|
||||
error={status?.error ?? ''}
|
||||
tr={tr}
|
||||
onToggleExpand={() => toggleExpand(server)}
|
||||
onToggleState={() => void toggleServer(server)}
|
||||
onRename={() => void renameServer(server)}
|
||||
onRemove={() => void removeServer(server)}
|
||||
onDiscover={() => void discover(server)}
|
||||
onSetPermission={(tool, permission) => void setPermission(server, tool, permission)}
|
||||
onClearStale={nativeName => void clearStalePermission(server, nativeName)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
194
src/scripts/extensions/mcp-manager/src/ServerRow.tsx
Normal file
194
src/scripts/extensions/mcp-manager/src/ServerRow.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import type { McpTranslator } from './host';
|
||||
import { ToolItem } from './ToolItem';
|
||||
|
||||
function discoveryIdentity(discovery: TauriTavernMcpDiscoveryResult, tr: McpTranslator): string {
|
||||
const implementation = [discovery.serverName, discovery.serverVersion].filter(Boolean).join(' ');
|
||||
return implementation
|
||||
? tr('discoveryIdentity', { implementation, protocol: discovery.protocolVersion })
|
||||
: `MCP ${discovery.protocolVersion}`;
|
||||
}
|
||||
|
||||
type ServerRowProps = {
|
||||
server: TauriTavernMcpServer;
|
||||
discovery: TauriTavernMcpDiscoveryResult | undefined;
|
||||
expanded: boolean;
|
||||
busy: boolean;
|
||||
discovering: boolean;
|
||||
error: string;
|
||||
tr: McpTranslator;
|
||||
onToggleExpand: () => void;
|
||||
onToggleState: () => void;
|
||||
onRename: () => void;
|
||||
onRemove: () => void;
|
||||
onDiscover: () => void;
|
||||
onSetPermission: (tool: TauriTavernMcpTool, permission: TauriTavernMcpToolPermission) => void;
|
||||
onClearStale: (nativeName: string) => void;
|
||||
};
|
||||
|
||||
export function ServerRow({
|
||||
server,
|
||||
discovery,
|
||||
expanded,
|
||||
busy,
|
||||
discovering,
|
||||
error,
|
||||
tr,
|
||||
onToggleExpand,
|
||||
onToggleState,
|
||||
onRename,
|
||||
onRemove,
|
||||
onDiscover,
|
||||
onSetPermission,
|
||||
onClearStale,
|
||||
}: ServerRowProps) {
|
||||
const active = server.state === 'active';
|
||||
|
||||
return (
|
||||
<article className={`tt-mcp-server${active ? ' is-active' : ''}${expanded ? ' is-expanded' : ''}`}>
|
||||
<div className="tt-mcp-server-row">
|
||||
<span className="tt-mcp-lamp" aria-hidden="true" />
|
||||
<div className="tt-mcp-identity">
|
||||
<b className="tt-mcp-name" title={server.displayName}>{server.displayName}</b>
|
||||
<code className="tt-mcp-endpoint" title={server.endpoint}>{server.endpoint}</code>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`tt-mcp-state${active ? ' is-active' : ''}`}
|
||||
aria-pressed={active}
|
||||
disabled={busy}
|
||||
onClick={onToggleState}
|
||||
>
|
||||
<span className="tt-mcp-state-dot" aria-hidden="true" />
|
||||
{tr(active ? 'active' : 'paused')}
|
||||
</button>
|
||||
<div className="tt-mcp-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="tt-mcp-icon-btn"
|
||||
title={tr('rename')}
|
||||
aria-label={tr('rename')}
|
||||
disabled={busy}
|
||||
onClick={onRename}
|
||||
>
|
||||
<i className="fa-solid fa-pen" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tt-mcp-icon-btn is-danger"
|
||||
title={tr('remove')}
|
||||
aria-label={tr('remove')}
|
||||
disabled={busy}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<i className="fa-solid fa-trash-can" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tt-mcp-icon-btn tt-mcp-chevron"
|
||||
title={tr('toggleTools')}
|
||||
aria-label={tr('toggleTools')}
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<i className="fa-solid fa-chevron-down" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !expanded && <p className="tt-mcp-error" role="alert">{error}</p>}
|
||||
|
||||
{expanded && (
|
||||
<div className="tt-mcp-console">
|
||||
{error && <p className="tt-mcp-error" role="alert">{error}</p>}
|
||||
{!discovery && !active && (
|
||||
<p className="tt-mcp-note">
|
||||
<i className="fa-solid fa-pause" aria-hidden="true" />
|
||||
{tr('pausedHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!discovery && active && discovering && (
|
||||
<p className="tt-mcp-note">
|
||||
<i className="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
|
||||
{tr('discovering')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!discovery && active && !discovering && (
|
||||
<button type="button" className="menu_button menu_button_icon" disabled={busy} onClick={onDiscover}>
|
||||
<i className="fa-solid fa-arrows-rotate" aria-hidden="true" />
|
||||
<span>{error ? tr('retry') : tr('discoverTools')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{discovery && (
|
||||
<>
|
||||
<div className="tt-mcp-console-head">
|
||||
<span className="tt-mcp-console-meta">{discoveryIdentity(discovery, tr)}</span>
|
||||
<span className="tt-mcp-console-count">{tr('toolCount', { count: discovery.tools.length })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="tt-mcp-icon-btn"
|
||||
title={tr('refreshTools')}
|
||||
aria-label={tr('refreshTools')}
|
||||
disabled={!active || busy}
|
||||
onClick={onDiscover}
|
||||
>
|
||||
<i className={`fa-solid ${discovering ? 'fa-circle-notch fa-spin' : 'fa-arrows-rotate'}`} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{discovery.diagnostics.length > 0 && (
|
||||
<details className="tt-mcp-details">
|
||||
<summary>{tr('diagnostics')} · {discovery.diagnostics.length}</summary>
|
||||
<ul>
|
||||
{discovery.diagnostics.map(diagnostic => (
|
||||
<li key={`${diagnostic.code}:${diagnostic.nativeName ?? ''}:${diagnostic.message}`}>
|
||||
<b>{diagnostic.nativeName ?? diagnostic.code}</b>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<ul className="tt-mcp-tools">
|
||||
{discovery.tools.map(tool => (
|
||||
<ToolItem
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
busy={busy}
|
||||
tr={tr}
|
||||
onSetPermission={permission => onSetPermission(tool, permission)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{discovery.staleTools.length > 0 && (
|
||||
<div className="tt-mcp-stale">
|
||||
<b>{tr('configuredToolsMissing')}</b>
|
||||
<ul>
|
||||
{discovery.staleTools.map(tool => (
|
||||
<li key={tool.nativeName}>
|
||||
<code>{tool.nativeName}</code>
|
||||
<span>{tr(tool.permission === 'allow' ? 'permissionAllow' : 'permissionAsk')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="menu_button"
|
||||
disabled={busy}
|
||||
onClick={() => onClearStale(tool.nativeName)}
|
||||
>
|
||||
{tr('setOff')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
116
src/scripts/extensions/mcp-manager/src/ToolItem.tsx
Normal file
116
src/scripts/extensions/mcp-manager/src/ToolItem.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { McpMessageKey, McpTranslator } from './host';
|
||||
|
||||
const PERMISSIONS = ['off', 'ask', 'allow'] as const satisfies readonly TauriTavernMcpToolPermission[];
|
||||
|
||||
const PERMISSION_LABELS: Record<TauriTavernMcpToolPermission, McpMessageKey> = {
|
||||
off: 'permissionOff',
|
||||
ask: 'permissionAsk',
|
||||
allow: 'permissionAllow',
|
||||
};
|
||||
|
||||
type SchemaParam = {
|
||||
name: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the human-facing surface of a JSON Schema 2020-12 object schema:
|
||||
* property names, whether they are required, and their one-line descriptions.
|
||||
* Non-object shapes simply yield no params; the raw schema stays one click away.
|
||||
*/
|
||||
function extractSchemaParams(inputSchema: Record<string, unknown>): SchemaParam[] {
|
||||
const { properties, required } = inputSchema;
|
||||
if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) {
|
||||
return [];
|
||||
}
|
||||
const requiredNames = new Set(
|
||||
Array.isArray(required) ? required.filter((name): name is string => typeof name === 'string') : [],
|
||||
);
|
||||
return Object.entries(properties as Record<string, unknown>).map(([name, schema]) => {
|
||||
const description = (
|
||||
typeof schema === 'object'
|
||||
&& schema !== null
|
||||
&& 'description' in schema
|
||||
&& typeof schema.description === 'string'
|
||||
) ? schema.description : '';
|
||||
return { name, required: requiredNames.has(name), description };
|
||||
});
|
||||
}
|
||||
|
||||
function toolDetails(tool: TauriTavernMcpTool): string {
|
||||
return JSON.stringify({
|
||||
inputSchema: tool.inputSchema,
|
||||
outputSchema: tool.outputSchema,
|
||||
annotations: tool.annotations,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
type PermissionGroupProps = {
|
||||
tool: TauriTavernMcpTool;
|
||||
busy: boolean;
|
||||
tr: McpTranslator;
|
||||
onSetPermission: (permission: TauriTavernMcpToolPermission) => void;
|
||||
};
|
||||
|
||||
function PermissionGroup({ tool, busy, tr, onSetPermission }: PermissionGroupProps) {
|
||||
return (
|
||||
<div className="tt-mcp-perm" role="radiogroup" aria-label={tr('permissionFor', { name: tool.nativeName })}>
|
||||
{PERMISSIONS.map(permission => (
|
||||
<label key={permission} className={tool.permission === permission ? 'is-selected' : ''}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`tt-mcp-perm-${tool.id}`}
|
||||
value={permission}
|
||||
checked={tool.permission === permission}
|
||||
disabled={busy}
|
||||
onChange={() => onSetPermission(permission)}
|
||||
/>
|
||||
<span>{tr(PERMISSION_LABELS[permission])}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolItemProps = {
|
||||
tool: TauriTavernMcpTool;
|
||||
busy: boolean;
|
||||
tr: McpTranslator;
|
||||
onSetPermission: (permission: TauriTavernMcpToolPermission) => void;
|
||||
};
|
||||
|
||||
export function ToolItem({ tool, busy, tr, onSetPermission }: ToolItemProps) {
|
||||
const params = extractSchemaParams(tool.inputSchema);
|
||||
const hasNativeAlias = tool.title !== undefined && tool.title !== tool.nativeName;
|
||||
|
||||
return (
|
||||
<li className="tt-mcp-tool">
|
||||
<div className="tt-mcp-tool-title">
|
||||
<b>{tool.title ?? tool.nativeName}</b>
|
||||
{hasNativeAlias && <code>{tool.nativeName}</code>}
|
||||
</div>
|
||||
<PermissionGroup tool={tool} busy={busy} tr={tr} onSetPermission={onSetPermission} />
|
||||
<div className="tt-mcp-tool-body">
|
||||
{tool.description && <p>{tool.description}</p>}
|
||||
{params.length > 0 && (
|
||||
<div className="tt-mcp-params">
|
||||
{params.map(param => (
|
||||
<code
|
||||
key={param.name}
|
||||
className={param.required ? 'is-required' : ''}
|
||||
title={param.description || undefined}
|
||||
>
|
||||
{param.required ? `${param.name}*` : param.name}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<details className="tt-mcp-details">
|
||||
<summary>{tr('schemaDetails')}</summary>
|
||||
<pre>{toolDetails(tool)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
354
src/scripts/extensions/mcp-manager/src/host.ts
Normal file
354
src/scripts/extensions/mcp-manager/src/host.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
const PREFIX = 'mcp_manager.';
|
||||
|
||||
const DEFAULT_MESSAGES = Object.freeze({
|
||||
active: 'Active',
|
||||
activate: 'Activate',
|
||||
activateHttpNote: 'This endpoint uses unencrypted HTTP. Other devices on the network may observe or modify MCP traffic. Activation does not grant tool permission.',
|
||||
activateNote: 'Activation allows manual discovery requests to this exact endpoint. It does not grant tool permission.',
|
||||
activateTitle: 'Activate this MCP server?',
|
||||
addServer: 'Add server',
|
||||
addServerTitle: 'Add MCP server',
|
||||
adding: 'Adding…',
|
||||
cancel: 'Cancel',
|
||||
configuredToolsMissing: 'Not offered by this discovery',
|
||||
diagnostics: 'Discovery notes',
|
||||
discoverTools: 'Discover tools',
|
||||
discovering: 'Discovering tools…',
|
||||
discoveryIdentity: '{implementation} · MCP {protocol}',
|
||||
displayName: 'Name',
|
||||
displayNamePlaceholder: 'Local tools',
|
||||
emptyHint: 'Add a Streamable HTTP endpoint to see the tools it offers.',
|
||||
emptyTitle: 'No MCP servers yet',
|
||||
endpoint: 'Endpoint',
|
||||
endpointHint: 'Streamable HTTP. This endpoint is the registration identity and cannot be changed later.',
|
||||
endpointInvalid: 'Enter a valid http:// or https:// URL.',
|
||||
hostApiUnavailable: 'TauriTavern MCP Host API is unavailable',
|
||||
mcp: 'MCP',
|
||||
nameRequired: 'Enter a name.',
|
||||
newServerNote: 'New servers start paused, and every discovered tool starts Off.',
|
||||
paused: 'Paused',
|
||||
pausedHint: 'Paused — activate to discover this server\'s tools.',
|
||||
permissionAllow: 'Allow',
|
||||
permissionAsk: 'Ask',
|
||||
permissionFor: 'Permission for {name}',
|
||||
permissionOff: 'Off',
|
||||
popupUnavailable: 'SillyTavern Popup API is unavailable',
|
||||
refreshTools: 'Refresh tools',
|
||||
remove: 'Remove',
|
||||
removeNote: 'The registration and all saved tool permissions will be removed.',
|
||||
removeTitle: 'Remove this MCP server?',
|
||||
rename: 'Rename',
|
||||
renameTitle: 'Rename server',
|
||||
retry: 'Retry',
|
||||
schemaDetails: 'Schema & hints',
|
||||
serverCount: 'Servers · {count}',
|
||||
setOff: 'Set Off',
|
||||
storageIssues: 'Registration files needing attention',
|
||||
toolCount: '{count} tools',
|
||||
toggleTools: 'Show or hide tools',
|
||||
unknownError: 'Unknown error',
|
||||
});
|
||||
|
||||
export type McpMessageKey = keyof typeof DEFAULT_MESSAGES;
|
||||
export type McpMessageParams = Readonly<Record<string, string | number>>;
|
||||
export type McpTranslator = (key: McpMessageKey, params?: McpMessageParams) => string;
|
||||
|
||||
type PopupInstance = {
|
||||
result: unknown;
|
||||
show: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
type PopupOptions = {
|
||||
okButton?: string;
|
||||
cancelButton?: string;
|
||||
allowVerticalScrolling?: boolean;
|
||||
onOpen?: () => void;
|
||||
onClosing?: (popup: PopupInstance) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type PopupConstructor = new (
|
||||
content: Element,
|
||||
type: number,
|
||||
inputValue: string,
|
||||
options: PopupOptions,
|
||||
) => PopupInstance;
|
||||
|
||||
type SillyTavernContext = {
|
||||
translate?: (fallback: string, key?: string) => string;
|
||||
Popup?: PopupConstructor;
|
||||
POPUP_TYPE?: { CONFIRM: number; INPUT: number };
|
||||
POPUP_RESULT?: { AFFIRMATIVE: unknown };
|
||||
};
|
||||
|
||||
type SillyTavernWindow = Window & {
|
||||
SillyTavern?: {
|
||||
getContext?: () => SillyTavernContext;
|
||||
};
|
||||
};
|
||||
|
||||
function context(): SillyTavernContext | null {
|
||||
return (window as SillyTavernWindow).SillyTavern?.getContext?.() ?? null;
|
||||
}
|
||||
|
||||
function requirePopupRuntime(): { Popup: PopupConstructor; affirmative: unknown } {
|
||||
const host = context();
|
||||
const Popup = host?.Popup;
|
||||
const affirmative = host?.POPUP_RESULT?.AFFIRMATIVE;
|
||||
if (!Popup || affirmative === undefined) {
|
||||
throw new Error(tr('popupUnavailable'));
|
||||
}
|
||||
return { Popup, affirmative };
|
||||
}
|
||||
|
||||
function formatMessage(message: string, params: McpMessageParams): string {
|
||||
return message.replace(/\{(\w+)\}/g, (match, name: string) => (
|
||||
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
|
||||
));
|
||||
}
|
||||
|
||||
export const tr: McpTranslator = (key, params = {}) => {
|
||||
const fallback = DEFAULT_MESSAGES[key];
|
||||
const message = context()?.translate?.(fallback, `${PREFIX}${key}`) ?? fallback;
|
||||
return formatMessage(message, params);
|
||||
};
|
||||
|
||||
export function errorText(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return typeof error === 'string' && error ? error : fallback;
|
||||
}
|
||||
|
||||
export async function waitForHostReady(): Promise<void> {
|
||||
const ready = window.__TAURITAVERN__?.ready ?? window.__TAURITAVERN_MAIN_READY__;
|
||||
if (ready !== undefined && ready !== null) {
|
||||
await ready;
|
||||
}
|
||||
}
|
||||
|
||||
export function requireMcpApi(): TauriTavernMcpApi {
|
||||
const api = window.__TAURITAVERN__?.api?.mcp;
|
||||
if (!api) {
|
||||
throw new Error(tr('hostApiUnavailable'));
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
function confirmationContent(title: string, endpoint: string, note: string): HTMLElement {
|
||||
const content = document.createElement('div');
|
||||
content.className = 'tt-mcp-confirm';
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = title;
|
||||
const endpointCode = document.createElement('code');
|
||||
endpointCode.textContent = endpoint;
|
||||
const detail = document.createElement('p');
|
||||
detail.textContent = note;
|
||||
|
||||
content.append(heading, endpointCode, detail);
|
||||
return content;
|
||||
}
|
||||
|
||||
async function confirm(content: HTMLElement, okButton: string): Promise<boolean> {
|
||||
const { Popup, affirmative } = requirePopupRuntime();
|
||||
const confirmType = context()?.POPUP_TYPE?.CONFIRM;
|
||||
if (confirmType === undefined) {
|
||||
throw new Error(tr('popupUnavailable'));
|
||||
}
|
||||
|
||||
const popup = new Popup(content, confirmType, '', {
|
||||
okButton,
|
||||
cancelButton: tr('cancel'),
|
||||
allowVerticalScrolling: true,
|
||||
});
|
||||
return await popup.show() === affirmative;
|
||||
}
|
||||
|
||||
export async function confirmActivate(server: TauriTavernMcpServer): Promise<boolean> {
|
||||
const insecureHttp = new URL(server.endpoint).protocol === 'http:';
|
||||
return confirm(
|
||||
confirmationContent(
|
||||
tr('activateTitle'),
|
||||
server.endpoint,
|
||||
tr(insecureHttp ? 'activateHttpNote' : 'activateNote'),
|
||||
),
|
||||
tr('activate'),
|
||||
);
|
||||
}
|
||||
|
||||
export async function confirmRemove(server: TauriTavernMcpServer): Promise<boolean> {
|
||||
return confirm(
|
||||
confirmationContent(tr('removeTitle'), server.endpoint, tr('removeNote')),
|
||||
tr('remove'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla SillyTavern rename flow: an INPUT popup resolves the typed string on
|
||||
* OK, `false` on the negative button and `null` on cancel/Escape.
|
||||
* Returns null when the user cancelled or the name did not change.
|
||||
*/
|
||||
export async function promptServerName(server: TauriTavernMcpServer): Promise<string | null> {
|
||||
const { Popup } = requirePopupRuntime();
|
||||
const inputType = context()?.POPUP_TYPE?.INPUT;
|
||||
if (inputType === undefined) {
|
||||
throw new Error(tr('popupUnavailable'));
|
||||
}
|
||||
|
||||
const content = document.createElement('h3');
|
||||
content.textContent = tr('renameTitle');
|
||||
|
||||
const value = await new Popup(content, inputType, server.displayName, {
|
||||
okButton: tr('rename'),
|
||||
cancelButton: tr('cancel'),
|
||||
}).show();
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const displayName = value.trim();
|
||||
return displayName.length > 0 && displayName !== server.displayName ? displayName : null;
|
||||
}
|
||||
|
||||
function addServerInput(
|
||||
form: HTMLElement,
|
||||
id: string,
|
||||
labelText: string,
|
||||
placeholder: string,
|
||||
): HTMLInputElement {
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = id;
|
||||
label.textContent = labelText;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.id = id;
|
||||
input.className = 'text_pole result-control';
|
||||
input.dataset.result = '1';
|
||||
input.dataset.resultEvent = 'submit';
|
||||
input.type = 'text';
|
||||
input.placeholder = placeholder;
|
||||
input.autocomplete = 'off';
|
||||
form.append(label, input);
|
||||
return input;
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openAddServerDialog(
|
||||
create: TauriTavernMcpApi['servers']['create'],
|
||||
): Promise<TauriTavernMcpServer | null> {
|
||||
const { Popup, affirmative } = requirePopupRuntime();
|
||||
const confirmType = context()?.POPUP_TYPE?.CONFIRM;
|
||||
if (confirmType === undefined) {
|
||||
throw new Error(tr('popupUnavailable'));
|
||||
}
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'tt-mcp-add-form';
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = tr('addServerTitle');
|
||||
form.append(heading);
|
||||
|
||||
const displayName = addServerInput(
|
||||
form,
|
||||
'tt-mcp-add-name',
|
||||
tr('displayName'),
|
||||
tr('displayNamePlaceholder'),
|
||||
);
|
||||
displayName.maxLength = 128;
|
||||
|
||||
const endpoint = addServerInput(
|
||||
form,
|
||||
'tt-mcp-add-endpoint',
|
||||
tr('endpoint'),
|
||||
'http://127.0.0.1:3000/mcp',
|
||||
);
|
||||
endpoint.inputMode = 'url';
|
||||
|
||||
for (const text of [tr('endpointHint'), tr('newServerNote')]) {
|
||||
const hint = document.createElement('small');
|
||||
hint.className = 'tt-mcp-add-hint';
|
||||
hint.textContent = text;
|
||||
form.append(hint);
|
||||
}
|
||||
|
||||
const error = document.createElement('p');
|
||||
error.className = 'tt-mcp-error';
|
||||
error.setAttribute('role', 'alert');
|
||||
|
||||
const busyNote = document.createElement('p');
|
||||
busyNote.className = 'tt-mcp-note';
|
||||
const spinner = document.createElement('i');
|
||||
spinner.className = 'fa-solid fa-circle-notch fa-spin';
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
busyNote.append(spinner, tr('adding'));
|
||||
|
||||
function showError(message: string): void {
|
||||
error.remove();
|
||||
error.textContent = message;
|
||||
if (message) {
|
||||
form.insertBefore(error, busyNote.parentElement === form ? busyNote : null);
|
||||
}
|
||||
}
|
||||
|
||||
function setBusy(value: boolean): void {
|
||||
displayName.disabled = value;
|
||||
endpoint.disabled = value;
|
||||
if (value) {
|
||||
form.append(busyNote);
|
||||
} else {
|
||||
busyNote.remove();
|
||||
}
|
||||
}
|
||||
|
||||
let created: TauriTavernMcpServer | null = null;
|
||||
let submitting = false;
|
||||
const popup = new Popup(form, confirmType, '', {
|
||||
okButton: tr('addServer'),
|
||||
cancelButton: tr('cancel'),
|
||||
allowVerticalScrolling: true,
|
||||
onOpen: () => displayName.focus(),
|
||||
onClosing: async (popup) => {
|
||||
if (popup.result !== affirmative) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (submitting) {
|
||||
return false;
|
||||
}
|
||||
const name = displayName.value.trim();
|
||||
const target = endpoint.value.trim();
|
||||
if (!name) {
|
||||
showError(tr('nameRequired'));
|
||||
return false;
|
||||
}
|
||||
if (!isHttpUrl(target)) {
|
||||
showError(tr('endpointInvalid'));
|
||||
return false;
|
||||
}
|
||||
|
||||
showError('');
|
||||
submitting = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
created = await create({ displayName: name, endpoint: target });
|
||||
return true;
|
||||
} catch (cause) {
|
||||
showError(errorText(cause, tr('unknownError')));
|
||||
return false;
|
||||
} finally {
|
||||
submitting = false;
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
await popup.show();
|
||||
return created;
|
||||
}
|
||||
70
src/scripts/extensions/mcp-manager/src/index.tsx
Normal file
70
src/scripts/extensions/mcp-manager/src/index.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import {
|
||||
confirmActivate,
|
||||
confirmRemove,
|
||||
openAddServerDialog,
|
||||
promptServerName,
|
||||
requireMcpApi,
|
||||
tr,
|
||||
waitForHostReady,
|
||||
} from './host';
|
||||
import { McpManagerApp, type McpManagerActions } from './McpManagerApp';
|
||||
|
||||
const CONTAINER_ID = 'mcp_manager_container';
|
||||
|
||||
function ensureContainer(): HTMLElement {
|
||||
const existing = document.getElementById(CONTAINER_ID);
|
||||
if (existing instanceof HTMLElement) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const column = document.getElementById('extensions_settings2');
|
||||
if (!(column instanceof HTMLElement)) {
|
||||
throw new Error('SillyTavern extension settings column is unavailable');
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.id = CONTAINER_ID;
|
||||
container.className = 'extension_container';
|
||||
|
||||
const anchor = document.getElementById('skill_manager_container')
|
||||
?? document.getElementById('agent_system_container');
|
||||
if (anchor?.parentElement === column) {
|
||||
anchor.insertAdjacentElement('afterend', container);
|
||||
} else {
|
||||
column.prepend(container);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
async function mountMcpManager(): Promise<void> {
|
||||
await waitForHostReady();
|
||||
const api = requireMcpApi();
|
||||
const initial = await api.servers.list();
|
||||
const actions: McpManagerActions = {
|
||||
addServer: () => openAddServerDialog(api.servers.create),
|
||||
renameServer: async (server) => {
|
||||
const displayName = await promptServerName(server);
|
||||
if (!displayName) {
|
||||
return null;
|
||||
}
|
||||
return api.servers.rename({ registrationId: server.id, displayName });
|
||||
},
|
||||
setState: api.servers.setState,
|
||||
remove: api.servers.remove,
|
||||
discover: api.servers.discover,
|
||||
setPermission: api.tools.setPermission,
|
||||
confirmActivate,
|
||||
confirmRemove,
|
||||
};
|
||||
|
||||
createRoot(ensureContainer()).render(
|
||||
<StrictMode>
|
||||
<McpManagerApp initial={initial} actions={actions} tr={tr} />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
await mountMcpManager();
|
||||
622
src/scripts/extensions/mcp-manager/style.css
Normal file
622
src/scripts/extensions/mcp-manager/style.css
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
.tt-mcp-root,
|
||||
.tt-mcp-add-form,
|
||||
.tt-mcp-confirm {
|
||||
--tt-mcp-accent: var(--SmartThemeQuoteColor);
|
||||
--tt-mcp-line: color-mix(in srgb, var(--SmartThemeBorderColor) 80%, transparent);
|
||||
--tt-mcp-dim: var(--SmartThemeEmColor);
|
||||
--tt-mcp-sink: var(--black30a);
|
||||
--tt-mcp-danger: color-mix(in srgb, var(--fullred) 72%, var(--SmartThemeBodyColor));
|
||||
}
|
||||
|
||||
.tt-mcp-root {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
font-family: var(--mainFontFamily);
|
||||
font-size: var(--mainFontSize);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tt-mcp-root .inline-drawer-content > * + * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.tt-mcp-drawer-header {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tt-mcp-title {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tt-mcp-title > i {
|
||||
color: var(--tt-mcp-accent);
|
||||
}
|
||||
|
||||
.tt-mcp-root code,
|
||||
.tt-mcp-root pre,
|
||||
.tt-mcp-confirm code {
|
||||
font-family: var(--monoFontFamily);
|
||||
}
|
||||
|
||||
/* Neutralize the host's global dark `code` chip; chips re-declare their own skin below. */
|
||||
.tt-mcp-root :where(code) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Toolbar & panel-level lines ─────────────────────────── */
|
||||
|
||||
.tt-mcp-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tt-mcp-count {
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.8em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tt-mcp-toolbar .menu_button,
|
||||
.tt-mcp-empty .menu_button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tt-mcp-error {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: var(--tt-mcp-danger);
|
||||
font-size: 0.82em;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tt-mcp-note {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Storage issues ──────────────────────────────────────── */
|
||||
|
||||
.tt-mcp-alert {
|
||||
padding: 4px 0 4px 10px;
|
||||
border-left: 2px solid var(--tt-mcp-danger);
|
||||
}
|
||||
|
||||
.tt-mcp-alert > b {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.tt-mcp-alert > b > i {
|
||||
color: var(--tt-mcp-danger);
|
||||
}
|
||||
|
||||
.tt-mcp-alert ul {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tt-mcp-alert li {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.tt-mcp-alert code {
|
||||
font-size: 0.78em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tt-mcp-alert span {
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.82em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────── */
|
||||
|
||||
.tt-mcp-empty {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 6px;
|
||||
padding: 18px 12px 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tt-mcp-empty > i {
|
||||
color: color-mix(in srgb, var(--tt-mcp-accent) 65%, var(--tt-mcp-dim));
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.tt-mcp-empty > span {
|
||||
max-width: 34ch;
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tt-mcp-empty .menu_button {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Server list ─────────────────────────────────────────── */
|
||||
|
||||
.tt-mcp-servers {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.tt-mcp-server {
|
||||
padding: 8px 0 9px;
|
||||
}
|
||||
|
||||
.tt-mcp-server + .tt-mcp-server {
|
||||
border-top: 1px solid var(--tt-mcp-line);
|
||||
}
|
||||
|
||||
.tt-mcp-server-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tt-mcp-lamp {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 50%;
|
||||
background-color: var(--tt-mcp-sink);
|
||||
}
|
||||
|
||||
.tt-mcp-server.is-active .tt-mcp-lamp {
|
||||
border-color: var(--tt-mcp-accent);
|
||||
background-color: var(--tt-mcp-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--tt-mcp-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.tt-mcp-identity {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.tt-mcp-name {
|
||||
overflow: hidden;
|
||||
font-size: 0.95em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tt-mcp-endpoint {
|
||||
overflow: hidden;
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.75em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── State pill & row icon buttons ───────────────────────── */
|
||||
|
||||
.tt-mcp-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 3px 9px;
|
||||
color: var(--tt-mcp-dim);
|
||||
font: inherit;
|
||||
font-size: 0.78em;
|
||||
border: 1px solid var(--tt-mcp-line);
|
||||
border-radius: 999px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tt-mcp-state-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--tt-mcp-dim);
|
||||
}
|
||||
|
||||
.tt-mcp-state.is-active {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
border-color: color-mix(in srgb, var(--tt-mcp-accent) 55%, var(--tt-mcp-line));
|
||||
}
|
||||
|
||||
.tt-mcp-state.is-active .tt-mcp-state-dot {
|
||||
background-color: var(--tt-mcp-accent);
|
||||
}
|
||||
|
||||
.tt-mcp-state:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tt-mcp-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tt-mcp-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--tt-mcp-dim);
|
||||
font: inherit;
|
||||
font-size: 0.82em;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--animation-duration-2x), background-color var(--animation-duration-2x);
|
||||
}
|
||||
|
||||
.tt-mcp-icon-btn:hover:not(:disabled) {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
background-color: var(--tt-mcp-sink);
|
||||
}
|
||||
|
||||
.tt-mcp-icon-btn.is-danger:hover:not(:disabled) {
|
||||
color: var(--tt-mcp-danger);
|
||||
}
|
||||
|
||||
.tt-mcp-icon-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.tt-mcp-chevron i {
|
||||
transition: transform var(--animation-duration-2x);
|
||||
}
|
||||
|
||||
.tt-mcp-server.is-expanded .tt-mcp-chevron i {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.tt-mcp-state:focus-visible,
|
||||
.tt-mcp-icon-btn:focus-visible,
|
||||
.tt-mcp-root summary:focus-visible {
|
||||
outline: 2px solid var(--interactable-outline-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ── Discovery console ───────────────────────────────────── */
|
||||
|
||||
.tt-mcp-console {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 7px 0 1px 4px;
|
||||
padding-left: 11px;
|
||||
border-left: 2px solid var(--tt-mcp-line);
|
||||
}
|
||||
|
||||
.tt-mcp-server.is-active .tt-mcp-console {
|
||||
border-left-color: color-mix(in srgb, var(--tt-mcp-accent) 45%, var(--tt-mcp-line));
|
||||
}
|
||||
|
||||
.tt-mcp-console > .menu_button {
|
||||
justify-self: start;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tt-mcp-console-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tt-mcp-console-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.78em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tt-mcp-console-count {
|
||||
margin-left: auto;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
font-size: 0.78em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tt-mcp-details {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.tt-mcp-root summary {
|
||||
color: var(--tt-mcp-dim);
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.tt-mcp-details ul {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.tt-mcp-details li span {
|
||||
display: block;
|
||||
color: var(--tt-mcp-dim);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tt-mcp-details pre {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
margin: 6px 0 0;
|
||||
padding: 7px 8px;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
border: 1px solid var(--tt-mcp-line);
|
||||
border-radius: 5px;
|
||||
background-color: var(--tt-mcp-sink);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── Tools ───────────────────────────────────────────────── */
|
||||
|
||||
.tt-mcp-tools {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tt-mcp-tool {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 4px 10px;
|
||||
}
|
||||
|
||||
.tt-mcp-tool + .tt-mcp-tool {
|
||||
padding-top: 9px;
|
||||
border-top: 1px solid var(--tt-mcp-line);
|
||||
}
|
||||
|
||||
.tt-mcp-tool-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 2px 7px;
|
||||
}
|
||||
|
||||
.tt-mcp-tool-title > b {
|
||||
font-size: 0.92em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tt-mcp-tool-title > code {
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.75em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tt-mcp-tool-body {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tt-mcp-tool-body > p {
|
||||
margin: 0;
|
||||
color: color-mix(in srgb, var(--SmartThemeBodyColor) 82%, var(--tt-mcp-dim));
|
||||
font-size: 0.82em;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tt-mcp-params {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tt-mcp-params code {
|
||||
padding: 1px 6px;
|
||||
color: var(--tt-mcp-dim);
|
||||
border: 1px solid var(--tt-mcp-line);
|
||||
border-radius: 4px;
|
||||
background-color: var(--tt-mcp-sink);
|
||||
font-size: 0.72em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tt-mcp-params code.is-required {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
border-color: color-mix(in srgb, var(--tt-mcp-accent) 60%, var(--tt-mcp-line));
|
||||
}
|
||||
|
||||
/* ── Permission segmented control ────────────────────────── */
|
||||
|
||||
.tt-mcp-perm {
|
||||
display: flex;
|
||||
padding: 1px;
|
||||
border: 1px solid var(--tt-mcp-line);
|
||||
border-radius: 6px;
|
||||
background-color: var(--tt-mcp-sink);
|
||||
}
|
||||
|
||||
.tt-mcp-perm label {
|
||||
position: relative;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tt-mcp-perm input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tt-mcp-perm span {
|
||||
display: block;
|
||||
padding: 2px 8px;
|
||||
color: var(--tt-mcp-dim);
|
||||
border-radius: 5px;
|
||||
font-size: 0.76em;
|
||||
line-height: 1.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tt-mcp-perm label.is-selected span {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
background-color: color-mix(in srgb, var(--tt-mcp-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.tt-mcp-perm input:focus-visible + span {
|
||||
outline: 2px solid var(--interactable-outline-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.tt-mcp-perm input:disabled,
|
||||
.tt-mcp-perm input:disabled + span {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tt-mcp-perm input:disabled + span {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Stale tools ─────────────────────────────────────────── */
|
||||
|
||||
.tt-mcp-stale {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--tt-mcp-line);
|
||||
}
|
||||
|
||||
.tt-mcp-stale > b {
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.tt-mcp-stale ul {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tt-mcp-stale li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tt-mcp-stale code {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0.78em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tt-mcp-stale li > span {
|
||||
margin-left: auto;
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.76em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tt-mcp-stale .menu_button {
|
||||
margin: 0;
|
||||
font-size: 0.76em;
|
||||
}
|
||||
|
||||
/* ── Add-server dialog form ──────────────────────────────── */
|
||||
|
||||
.tt-mcp-add-form {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: min(360px, 62vw);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tt-mcp-add-form h3 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.tt-mcp-add-form > label {
|
||||
margin-top: 4px;
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.tt-mcp-add-form .text_pole {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tt-mcp-add-hint {
|
||||
color: var(--tt-mcp-dim);
|
||||
font-size: 0.76em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Activate / remove confirmation content ──────────────── */
|
||||
|
||||
.tt-mcp-confirm {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tt-mcp-confirm h3,
|
||||
.tt-mcp-confirm p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tt-mcp-confirm code {
|
||||
padding: 8px 10px;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 5px;
|
||||
background-color: var(--tt-mcp-sink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tt-mcp-root .fa-spin {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.tt-mcp-icon-btn,
|
||||
.tt-mcp-chevron i {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ test('Rspack exposes a dedicated TauriTavern Dev Logs Vue entry', async () => {
|
|||
const source = await readRepoFile('rspack.config.js');
|
||||
|
||||
assert.match(source, /['"]dev-logs['"]:\s*['"]\.\/src\/scripts\/tauri\/setting\/dev-logs-app\/index\.js['"]/);
|
||||
assert.match(source, /listJavaScriptFiles\(['"]src\/scripts\/tauri\/setting\/dev-logs-app['"]\)/);
|
||||
assert.match(source, /listScriptFiles\(['"]src\/scripts\/tauri\/setting\/dev-logs-app['"]\)/);
|
||||
assert.match(source, /src\/scripts\/tauri\/setting\/dist/);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ test('Rspack exposes a dedicated TauriTavern Sync Vue entry', async () => {
|
|||
const source = await readRepoFile('rspack.config.js');
|
||||
|
||||
assert.match(source, /sync:\s*['"]\.\/src\/scripts\/tauri\/setting\/sync-app\/index\.js['"]/);
|
||||
assert.match(source, /listJavaScriptFiles\(['"]src\/scripts\/tauri\/setting\/sync-app['"]\)/);
|
||||
assert.match(source, /listScriptFiles\(['"]src\/scripts\/tauri\/setting\/sync-app['"]\)/);
|
||||
assert.match(source, /src\/scripts\/tauri\/setting\/dist/);
|
||||
});
|
||||
|
||||
|
|
|
|||
15
tsconfig.ui.json
Normal file
15
tsconfig.ui.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "./tsconfig.host.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"isolatedModules": true,
|
||||
"module": "ESNext",
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["react", "react-dom"]
|
||||
},
|
||||
"include": [
|
||||
"src/types.d.ts",
|
||||
"src/scripts/extensions/**/*.ts",
|
||||
"src/scripts/extensions/**/*.tsx"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue