Compare commits

..

70 Commits

Author SHA1 Message Date
samanhappy
e2c5cc8ed1 fix: refine sessionId handling in handleMcpPostRequest for initialization requests (#138)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-05-28 23:25:21 +08:00
samanhappy
b0a65cc6d0 fix: streamline sessionId handling in handleMcpPostRequest (#137)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-05-28 22:15:14 +08:00
samanhappy
e019c9e8b3 fix: simplify server status labels in English and Chinese locales (#128) 2025-05-28 18:20:43 +08:00
samanhappy
39b69bf550 fix: improve sessionId validation and error handling in handleSseMessage (#134) 2025-05-28 14:30:35 +08:00
samanhappy
a1047321d1 feat: introduce runtime path (#132) 2025-05-27 18:34:23 +08:00
samanhappy
268ce5cce6 feat: Refactor API URL handling and add base path support (#131) 2025-05-27 16:11:35 +08:00
samanhappy
37bb3414c8 feat: add VITE_BASE_PATH environment variable and update routing in App component (#130) 2025-05-27 13:08:41 +08:00
samanhappy
c3e1fa1eee Update asset paths to use relative URLs (#129) 2025-05-27 11:55:50 +08:00
samanhappy
edfbdb7123 fix: update README and README.zh.md for clarity and consistency (#124) 2025-05-25 21:32:46 +08:00
samanhappy
9e5c2b5525 feat: introduce auto routing (#122) 2025-05-25 21:09:47 +08:00
samanhappy
27b7e766af fix: downgrade express and @types/express to compatible versions (#119) 2025-05-22 15:14:05 +08:00
dependabot[bot]
9c55f63bb1 chore(deps-dev): bump @types/node from 20.17.28 to 22.15.21 (#114)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-22 14:57:56 +08:00
dependabot[bot]
b1674cc630 chore(deps): bump express and @types/express (#113)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-22 14:56:06 +08:00
dependabot[bot]
3570232ccc chore(deps-dev): bump tailwind-merge from 3.1.0 to 3.3.0 (#112)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-22 14:54:29 +08:00
dependabot[bot]
f50bb08816 chore(deps-dev): bump @tailwindcss/postcss from 4.1.3 to 4.1.7 (#115)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-22 14:54:03 +08:00
dependabot[bot]
721674e7eb chore(deps-dev): bump @radix-ui/react-slot from 1.1.2 to 1.2.3 (#116)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-22 14:53:50 +08:00
samanhappy
1337eeed84 Update dependabot.yml (#111) 2025-05-21 09:39:56 +08:00
samanhappy
b14be9955b Create dependabot.yml (#110) 2025-05-21 09:37:39 +08:00
samanhappy
da45d49a5e feat: Bump Vite to v6 and update esbuild (#109)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-21 09:34:24 +08:00
samanhappy
f46e2c22fc feat: implement dynamic package versioning and refactor MCP server initialization (#104) 2025-05-18 20:07:01 +08:00
samanhappy
ae3fe1c6f1 add mintlify doc (#106) 2025-05-17 22:42:46 +08:00
samanhappy
76a27a454e docs: add Star History section to README files (#105) 2025-05-17 20:34:02 +08:00
samanhappy
b15b71f407 fix: enhance stdio log (#103) 2025-05-17 19:40:34 +08:00
samanhappy
298d96d593 feat: enhance MCP server retrieval and logging for transport closure (#101) 2025-05-16 16:47:21 +08:00
samanhappy
d44886b81b docs: update README to enhance clarity and add new features overview (#102) 2025-05-16 13:28:46 +08:00
Lawrence Sinclair
bbe44fc540 Add MseeP.ai badge (#95) 2025-05-14 17:14:48 +08:00
samanhappy
c60b98e3d6 feat: add icons and link to Header (#94) 2025-05-14 13:54:29 +08:00
samanhappy
a447fe5b41 feat: display current version in Header and remove version display from UserProfileMenu (#93) 2025-05-14 10:19:43 +08:00
samanhappy
94d51fa03a refactor: update issue templates for consistency and clarity (#91) 2025-05-13 22:01:23 +08:00
samanhappy
b88a7240c6 Update issue templates (#90) 2025-05-13 19:57:00 +08:00
samanhappy
3c875590ce refactor: remove Node.js setup and version update steps from release workflow (#84) 2025-05-13 14:37:09 +08:00
samanhappy
26fa61fcfc feat: implement version checking and update notifications in AboutDialog and UserProfileMenu (#83) 2025-05-13 14:18:49 +08:00
samanhappy
d689541fc4 refactor: remove dependency on wait-for-npm job in build workflow (#82) 2025-05-13 13:07:21 +08:00
samanhappy
30895c4b9a refactor: remove wait-for-npm job from build workflow (#81) 2025-05-13 13:05:26 +08:00
samanhappy
37c3fd9e06 feat: add bearer authentication support for MCP requests (#79) 2025-05-13 13:02:41 +08:00
samanhappy
59454ca250 feat: add wait-for-npm job and update version from tag in build workflow (#80) 2025-05-13 13:01:54 +08:00
samanhappy
63efa0038c feat: add npmRegistry support to installation configuration (#77)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-05-13 09:21:07 +08:00
samanhappy
040782da8d feat: support streamable http upstream server (#75)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-12 22:12:48 +08:00
samanhappy
f1a5f692cc feat: create MCP server for different session (#74) 2025-05-12 15:11:37 +08:00
samanhappy
5d798cfe6a feat: enhance release workflow to automatically update package.json version (#70) 2025-05-11 14:43:48 +08:00
samanhappy
0490d98c9e feat: add sponsorship section to README.md (#72) 2025-05-11 14:41:00 +08:00
samanhappy
7af3c8a2ba feat: add installation configuration support with pythonIndexUrl in settings (#67) 2025-05-10 21:33:35 +08:00
samanhappy
7c43ca359e feat: add acknowledgment section to README.zh.md (#68)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-10 21:33:22 +08:00
samanhappy
2bb6302cbc feat: enhance user experience with version info (#69)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-10 21:33:05 +08:00
samanhappy
3a3f6c984c Create FUNDING.yml (#65) 2025-05-10 11:05:07 +08:00
samanhappy
e8bc053788 fix: update react-router-dom to version 7.6.0 in package.json and pnpm-lock.yaml (#63) 2025-05-09 22:06:51 +08:00
samanhappy
bb674236c7 feat: update Node.js and pnpm versions in CI workflow; add packageManager field in package.json (#62) 2025-05-09 21:34:25 +08:00
samanhappy
0f5dfbe419 feat: support server-specified endpoint (#58) 2025-05-09 21:25:44 +08:00
samanhappy
74d1ca6a87 fix: remove unnecessary dependency from fetchMarketServers to improve performance (#56) 2025-05-09 21:24:27 +08:00
dependabot[bot]
eab421c753 chore(deps-dev): bump vite from 5.4.18 to 5.4.19 (#51)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 20:58:54 +08:00
samanhappy
886ca44681 Implement code changes to enhance functionality and improve performance (#52) 2025-05-05 20:54:11 +08:00
samanhappy
3d4baeef1a feat: update dependencies in pnpm-lock.yaml for improved functionality and security (#50) 2025-05-05 19:44:25 +08:00
samanhappy
4379513a35 feat: add GitHub Actions workflow for publishing to NPM (#49) 2025-05-05 19:39:09 +08:00
samanhappy
9a06bae225 fix: improve error handling in CallToolRequest by providing detailed error messages (#48) 2025-05-05 19:09:02 +08:00
samanhappy
e5aaae466f feat: add log management features including log viewing, filtering, and streaming (#45) 2025-05-02 21:41:16 +08:00
samanhappy
9b1338a356 feat: add support for HTTP_PROXY and HTTPS_PROXY environment variables in Dockerfile and entrypoint script (#42) 2025-05-02 12:40:44 +08:00
samanhappy
10d4616601 Implement theme switching and enhance dark mode support (#43) 2025-05-02 12:40:11 +08:00
samanhappy
9ca242a0e4 docs: update README and README.zh to clarify Streamable HTTP endpoint support (#41) 2025-05-01 17:42:46 +08:00
samanhappy
0a6259decf feat: enhance configuration file handling and dynamic frontend path resolution (#40)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-30 22:38:21 +08:00
samanhappy
7887a3a5f9 docs: update README to reflect changes in HTTP endpoint support and deprecate SSE endpoint (#38) 2025-04-28 14:49:40 +08:00
samanhappy
7f33615161 feat: support Streamable HTTP transport for downstream (#32) 2025-04-27 13:55:25 +08:00
samanhappy
c9ec3b77ce fix: sort market servers by official status (#36) 2025-04-26 21:17:05 +08:00
samanhappy
142c3f628a Update group chat invitation details (#34) 2025-04-25 10:00:56 +08:00
samanhappy
bbb99b6f17 fix: enhance MCP server tool registration and client initialization logic (#31) 2025-04-23 16:27:21 +08:00
samanhappy
c1eabb5607 fix: improve client connection handling and tool listing in mcpService (#30) 2025-04-23 15:46:06 +08:00
samanhappy
afd1ee7a50 Enhance routing and settings functionality (#29) 2025-04-21 17:51:21 +08:00
samanhappy
6bf22025e1 docs: Refine introduction and usage details in intro2.md for clarity … (#27) 2025-04-21 14:03:20 +08:00
samanhappy
2c00f1d606 docs: Add comprehensive introduction and usage guide for MCPHub, deta… (#26) 2025-04-20 17:49:40 +08:00
samanhappy
3d49c652ad introduce market 2025-04-20 13:58:52 +08:00
samanhappy
c4008f617d fix: add catch-all route to serve frontend index.html in AppServer in… (#24) 2025-04-18 22:44:12 +08:00
154 changed files with 86917 additions and 1675 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: samanhappy
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -0,0 +1,29 @@
---
name: Bug Report / Bug 报告
about: Create a report to help us improve / 报告问题以帮助改进
title: ''
labels: bug
assignees: ''
---
**Bug Description / 问题描述**
What happened? / 发生了什么?
**Steps to Reproduce / 复现步骤**
1.
2.
3.
**Expected Behavior / 预期行为**
What should happen? / 应该发生什么?
**Environment / 运行环境**
- Running on / 运行方式: [docker/npx/local / docker/npx/本地]
- Version / 版本: [e.g. 1.0.0]
**Screenshots / 截图**
If relevant, add screenshots / 如果有帮助的话,请添加截图
**Additional Info / 补充信息**
Any other details? / 还有其他信息吗?

View File

@@ -0,0 +1,20 @@
---
name: Feature request / 功能请求
about: Suggest an idea for this project / 为项目提出新想法
title: ''
labels: enhancement
assignees: ''
---
**Current Problem / 当前问题**
What problem are you trying to solve? / 您想要解决什么问题?
**Proposed Solution / 建议方案**
How would you like this to work? / 您期望的解决方案是什么?
**Alternatives / 替代方案**
Have you considered any alternatives? / 您是否考虑过其他解决方案?
**Additional Context / 补充说明**
Any screenshots, mockups, or relevant information? / 有任何截图、设计图或相关信息吗?

11
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "monthly"

View File

@@ -16,6 +16,16 @@ jobs:
with:
fetch-depth: 0
- name: Update version from tag
if: startsWith(github.ref, 'refs/tags/')
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "Updating package.json version to $VERSION"
jq ".version = \"$VERSION\"" package.json > package.json.tmp
mv package.json.tmp package.json
echo "Updated version in package.json:"
grep -m 1 "version" package.json
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -36,7 +46,7 @@ jobs:
type=raw,value=latest${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
flavor: |
latest=false
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:

58
.github/workflows/npm-publish.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Publish to NPM
on:
push:
tags: ['v*.*.*']
jobs:
publish-npm:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update version from tag
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "Updating package.json version to $VERSION"
jq ".version = \"$VERSION\"" package.json > package.json.tmp
mv package.json.tmp package.json
echo "Updated version in package.json:"
grep -m 1 "version" package.json
- name: Build package
run: pnpm build
- name: Publish to NPM
run: pnpm publish --no-git-checks --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -13,6 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Release
uses: softprops/action-gh-release@v2
with:

View File

@@ -2,7 +2,13 @@ FROM python:3.13-slim-bookworm AS base
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN apt-get update && apt-get install -y curl gnupg \
# 添加 HTTP_PROXY 和 HTTPS_PROXY 环境变量
ARG HTTP_PROXY=""
ARG HTTPS_PROXY=""
ENV HTTP_PROXY=$HTTP_PROXY
ENV HTTPS_PROXY=$HTTPS_PROXY
RUN apt-get update && apt-get install -y curl gnupg git \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
@@ -12,23 +18,25 @@ RUN npm install -g pnpm
ARG REQUEST_TIMEOUT=60000
ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
ARG BASE_PATH=""
ENV BASE_PATH=$BASE_PATH
ENV PNPM_HOME=/usr/local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN mkdir -p $PNPM_HOME && \
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
ARG INSTALL_EXT=false
RUN if [ "$INSTALL_EXT" = "true" ]; then \
ARCH=$(uname -m); \
if [ "$ARCH" = "x86_64" ]; then \
npx -y playwright install --with-deps chrome; \
else \
echo "Skipping Chrome installation on non-amd64 architecture: $ARCH"; \
fi; \
fi
ARCH=$(uname -m); \
if [ "$ARCH" = "x86_64" ]; then \
npx -y playwright install --with-deps chrome; \
else \
echo "Skipping Chrome installation on non-amd64 architecture: $ARCH"; \
fi; \
fi
RUN uv tool install mcp-server-fetch
ENV UV_PYTHON_INSTALL_MIRROR="http://mirrors.aliyun.com/pypi/simple/"
WORKDIR /app
@@ -37,6 +45,9 @@ RUN pnpm install
COPY . .
# Download the latest servers.json from mcpm.sh and replace the existing file
RUN curl -s -f --connect-timeout 10 https://mcpm.sh/api/servers.json -o servers.json || echo "Failed to download servers.json, using bundled version"
RUN pnpm frontend:build && pnpm build
COPY entrypoint.sh /usr/local/bin/entrypoint.sh

147
README.md
View File

@@ -1,14 +1,14 @@
# MCPHub: Your Ultimate MCP Server Hub
# MCPHub: The Unified Hub for Model Context Protocol (MCP) Servers
English | [中文版](README.zh.md)
MCPHub is a unified management platform that aggregates multiple MCP (Model Context Protocol) servers into separate SSE endpoints for different scenarios by group. It streamlines your AI tool integrations through an intuitive interface and robust protocol handling.
MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) servers by organizing them into flexible Streamable HTTP (SSE) endpoints—supporting access to all servers, individual servers, or logical server groups.
![Dashboard Preview](assets/dashboard.png)
## 🚀 Features
- **Out-of-the-Box MCP Server Support**: Seamlessly integrate popular servers like `amap-maps`, `playwright`, `fetch`, `slack`, and more.
- **Broadened MCP Server Support**: Seamlessly integrate any MCP server with minimal configuration.
- **Centralized Dashboard**: Monitor real-time status and performance metrics from one sleek web UI.
- **Flexible Protocol Handling**: Full compatibility with both stdio and SSE MCP protocols.
- **Hot-Swappable Configuration**: Add, remove, or update MCP servers on the fly — no downtime required.
@@ -18,14 +18,14 @@ MCPHub is a unified management platform that aggregates multiple MCP (Model Cont
## 🔧 Quick Start
### Optional Configuration
### Configuration
Create a `mcp_settings.json` file to customize your server settings:
```json
{
"mcpServers": {
"amap-maps": {
"amap": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
@@ -48,31 +48,20 @@ Create a `mcp_settings.json` file to customize your server settings:
"SLACK_TEAM_ID": "your-team-id"
}
}
},
"users": [
{
"username": "admin",
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
"isAdmin": true
}
]
}
}
```
> **Note**: Default credentials are `admin` / `admin123`. Passwords are securely hashed with bcrypt. Generate a new hash with:
>
> ```bash
> npx bcryptjs your-password
> ```
### Docker Deployment
**Recommended**: Mount your custom config:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
```
or run with default settings:
```bash
docker run -p 3000:3000 samanhappy/mcphub
```
@@ -80,34 +69,110 @@ docker run -p 3000:3000 samanhappy/mcphub
### Access the Dashboard
Open `http://localhost:3000` and log in with your credentials.
> **Note**: Default credentials are `admin` / `admin123`.
**Dashboard Overview**:
- Live status of all MCP servers
- Enable/disable or reconfigure servers
- Group management for organizing servers
- User administration for access control
### SSE Endpoint
### Streamable HTTP Endpoint
> As of now, support for streaming HTTP endpoints varies across different AI clients. If you encounter issues, you can use the SSE endpoint or wait for future updates.
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
```
http://localhost:3000/mcp
```
This endpoint provides a unified streamable HTTP interface for all your MCP servers. It allows you to:
- Send requests to any configured MCP server
- Receive responses in real-time
- Easily integrate with various AI clients and tools
- Use the same endpoint for all servers, simplifying your integration process
**Smart Routing (Experimental)**:
Smart Routing is MCPHub's intelligent tool discovery system that uses vector semantic search to automatically find the most relevant tools for any given task.
```
http://localhost:3000/mcp/$smart
```
**How it Works:**
1. **Tool Indexing**: All MCP tools are automatically converted to vector embeddings and stored in PostgreSQL with pgvector
2. **Semantic Search**: User queries are converted to vectors and matched against tool embeddings using cosine similarity
3. **Intelligent Filtering**: Dynamic thresholds ensure relevant results without noise
4. **Precise Execution**: Found tools can be directly executed with proper parameter validation
**Setup Requirements:**
![Smart Routing](assets/smart-routing.png)
To enable Smart Routing, you need:
- PostgreSQL with pgvector extension
- OpenAI API key (or compatible embedding service)
- Enable Smart Routing in MCPHub settings
**Group-Specific Endpoints (Recommended)**:
![Group Management](assets/group.png)
For targeted access to specific server groups, use the group-based HTTP endpoint:
```
http://localhost:3000/mcp/{group}
```
Where `{group}` is the ID or name of the group you created in the dashboard. This allows you to:
- Connect to a specific subset of MCP servers organized by use case
- Isolate different AI tools to access only relevant servers
- Implement more granular access control for different environments or teams
**Server-Specific Endpoints**:
For direct access to individual servers, use the server-specific HTTP endpoint:
```
http://localhost:3000/mcp/{server}
```
Where `{server}` is the name of the server you want to connect to. This allows you to access a specific MCP server directly.
> **Note**: If the server name and group name are the same, the group name will take precedence.
### SSE Endpoint (Deprecated in Future)
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
Connect AI clients (e.g., Claude Desktop, Cursor, Cherry Studio) via:
```
http://localhost:3000/sse
```
**Group-Specific Endpoints (Recommended)**:
For smart routing, use:
![Group Management](assets/group.png)
```
http://localhost:3000/sse/$smart
```
For targeted access to specific server groups, use the group-based SSE endpoint:
```
http://localhost:3000/sse/{groupId}
http://localhost:3000/sse/{group}
```
Where `{groupId}` is the ID of the group you created in the dashboard. This allows you to:
- Connect to a specific subset of MCP servers organized by use case
- Isolate different AI tools to access only relevant servers
- Implement more granular access control for different environments or teams
For direct access to individual servers, use the server-specific SSE endpoint:
```
http://localhost:3000/sse/{server}
```
## 🧑‍💻 Local Development
@@ -120,6 +185,18 @@ pnpm dev
This starts both frontend and backend in development mode with hot-reloading.
> For windows users, you may need to start the backend server and frontend separately: `pnpm backend:dev`, `pnpm frontend:dev`.
## 🛠️ Common Issues
### Using Nginx as a Reverse Proxy
If you are using Nginx to reverse proxy MCPHub, please make sure to add the following configuration in your Nginx setup:
```nginx
proxy_buffering off
```
## 🔍 Tech Stack
- **Backend**: Node.js, Express, TypeScript
@@ -129,13 +206,25 @@ This starts both frontend and backend in development mode with hot-reloading.
## 👥 Contributing
Contributions are welcome!
Contributions of any kind are welcome!
- New features & optimizations
- Documentation improvements
- Bug reports & fixes
- Translations & suggestions
Welcome to join our [Discord community](https://discord.gg/qMKNsn5Q) for discussions and support.
## ❤️ Sponsor
If you like this project, maybe you can consider:
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/samanhappy)
## 🌟 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=samanhappy/mcphub&type=Date)](https://www.star-history.com/#samanhappy/mcphub&Date)
## 📄 License
Licensed under the [Apache 2.0 License](LICENSE).

View File

@@ -2,13 +2,13 @@
[English Version](README.md) | 中文版
MCPHub 是一个统一的 MCPModel Context Protocol,模型上下文协议)服务器聚合平台,可以根据场景将多个服务器聚合到不同的 SSE 端点。它通过直观的界面和强大的协议处理能力,简化了您的 AI 工具集成流程
MCPHub 通过将多个 MCPModel Context Protocol)服务器组织为灵活的流式 HTTPSSE端点,简化了管理与扩展工作。系统支持按需访问全部服务器、单个服务器或按场景分组的服务器集合
![控制面板预览](assets/dashboard.zh.png)
## 🚀 功能亮点
- **开箱即用的 MCP 服务器支持**:无缝集成 `amap-maps``playwright``fetch``slack` 等常见服务器
- **广泛的 MCP 服务器支持**:无缝集成任何 MCP 服务器,配置简单
- **集中式管理控制台**:在一个简洁的 Web UI 中实时监控所有服务器的状态和性能指标。
- **灵活的协议兼容**:完全支持 stdio 和 SSE 两种 MCP 协议。
- **热插拔式配置**:在运行时动态添加、移除或更新服务器配置,无需停机。
@@ -18,14 +18,14 @@ MCPHub 是一个统一的 MCPModel Context Protocol模型上下文协议
## 🔧 快速开始
### 可选配置
### 配置
通过创建 `mcp_settings.json` 自定义服务器设置:
```json
{
"mcpServers": {
"amap-maps": {
"amap": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
@@ -48,31 +48,20 @@ MCPHub 是一个统一的 MCPModel Context Protocol模型上下文协议
"SLACK_TEAM_ID": "your-team-id"
}
}
},
"users": [
{
"username": "admin",
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
"isAdmin": true
}
]
}
}
```
> **提示**:默认用户名/密码为 `admin` / `admin123`。密码已通过 bcrypt 安全哈希。生成新密码哈希:
>
> ```bash
> npx bcryptjs your-password
> ```
### Docker 部署
**推荐**:挂载自定义配置:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
```
或使用默认配置运行:
```bash
docker run -p 3000:3000 samanhappy/mcphub
```
@@ -80,34 +69,110 @@ docker run -p 3000:3000 samanhappy/mcphub
### 访问控制台
打开 `http://localhost:3000`,使用您的账号登录。
> **提示**:默认用户名/密码为 `admin` / `admin123`。
**控制台功能**
- 实时监控所有 MCP 服务器状态
- 启用/禁用或重新配置服务器
- 分组管理,组织服务器访问
- 用户管理,设定权限
### SSE 端点集成
### 支持流式的 HTTP 端点
> 截至目前,各家 AI 客户端对流式的 HTTP 端点支持不一,如果遇到问题,可以使用 SSE 端点或者等待更新。
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
```
http://localhost:3000/mcp
```
这个端点为所有 MCP 服务器提供统一的流式 HTTP 接口。它允许您:
- 向任何配置的 MCP 服务器发送请求
- 实时接收响应
- 轻松与各种 AI 客户端和工具集成
- 对所有服务器使用相同的端点,简化集成过程
**智能路由(实验性功能)**
智能路由是 MCPHub 的智能工具发现系统,使用向量语义搜索自动为任何给定任务找到最相关的工具。
```
http://localhost:3000/mcp/$smart
```
**工作原理:**
1. **工具索引**:所有 MCP 工具自动转换为向量嵌入并存储在 PostgreSQL 与 pgvector 中
2. **语义搜索**:用户查询转换为向量并使用余弦相似度与工具嵌入匹配
3. **智能筛选**:动态阈值确保相关结果且无噪声
4. **精确执行**:找到的工具可以直接执行并进行适当的参数验证
**设置要求:**
![智能路由](assets/smart-routing.zh.png)
为了启用智能路由,您需要:
- 支持 pgvector 扩展的 PostgreSQL
- OpenAI API 密钥(或兼容的嵌入服务)
- 在 MCPHub 设置中启用智能路由
**基于分组的 HTTP 端点(推荐)**
![分组](assets/group.zh.png)
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
```
http://localhost:3000/mcp/{group}
```
其中 `{group}` 是您在控制面板中创建的分组 ID 或名称。这样做可以:
- 连接到按用例组织的特定 MCP 服务器子集
- 隔离不同的 AI 工具,使其只能访问相关服务器
- 为不同环境或团队实现更精细的访问控制
- 通过分组名称轻松识别和管理服务器
- 允许不同的 AI 客户端使用相同的端点,简化集成过程
**针对特定服务器的 HTTP 端点**
要针对特定服务器进行访问,请使用以下格式:
```
http://localhost:3000/mcp/{server}
```
其中 `{server}` 是您要连接的服务器名称。这样做可以直接访问特定的 MCP 服务器。
> **提示**:如果服务器名称和分组名称相同,则分组名称优先。
### SSE 端点集成 (未来可能废弃)
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、Cherry Studio 等):
```
http://localhost:3000/sse
```
**基于分组的 SSE 端点(推荐)**
要启用智能路由,请使用:
![分组](assets/group.zh.png)
```
http://localhost:3000/sse/$smart
```
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
```
http://localhost:3000/sse/{groupId}
http://localhost:3000/sse/{group}
```
其中 `{groupId}` 是您在控制面板中创建的分组 ID。这样做可以
- 连接到按用例组织的特定 MCP 服务器子集
- 隔离不同的 AI 工具,使其只能访问相关服务器
- 为不同环境或团队实现更精细的访问控制
要针对特定服务器进行访问,请使用以下格式
```
http://localhost:3000/sse/{server}
```
## 🧑‍💻 本地开发
@@ -120,6 +185,18 @@ pnpm dev
此命令将在开发模式下启动前后端,并启用热重载。
> 针对 Windows 用户,可能需要分别启动后端服务器和前端:`pnpm backend:dev``pnpm frontend:dev`。
## 🛠️ 常见问题
### 使用 nginx 反向代理
如果您在使用 nginx 反向代理 MCPHub请确保在 nginx 配置中添加以下内容:
```nginx
proxy_buffering off
```
## 🔍 技术栈
- **后端**Node.js、Express、TypeScript
@@ -136,9 +213,21 @@ pnpm dev
- Bug 报告与修复
- 翻译与建议
欢迎加入企微交流共建群
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
<img src="assets/wegroup.png" width="500">
<img src="assets/wexin.png" width="350">
如果觉得项目有帮助,不妨请我喝杯咖啡 ☕️
<img src="assets/reward.png" width="350">
## 致谢
感谢以下人员的赞赏:小白、琛。你们的支持是我继续前进的动力!
## 🌟 Star 历史趋势
[![Star History Chart](https://api.star-history.com/svg?repos=samanhappy/mcphub&type=Date)](https://www.star-history.com/#samanhappy/mcphub&Date)
## 📄 许可证

BIN
assets/cursor-mcp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
assets/cursor-query.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/cursor-tools.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/market.zh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
assets/reward.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
assets/smart-routing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
assets/smart-routing.zh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
assets/wegroup.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
assets/wexin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

96
bin/cli.js Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import fs from 'fs';
// Enable debug logging if needed
// process.env.DEBUG = 'true';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Start with more debug information
console.log('📋 MCPHub CLI');
console.log(`📁 CLI script location: ${__dirname}`);
// The npm package directory structure when installed is:
// node_modules/@samanhappy/mcphub/
// - dist/
// - bin/
// - frontend/dist/
// Get the package root - this is where package.json is located
function findPackageRoot() {
const isDebug = process.env.DEBUG === 'true';
// Possible locations for package.json
const possibleRoots = [
// Standard npm package location
path.resolve(__dirname, '..'),
// When installed via npx
path.resolve(__dirname, '..', '..', '..')
];
// Special handling for npx
if (process.argv[1] && process.argv[1].includes('_npx')) {
const npxDir = path.dirname(process.argv[1]);
possibleRoots.unshift(path.resolve(npxDir, '..'));
}
if (isDebug) {
console.log('DEBUG: Checking for package.json in:', possibleRoots);
}
for (const root of possibleRoots) {
const packageJsonPath = path.join(root, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
if (isDebug) {
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
}
return root;
}
} catch (e) {
// Continue to the next potential root
}
}
}
console.log('⚠️ Could not find package.json, using default path');
return path.resolve(__dirname, '..');
}
// Locate and check the frontend distribution
function checkFrontend(packageRoot) {
const isDebug = process.env.DEBUG === 'true';
const frontendDistPath = path.join(packageRoot, 'frontend', 'dist');
if (isDebug) {
console.log(`DEBUG: Checking frontend at: ${frontendDistPath}`);
}
if (fs.existsSync(frontendDistPath) && fs.existsSync(path.join(frontendDistPath, 'index.html'))) {
console.log('✅ Frontend distribution found');
return true;
} else {
console.log('⚠️ Frontend distribution not found at', frontendDistPath);
return false;
}
}
const projectRoot = findPackageRoot();
console.log(`📦 Using package root: ${projectRoot}`);
// Check if frontend exists
checkFrontend(projectRoot);
// Start the server
console.log('🚀 Starting MCPHub server...');
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
console.error('Failed to start MCPHub:', err);
process.exit(1);
});

232
doc/intro2.md Normal file
View File

@@ -0,0 +1,232 @@
# 本地部署、一键安装、分组路由MCPHub 重塑 MCP 服务器体验
## 概述
现代 AI 应用场景中将大模型LLM与各种数据源和工具无缝对接往往需要手动编写大量胶水代码并且无法快速复用。MCPModel Context Protocol协议由 Anthropic 在 2024 年开源旨在提供类似“USBC”接口般的标准化通信方式简化 AI 助手与内容仓库、业务系统等的集成流程。然而MCP 服务器部署常常需要大量环境依赖、手动配置及持续运行开发者常因安装和配置耗费大量时间和精力。MCPHub 作为一款开源的一站式聚合平台,通过直观的 Web UI、Docker 镜像和热插拔配置,实现本地或容器里的“一键安装”与“分组路由”,大幅降低 MCP 服务器的使用门槛和运维成本​。
## MCPHub 是什么
### MCP 协议简介
Model Context ProtocolMCP是一种开放标准类似“USBC”接口为 AI 助手与内容仓库、业务系统和第三方服务之间提供统一通信协议。它支持 stdio 与 SSE最新协议中被 Streamable HTTP 取代两种通信方式既能满足实时流式数据交换也可用于批量任务。2024 年由 Anthropic 团队开源发布后MCP 已在各类 AI 客户端(如 Claude Desktop中得到应用成功实现与 GitHub、Slack、网页自动化工具等的无缝对接。
### MCPHub 项目概览
MCPHub 是一个统一的 MCP 服务器聚合平台,内置 MCP 服务器市场实现一键安装。前端基于 React、Vite 和 Tailwind CSS 构建,后端兼容任意使用 npx 或 uvx 命令启动的 MCP 服务器。它通过一个集中式 Dashboard 实时展示各服务器的运行状态,并支持在运行时热插拔增删改服务器配置,无需停机维护。支持分组式访问控制,可以通过独立的 SSE 端点访问不同的 MCP 服务器组合,管理员可灵活定义不同团队或环境的权限策略。官方提供 Docker 镜像,仅需一条命令即可快速启动本地或云端服务。
![MCPHub 控制面板](../assets/dashboard.zh.png)
## 为什么要使用 MCPHub
### 1. 复杂的环境依赖与配置
- MCP 服务器常依赖 Node.js、Python 等多种运行时,需手动维护大量命令、参数和环境变量。
- MCPHub 内置 MCP 服务器市场,包含众多常用 MCP 服务器,支持一键安装和自动配置,简化了环境搭建过程。
- 通过 Docker 部署MCPHub 可在任何支持 Docker 的平台上运行,避免了环境不一致的问题。
![MCPHub 市场](../assets/market.zh.png)
### 2. 持续运行的服务压力
- MCP 要求长连接服务常驻内存,重启或升级时需要人工干预,缺乏弹性。
- 借助 Docker 容器化部署MCPHub 可快速重建环境,享受容器带来的弹性与隔离优势。
### 3. 路由与分组管理缺乏统一视图
- 传统方式下,很难可视化地将不同 MCP 服务按场景分类,容易造成 token 浪费和工具选择精度下降。
- MCPHub 支持动态创建分组(如“地图检索”、“网页自动化”、“聊天”等),为每个分组生成独立的 SSE 端点,实现各类用例的隔离与优化。
![MCPHub 分组](../assets/group.zh.png)
## 如何使用 MCPHub
### 快速部署
```bash
docker run -p 3000:3000 samanhappy/mcphub
```
一条命令就可以在本地快速启动 MCPHub默认监听 3000 端口。
MCPHub 使用`mcp_settings.json`保存所有服务器、分组和用户的配置。你可以创建一个 `mcp_settings.json` 文件,并将其挂载到 Docker 容器中,以便在重启时保留配置。
```json
{
"mcpServers": {
"amap": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
"AMAP_MAPS_API_KEY": "your-api-key"
}
},
"time-mcp": {
"command": "npx",
"args": [
"-y",
"time-mcp"
]
},
"sequential-thinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
}
}
}
```
然后挂载配置文件启动:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
```
> 注意首次运行时MCPHub 会自动下载并安装所需的依赖包,可能需要一些时间。
### 访问控制台
启动后访问 `http://localhost:3000` 即可进入控制台。
> 默认登录用户名和密码为 `admin`/`admin123`,登录后可以修改密码以确保安全。
控制台提供了服务器管理、分组管理和市场管理等功能,你可以在这里查看所有已安装的 MCP 服务器、创建新的分组、添加或删除服务器等。
### 分组路由 & SSE 端点
#### 全局 SSE 端点
```
http://localhost:3000/sse
```
通过全局 SSE 端点可以访问所有已启用的 MCP 服务器。
#### 基于分组的 SSE 端点
除了全局 SSE 端点MCPHub 还支持基于分组的 SSE 端点。你可以为每个分组创建独立的 SSE 端点,以便更好地管理和路由请求。
分组的 SSE 端点格式如下:
```
http://localhost:3000/sse/{groupId}
```
其中 `{groupId}` 是分组的唯一标识符,可以从控制台获取。比如我在上面的截图中创建了一个名为 `map` 的分组,选择了 `amap``sequential-thinking` 两个服务器,那么可以通过以下 URL 访问这个分组的 SSE 端点:
```
http://localhost:3000/sse/a800bef7-c4c1-4460-9557-5f4404cdd0bd
```
#### 使用分组路由
创建了路由分组后,我们就可以在任意支持 MCP 的客户端中使用这个分组的 SSE 端点了。比如在 `Cursor` 中,我们可以在配置文件中指定分组的 SSE 端点:
```json
{
"mcpServers": {
"mcphub": {
"url": "http://localhost:3000/sse/a800bef7-c4c1-4460-9557-5f4404cdd0bd"
}
}
}
```
配置完成后,可以从 `Cursor` 中看到所有可用的 MCP 服务器工具列表。
![Cursor 中的 MCP 配置](../assets/cursor-mcp.png)
然后,我们可以测试一下,比如输入:深度思考一下,帮我制定一个五一假期从南京出发的自驾行出游计划,要求避开拥堵路线,结合天气情况,并且可以体验到不同的自然风光。
![Cursor 中的测试输入](../assets/cursor-query.png)
接着可以看到,`Cursor` 在运行过程中调用了多个工具。
![Cursor 中的工具调用](../assets/cursor-tools.png)
最终生成结果如下:
```markdown
基于对路线和天气的全面分析,我为你制定了一个四天的五一自驾游行程方案:
### 第一天4月29日南京→黄山
- 出发时间建议早上8点出发
- 行驶路线G42沪蓉高速 → S11巢黄高速
- 行驶时间约4.5小时282公里
- 住宿地点:黄山风景区附近
- 特别提醒:
* 路线多隧道,注意安全
* 天气较好,适合出行
* 建议提前订好黄山景区附近酒店
### 第二天4月30日黄山→婺源
- 游览时间:上午游览黄山景区
- 下午出发前往婺源
- 行驶路线G3京台高速 → G56杭瑞高速
- 行驶时间约2.5小时139公里
- 住宿地点:婺源县城或特色古村落
- 特别提醒:
* 注意天气可能有阵雨
* 建议游览篁岭、李坑等著名古村落
### 第三天5月1日婺源→景德镇
- 上午游览婺源景点
- 中午后出发前往景德镇
- 行驶路线G56杭瑞高速
- 行驶时间约1.5小时77公里
- 住宿地点:景德镇市区
- 特别提醒:
* 可能遇到阵雨天气
* 建议参观陶瓷博物馆和古窑址
### 第四天5月2日景德镇→南京
- 建议上午游览景德镇景点
- 中午后出发返回南京
- 行驶路线G35济广高速 → G4211宁芜高速
- 行驶时间约5小时429公里
- 特别提醒:
* 路程较长,建议做好休息安排
* 注意途中加油站分布
* 建议错峰返程,避免拥堵
### 整体建议:
1. 避堵建议:
- 选择错峰出行时间
- 提前查看实时路况
- 准备备选绕行路线
2. 安全建议:
- 全程多雨,注意雨天行车安全
- 保持车距,谨慎驾驶
- 途经隧道较多,注意灯光使用
3. 行程准备:
- 提前预订住宿
- 准备雨具和应急用品
- 建议下载离线地图
- 检查车况,特别是雨刮和轮胎
4. 景点安排:
- 黄山:建议游览迎客松、光明顶等
- 婺源:篁岭、李坑、江岭等
- 景德镇:陶瓷博物馆、古窑民俗博览区
这条路线可以让你体验到:
- 黄山的壮丽山景
- 婺源的田园风光
- 景德镇的人文历史
既避开了主要拥堵路段,又能欣赏到不同的自然风光。
```
可以看到,`Cursor` 通过调用 `amap``sequential-thinking` 两个服务器,成功生成了一个五一假期的自驾游行程方案,并且避开了拥堵路线,结合了天气情况。但是细心的同学可能发现,计划中的开始时间是 4 月 29 日,而今年的五一假期是 5 月 1 日开始的,产生偏差的原因是 `sequential-thinking` 使用了错误的假期时间。如何解决这个问题呢?我们可以尝试在分组中添加支持搜索的 MCP 服务器,这样就可以在查询时自动纠正错误的假期时间了,具体就不在这里展开了。
## 结语
MCPHub 将本地部署、一键安装、分组路由和可视化管理融为一体,以简洁而强大的设计,彻底解决了 MCP 服务器的部署、配置与运维难题。无论是追求快速验证的开发者,还是需要稳定可靠 AI 工具链的企业用户,都能通过 MCPHub 专注于核心业务与创新,而无需被底层细节所困扰。
尽管目前各家平台都在陆续推出各类 MCP 云服务但在数据隐私、合规性和定制化需求日益增长的背景下MCPHub 仍然是一个值得关注的本地部署解决方案​。
MCPHub 只是我一时兴起开发的小项目,没想到竟收获了这么多关注,非常感谢大家的支持!目前 MCPHub 还有不少地方需要优化和完善我也专门建了个交流群方便大家交流反馈。如果你也对这个项目感兴趣欢迎一起参与建设项目地址为https://github.com/samanhappy/mcphub。
![企业微信交流群](../assets/wegroup.jpg)

271
doc/smart-routing.md Normal file
View File

@@ -0,0 +1,271 @@
# 智能工具发现精准调用MCPHub 智能路由重新定义 AI 工具选择
## 概述
在现代 AI 应用中,随着 MCP 服务器数量的快速增长和工具种类的日益丰富如何从数百个可用工具中快速找到最适合当前任务的工具成为了一个日益突出的挑战。传统方式下AI 助手要么被迫处理所有可用工具的庞大列表,导致 token 消耗激增和响应延迟要么依赖开发者手动分组缺乏灵活性和智能化。MCPHub 的智能路由功能基于向量语义搜索技术,实现了自然语言驱动的工具发现与精准推荐,让 AI 助手能够像人类专家一样,根据任务描述智能地选择最合适的工具组合,大幅提升工作效率和用户体验。
## 智能路由是什么
### 技术原理
智能路由是 MCPHub 的核心创新功能,它基于现代向量语义搜索技术,将每个 MCP 工具的描述、参数和功能特征转换为高维向量表示。当用户提出任务需求时,系统将需求同样转换为向量,通过计算向量间的余弦相似度,快速定位最相关的工具集合。这种方法不依赖精确的关键词匹配,而是理解语义层面的相关性,能够处理自然语言的模糊性和多样性。
### 核心组件
**向量嵌入引擎**:支持 OpenAI text-embedding-3-small、BGE-M3 等多种主流嵌入模型,将工具描述转换为 1536 维或 1024 维向量表示,捕获工具功能的语义特征。
**PostgreSQL + pgvector 数据库**:采用业界领先的向量数据库解决方案,支持高效的向量索引和相似度搜索,能够在毫秒级时间内从大量工具中找到最相关的候选。
**动态阈值算法**根据查询复杂度和具体程度自动调整相似度阈值确保既不遗漏相关工具也不引入无关噪声。简单查询使用较低阈值0.2获得更多样化结果具体查询使用较高阈值0.4)确保精确匹配。
**两步工作流**`search_tools` 负责工具发现,`call_tool` 负责工具执行,清晰分离发现和执行逻辑,提供更好的可控性和调试体验。
## 为什么要使用智能路由
### 1. 解决工具选择的认知负荷
- **信息过载问题**:当 MCP 服务器数量超过 10 个、工具总数超过 100 个时AI 助手面临严重的信息过载,难以在合理时间内做出最优选择。
- **智能路由优势**:通过语义搜索将候选工具缩减到 5-10 个最相关的选项,让 AI 助手能够专注于理解和使用最合适的工具,而不是被迫处理庞大的工具清单。
### 2. 大幅降低 Token 消耗
- **传统方式的成本**:向 AI 模型传递完整的工具列表会消耗大量 token特别是当工具描述详细时单次请求可能消耗数千 token。
- **智能路由的效益**:只传递最相关的工具信息,通常可以将工具相关的 token 消耗降低 70-90%,显著减少 API 调用成本,特别是在频繁交互的场景中。
### 3. 提升工具使用的准确性
- **语义理解能力**:智能路由能够理解"图片处理"、"数据分析"、"文档转换"等抽象概念,将其映射到具体的工具实现,避免了传统关键词匹配的局限性。
- **上下文感知**:考虑工具的输入输出模式和使用场景,推荐最适合当前任务流程的工具组合。
![智能路由工作原理](../assets/smart-routing-flow.png)
## 如何使用智能路由
### 配置智能路由
#### 1. 数据库配置
智能路由需要 PostgreSQL 数据库支持 pgvector 扩展:
```bash
# 使用 Docker 快速启动支持 pgvector 的 PostgreSQL
docker run --name mcphub-postgres \
-e POSTGRES_DB=mcphub \
-e POSTGRES_USER=mcphub \
-e POSTGRES_PASSWORD=your_password \
-p 5432:5432 \
-d pgvector/pgvector:pg16
```
#### 2. 在 MCPHub 控制台配置智能路由
访问 MCPHub 设置页面http://localhost:3000/settings在"智能路由配置"部分填写:
- **数据库 URL**`postgresql://mcphub:your_password@localhost:5432/mcphub`
- **OpenAI API Key**:您的 OpenAI API 密钥
- **API Base URL**:可选,默认为 `https://api.openai.com/v1`
- **嵌入模型**:推荐使用 `text-embedding-3-small`1536 维,性价比最佳)
![智能路由配置界面](../assets/smart-routing-config.png)
#### 3. 启用智能路由
配置完成后,开启"启用智能路由"开关。系统将自动:
- 为所有已连接的 MCP 服务器工具生成向量嵌入
- 建立向量索引以支持快速搜索
- 在后续新增工具时自动更新向量数据库
### 智能工具发现的使用方式
启用智能路由后MCPHub 会自动提供两个核心工具:
#### search_tools - 智能工具搜索
```typescript
// 使用示例
{
"name": "search_tools",
"arguments": {
"query": "help me process images and resize them", // 自然语言查询
"limit": 10 // 返回结果数量
}
}
```
**查询策略建议**
- **宽泛查询**:使用较高的 limit20-30如"数据处理工具"
- **精确查询**:使用较低的 limit5-10如"将 PNG 图片转换为 WebP 格式"
- **分步查询**:复杂任务可以分解为多个具体查询
#### call_tool - 精准工具执行
```typescript
// 使用示例
{
"name": "call_tool",
"arguments": {
"toolName": "image_resize", // 从 search_tools 结果中获取的工具名
"arguments": { // 根据工具的 inputSchema 提供参数
"input_path": "/path/to/image.png",
"width": 800,
"height": 600
}
}
}
```
### 实际应用场景演示
#### 场景 1图像处理工作流
```markdown
用户请求:我需要批量处理一些产品图片,调整大小并转换格式
AI 工作流:
1. search_tools("image processing batch resize convert format")
→ 返回image_batch_processor, format_converter, image_optimizer
2. call_tool("image_batch_processor", {...})
→ 执行批量图像处理
```
#### 场景 2数据分析任务
```markdown
用户请求:分析这个 CSV 文件的销售数据,生成可视化图表
AI 工作流:
1. search_tools("CSV data analysis visualization charts")
→ 返回csv_analyzer, chart_generator, statistics_calculator
2. call_tool("csv_analyzer", {"file_path": "sales.csv"})
3. call_tool("chart_generator", {"data": analysis_result})
```
#### 场景 3文档处理流水线
```markdown
用户请求:将 Word 文档转换为 PDF然后提取其中的文本内容
AI 工作流:
1. search_tools("document conversion Word to PDF")
→ 返回doc_converter, pdf_generator
2. call_tool("doc_converter", {"input": "document.docx", "output_format": "pdf"})
3. search_tools("PDF text extraction")
→ 返回pdf_text_extractor, ocr_processor
4. call_tool("pdf_text_extractor", {"pdf_path": "document.pdf"})
```
### 高级配置选项
#### 多模型支持
智能路由支持多种嵌入模型,可根据需求选择:
```json
{
"embeddingModel": "text-embedding-3-small", // OpenAI1536维平衡性能和成本
"embeddingModel": "text-embedding-3-large", // OpenAI3072维最高精度
"embeddingModel": "bge-m3" // 开源模型1024维支持多语言
}
```
#### 自定义 API 端点
支持使用自建的嵌入服务或其他 OpenAI 兼容的 API
```json
{
"openaiApiBaseUrl": "https://your-api-endpoint.com/v1",
"openaiApiKey": "your-custom-api-key"
}
```
## 性能优化与最佳实践
### 查询优化策略
**分层查询**:对于复杂任务,使用从宽泛到具体的查询策略:
```
1. 宽泛查询:"文档处理工具" (limit: 20)
2. 具体查询:"PDF 转 Word 转换器" (limit: 5)
```
**上下文相关性**:在查询中包含任务上下文信息:
```
search_tools("为电商网站批量压缩产品图片")
较好search_tools("图片压缩工具")
```
**动态调整**:根据返回结果质量动态调整查询词和限制数量。
### 数据库性能调优
**索引优化**:智能路由自动创建最优的向量索引:
```sql
CREATE INDEX idx_vector_embeddings_embedding
ON vector_embeddings USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
```
**内存配置**:对于大规模部署,建议增加 PostgreSQL 内存配置:
```
shared_buffers = 256MB
effective_cache_size = 1GB
work_mem = 64MB
```
### 监控与调试
**相似度阈值监控**:观察搜索结果的相似度分数,调整阈值以获得最佳效果。
**查询效果分析**:定期检查常用查询的返回结果,优化工具描述以提高搜索准确性。
## 智能路由的技术优势
### 语义理解能力
与传统的关键词匹配相比,智能路由能够理解:
- **同义词和近义词**"图片"和"图像"、"转换"和"变换"
- **上下层级概念**"数据可视化"包含"图表生成"、"统计图绘制"等
- **任务意图推理**"我要做一个数据报告"自动关联数据分析、图表生成、文档创建等工具
### 多语言支持
智能路由支持中英文混合查询,能够处理:
```
search_tools("图片 resize 和 format conversion")
search_tools("将文档转换为 PDF 格式")
search_tools("image processing and 格式转换")
```
### 容错能力
具备一定的容错能力,能够处理:
- 拼写错误:自动纠正常见拼写错误
- 模糊描述:从不完整的描述中推导完整意图
- 领域术语:理解特定领域的专业术语
## 结语
MCPHub 的智能路由功能代表着 MCP 生态系统向智能化方向发展的重要一步。通过引入向量语义搜索技术,它不仅解决了工具数量激增带来的选择困难,更为 AI 助手提供了类似人类专家的工具发现和选择能力。
随着 MCP 服务器生态的不断丰富,智能路由将成为连接用户需求与丰富工具资源的关键桥梁。它让开发者无需担心工具管理的复杂性,让用户享受到更加智能和高效的 AI 助手体验。
未来,我们还将继续优化智能路由的算法,引入更多先进的 AI 技术,如基于强化学习的工具推荐、多模态工具理解等,为 MCP 生态系统注入更强的智能化动力。
智能路由不仅仅是一个技术功能,更是 MCPHub 对于"让 AI 工具使用变得简单而智能"这一愿景的具体实现。在这个工具爆炸的时代,智能路由让我们重新定义了 AI 与工具的交互方式。
项目地址https://github.com/samanhappy/mcphub
![智能路由架构图](../assets/smart-routing-architecture.png)

32
docs/README.md Normal file
View File

@@ -0,0 +1,32 @@
# Mintlify Starter Kit
Click on `Use this template` to copy the Mintlify starter kit. The starter kit contains examples including
- Guide pages
- Navigation
- Customizations
- API Reference pages
- Use of popular components
### Development
Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
```
npm i -g mintlify
```
Run the following command at the root of your documentation (where docs.json is)
```
mintlify dev
```
### Publishing Changes
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
#### Troubleshooting
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
- Page loads as a 404 - Make sure you are running in a folder with `docs.json`

View File

@@ -0,0 +1,4 @@
---
title: 'Create Plant'
openapi: 'POST /plants'
---

View File

@@ -0,0 +1,4 @@
---
title: 'Delete Plant'
openapi: 'DELETE /plants/{id}'
---

View File

@@ -0,0 +1,4 @@
---
title: 'Get Plants'
openapi: 'GET /plants'
---

View File

@@ -0,0 +1,4 @@
---
title: 'New Plant'
openapi: 'WEBHOOK /plant/webhook'
---

View File

@@ -0,0 +1,33 @@
---
title: 'Introduction'
description: 'Example section for showcasing API endpoints'
---
<Note>
If you're not looking to build API reference documentation, you can delete
this section by removing the api-reference folder.
</Note>
## Welcome
There are two ways to build API documentation: [OpenAPI](https://mintlify.com/docs/api-playground/openapi/setup) and [MDX components](https://mintlify.com/docs/api-playground/mdx/configuration). For the starter kit, we are using the following OpenAPI specification.
<Card
title="Plant Store Endpoints"
icon="leaf"
href="https://github.com/mintlify/starter/blob/main/api-reference/openapi.json"
>
View the OpenAPI specification file
</Card>
## Authentication
All API endpoints are authenticated using Bearer tokens and picked up from the specification file.
```json
"security": [
{
"bearerAuth": []
}
]
```

View File

@@ -0,0 +1,217 @@
{
"openapi": "3.1.0",
"info": {
"title": "OpenAPI Plant Store",
"description": "A sample API that uses a plant store as an example to demonstrate features in the OpenAPI specification",
"license": {
"name": "MIT"
},
"version": "1.0.0"
},
"servers": [
{
"url": "http://sandbox.mintlify.com"
}
],
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/plants": {
"get": {
"description": "Returns all plants from the system that the user has access to",
"parameters": [
{
"name": "limit",
"in": "query",
"description": "The maximum number of results to return",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Plant response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plant"
}
}
}
}
},
"400": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"post": {
"description": "Creates a new plant in the store",
"requestBody": {
"description": "Plant to add to the store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NewPlant"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "plant response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Plant"
}
}
}
},
"400": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/plants/{id}": {
"delete": {
"description": "Deletes a single plant based on the ID supplied",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of plant to delete",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"204": {
"description": "Plant deleted",
"content": {}
},
"400": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
},
"webhooks": {
"/plant/webhook": {
"post": {
"description": "Information about a new plant added to the store",
"requestBody": {
"description": "Plant added to the store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NewPlant"
}
}
}
},
"responses": {
"200": {
"description": "Return a 200 status to indicate that the data was received successfully"
}
}
}
}
},
"components": {
"schemas": {
"Plant": {
"required": [
"name"
],
"type": "object",
"properties": {
"name": {
"description": "The name of the plant",
"type": "string"
},
"tag": {
"description": "Tag to specify the type",
"type": "string"
}
}
},
"NewPlant": {
"allOf": [
{
"$ref": "#/components/schemas/Plant"
},
{
"required": [
"id"
],
"type": "object",
"properties": {
"id": {
"description": "Identification number of the plant",
"type": "integer",
"format": "int64"
}
}
}
]
},
"Error": {
"required": [
"error",
"message"
],
"type": "object",
"properties": {
"error": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
}
}
}
},
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer"
}
}
}
}

107
docs/development.mdx Normal file
View File

@@ -0,0 +1,107 @@
---
title: 'Development'
description: 'Preview changes locally to update your docs'
---
<Info>
**Prerequisite**: Please install Node.js (version 19 or higher) before proceeding. <br />
Please upgrade to ```docs.json``` before proceeding and delete the legacy ```mint.json``` file.
</Info>
Follow these steps to install and run Mintlify on your operating system:
**Step 1**: Install Mintlify:
<CodeGroup>
```bash npm
npm i -g mintlify
```
```bash yarn
yarn global add mintlify
```
</CodeGroup>
**Step 2**: Navigate to the docs directory (where the `docs.json` file is located) and execute the following command:
```bash
mintlify dev
```
A local preview of your documentation will be available at `http://localhost:3000`.
### Custom Ports
By default, Mintlify uses port 3000. You can customize the port Mintlify runs on by using the `--port` flag. To run Mintlify on port 3333, for instance, use this command:
```bash
mintlify dev --port 3333
```
If you attempt to run Mintlify on a port that's already in use, it will use the next available port:
```md
Port 3000 is already in use. Trying 3001 instead.
```
## Mintlify Versions
Please note that each CLI release is associated with a specific version of Mintlify. If your local website doesn't align with the production version, please update the CLI:
<CodeGroup>
```bash npm
npm i -g mintlify@latest
```
```bash yarn
yarn global upgrade mintlify
```
</CodeGroup>
## Validating Links
The CLI can assist with validating reference links made in your documentation. To identify any broken links, use the following command:
```bash
mintlify broken-links
```
## Deployment
<Tip>
Unlimited editors available under the [Pro
Plan](https://mintlify.com/pricing) and above.
</Tip>
If the deployment is successful, you should see the following:
<Frame>
<img src="/images/checks-passed.png" style={{ borderRadius: '0.5rem' }} />
</Frame>
## Code Formatting
We suggest using extensions on your IDE to recognize and format MDX. If you're a VSCode user, consider the [MDX VSCode extension](https://marketplace.visualstudio.com/items?itemName=unifiedjs.vscode-mdx) for syntax highlighting, and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for code formatting.
## Troubleshooting
<AccordionGroup>
<Accordion title='Error: Could not load the "sharp" module using the darwin-arm64 runtime'>
This may be due to an outdated version of node. Try the following:
1. Remove the currently-installed version of mintlify: `npm remove -g mintlify`
2. Upgrade to Node v19 or higher.
3. Reinstall mintlify: `npm install -g mintlify`
</Accordion>
<Accordion title="Issue: Encountering an unknown error">
Solution: Go to the root of your device and delete the \~/.mintlify folder. Afterwards, run `mintlify dev` again.
</Accordion>
</AccordionGroup>
Curious about what changed in the CLI version? [Check out the CLI changelog.](https://www.npmjs.com/package/mintlify?activeTab=versions)

102
docs/docs.json Normal file
View File

@@ -0,0 +1,102 @@
{
"$schema": "https://mintlify.com/docs.json",
"theme": "mint",
"name": "Mint Starter Kit",
"colors": {
"primary": "#16A34A",
"light": "#07C983",
"dark": "#15803D"
},
"favicon": "/favicon.svg",
"navigation": {
"tabs": [
{
"tab": "Guides",
"groups": [
{
"group": "Get Started",
"pages": [
"index",
"quickstart",
"development"
]
},
{
"group": "Essentials",
"pages": [
"essentials/markdown",
"essentials/code",
"essentials/images",
"essentials/settings",
"essentials/navigation",
"essentials/reusable-snippets"
]
}
]
},
{
"tab": "API Reference",
"groups": [
{
"group": "API Documentation",
"pages": [
"api-reference/introduction"
]
},
{
"group": "Endpoint Examples",
"pages": [
"api-reference/endpoint/get",
"api-reference/endpoint/create",
"api-reference/endpoint/delete",
"api-reference/endpoint/webhook"
]
}
]
}
],
"global": {
"anchors": [
{
"anchor": "Documentation",
"href": "https://mintlify.com/docs",
"icon": "book-open-cover"
},
{
"anchor": "Community",
"href": "https://mintlify.com/community",
"icon": "slack"
},
{
"anchor": "Blog",
"href": "https://mintlify.com/blog",
"icon": "newspaper"
}
]
}
},
"logo": {
"light": "/logo/light.svg",
"dark": "/logo/dark.svg"
},
"navbar": {
"links": [
{
"label": "Support",
"href": "mailto:hi@mintlify.com"
}
],
"primary": {
"type": "button",
"label": "Dashboard",
"href": "https://dashboard.mintlify.com"
}
},
"footer": {
"socials": {
"x": "https://x.com/mintlify",
"github": "https://github.com/mintlify",
"linkedin": "https://linkedin.com/company/mintlify"
}
}
}

37
docs/essentials/code.mdx Normal file
View File

@@ -0,0 +1,37 @@
---
title: 'Code Blocks'
description: 'Display inline code and code blocks'
icon: 'code'
---
## Basic
### Inline Code
To denote a `word` or `phrase` as code, enclose it in backticks (`).
```
To denote a `word` or `phrase` as code, enclose it in backticks (`).
```
### Code Block
Use [fenced code blocks](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks) by enclosing code in three backticks and follow the leading ticks with the programming language of your snippet to get syntax highlighting. Optionally, you can also write the name of your code after the programming language.
```java HelloWorld.java
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
```
````md
```java HelloWorld.java
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
```
````

View File

@@ -0,0 +1,59 @@
---
title: 'Images and Embeds'
description: 'Add image, video, and other HTML elements'
icon: 'image'
---
<img
style={{ borderRadius: '0.5rem' }}
src="https://mintlify-assets.b-cdn.net/bigbend.jpg"
/>
## Image
### Using Markdown
The [markdown syntax](https://www.markdownguide.org/basic-syntax/#images) lets you add images using the following code
```md
![title](/path/image.jpg)
```
Note that the image file size must be less than 5MB. Otherwise, we recommend hosting on a service like [Cloudinary](https://cloudinary.com/) or [S3](https://aws.amazon.com/s3/). You can then use that URL and embed.
### Using Embeds
To get more customizability with images, you can also use [embeds](/writing-content/embed) to add images
```html
<img height="200" src="/path/image.jpg" />
```
## Embeds and HTML elements
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/4KzFe50RQkQ"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ width: '100%', borderRadius: '0.5rem' }}
></iframe>
<br />
<Tip>
Mintlify supports [HTML tags in Markdown](https://www.markdownguide.org/basic-syntax/#html). This is helpful if you prefer HTML tags to Markdown syntax, and lets you create documentation with infinite flexibility.
</Tip>
### iFrames
Loads another HTML page within the document. Most commonly used for embedding videos.
```html
<iframe src="https://www.youtube.com/embed/4KzFe50RQkQ"> </iframe>
```

View File

@@ -0,0 +1,88 @@
---
title: 'Markdown Syntax'
description: 'Text, title, and styling in standard markdown'
icon: 'text-size'
---
## Titles
Best used for section headers.
```md
## Titles
```
### Subtitles
Best use to subsection headers.
```md
### Subtitles
```
<Tip>
Each **title** and **subtitle** creates an anchor and also shows up on the table of contents on the right.
</Tip>
## Text Formatting
We support most markdown formatting. Simply add `**`, `_`, or `~` around text to format it.
| Style | How to write it | Result |
| ------------- | ----------------- | --------------- |
| Bold | `**bold**` | **bold** |
| Italic | `_italic_` | _italic_ |
| Strikethrough | `~strikethrough~` | ~strikethrough~ |
You can combine these. For example, write `**_bold and italic_**` to get **_bold and italic_** text.
You need to use HTML to write superscript and subscript text. That is, add `<sup>` or `<sub>` around your text.
| Text Size | How to write it | Result |
| ----------- | ------------------------ | ---------------------- |
| Superscript | `<sup>superscript</sup>` | <sup>superscript</sup> |
| Subscript | `<sub>subscript</sub>` | <sub>subscript</sub> |
## Linking to Pages
You can add a link by wrapping text in `[]()`. You would write `[link to google](https://google.com)` to [link to google](https://google.com).
Links to pages in your docs need to be root-relative. Basically, you should include the entire folder path. For example, `[link to text](/writing-content/text)` links to the page "Text" in our components section.
Relative links like `[link to text](../text)` will open slower because we cannot optimize them as easily.
## Blockquotes
### Singleline
To create a blockquote, add a `>` in front of a paragraph.
> Dorothy followed her through many of the beautiful rooms in her castle.
```md
> Dorothy followed her through many of the beautiful rooms in her castle.
```
### Multiline
> Dorothy followed her through many of the beautiful rooms in her castle.
>
> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
```md
> Dorothy followed her through many of the beautiful rooms in her castle.
>
> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
```
### LaTeX
Mintlify supports [LaTeX](https://www.latex-project.org) through the Latex component.
<Latex>8 x (vk x H1 - H2) = (0,1)</Latex>
```md
<Latex>8 x (vk x H1 - H2) = (0,1)</Latex>
```

View File

@@ -0,0 +1,87 @@
---
title: 'Navigation'
description: 'The navigation field in docs.json defines the pages that go in the navigation menu'
icon: 'map'
---
The navigation menu is the list of links on every website.
You will likely update `docs.json` every time you add a new page. Pages do not show up automatically.
## Navigation syntax
Our navigation syntax is recursive which means you can make nested navigation groups. You don't need to include `.mdx` in page names.
<CodeGroup>
```json Regular Navigation
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Getting Started",
"pages": ["quickstart"]
}
]
}
]
}
```
```json Nested Navigation
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Getting Started",
"pages": [
"quickstart",
{
"group": "Nested Reference Pages",
"pages": ["nested-reference-page"]
}
]
}
]
}
]
}
```
</CodeGroup>
## Folders
Simply put your MDX files in folders and update the paths in `docs.json`.
For example, to have a page at `https://yoursite.com/your-folder/your-page` you would make a folder called `your-folder` containing an MDX file called `your-page.mdx`.
<Warning>
You cannot use `api` for the name of a folder unless you nest it inside another folder. Mintlify uses Next.js which reserves the top-level `api` folder for internal server calls. A folder name such as `api-reference` would be accepted.
</Warning>
```json Navigation With Folder
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Group Name",
"pages": ["your-folder/your-page"]
}
]
}
]
}
```
## Hidden Pages
MDX files not included in `docs.json` will not show up in the sidebar but are accessible through the search bar and by linking directly to them.

View File

@@ -0,0 +1,110 @@
---
title: Reusable Snippets
description: Reusable, custom snippets to keep content in sync
icon: 'recycle'
---
import SnippetIntro from '/snippets/snippet-intro.mdx';
<SnippetIntro />
## Creating a custom snippet
**Pre-condition**: You must create your snippet file in the `snippets` directory.
<Note>
Any page in the `snippets` directory will be treated as a snippet and will not
be rendered into a standalone page. If you want to create a standalone page
from the snippet, import the snippet into another file and call it as a
component.
</Note>
### Default export
1. Add content to your snippet file that you want to re-use across multiple
locations. Optionally, you can add variables that can be filled in via props
when you import the snippet.
```mdx snippets/my-snippet.mdx
Hello world! This is my content I want to reuse across pages. My keyword of the
day is {word}.
```
<Warning>
The content that you want to reuse must be inside the `snippets` directory in
order for the import to work.
</Warning>
2. Import the snippet into your destination file.
```mdx destination-file.mdx
---
title: My title
description: My Description
---
import MySnippet from '/snippets/path/to/my-snippet.mdx';
## Header
Lorem impsum dolor sit amet.
<MySnippet word="bananas" />
```
### Reusable variables
1. Export a variable from your snippet file:
```mdx snippets/path/to/custom-variables.mdx
export const myName = 'my name';
export const myObject = { fruit: 'strawberries' };
```
2. Import the snippet from your destination file and use the variable:
```mdx destination-file.mdx
---
title: My title
description: My Description
---
import { myName, myObject } from '/snippets/path/to/custom-variables.mdx';
Hello, my name is {myName} and I like {myObject.fruit}.
```
### Reusable components
1. Inside your snippet file, create a component that takes in props by exporting
your component in the form of an arrow function.
```mdx snippets/custom-component.mdx
export const MyComponent = ({ title }) => (
<div>
<h1>{title}</h1>
<p>... snippet content ...</p>
</div>
);
```
<Warning>
MDX does not compile inside the body of an arrow function. Stick to HTML
syntax when you can or use a default export if you need to use MDX.
</Warning>
2. Import the snippet into your destination file and pass in the props
```mdx destination-file.mdx
---
title: My title
description: My Description
---
import { MyComponent } from '/snippets/custom-component.mdx';
Lorem ipsum dolor sit amet.
<MyComponent title={'Custom title'} />
```

View File

@@ -0,0 +1,318 @@
---
title: 'Global Settings'
description: 'Mintlify gives you complete control over the look and feel of your documentation using the docs.json file'
icon: 'gear'
---
Every Mintlify site needs a `docs.json` file with the core configuration settings. Learn more about the [properties](#properties) below.
## Properties
<ResponseField name="name" type="string" required>
Name of your project. Used for the global title.
Example: `mintlify`
</ResponseField>
<ResponseField name="navigation" type="Navigation[]" required>
An array of groups with all the pages within that group
<Expandable title="Navigation">
<ResponseField name="group" type="string">
The name of the group.
Example: `Settings`
</ResponseField>
<ResponseField name="pages" type="string[]">
The relative paths to the markdown files that will serve as pages.
Example: `["customization", "page"]`
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="logo" type="string or object">
Path to logo image or object with path to "light" and "dark" mode logo images
<Expandable title="Logo">
<ResponseField name="light" type="string">
Path to the logo in light mode
</ResponseField>
<ResponseField name="dark" type="string">
Path to the logo in dark mode
</ResponseField>
<ResponseField name="href" type="string" default="/">
Where clicking on the logo links you to
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="favicon" type="string">
Path to the favicon image
</ResponseField>
<ResponseField name="colors" type="Colors">
Hex color codes for your global theme
<Expandable title="Colors">
<ResponseField name="primary" type="string" required>
The primary color. Used for most often for highlighted content, section
headers, accents, in light mode
</ResponseField>
<ResponseField name="light" type="string">
The primary color for dark mode. Used for most often for highlighted
content, section headers, accents, in dark mode
</ResponseField>
<ResponseField name="dark" type="string">
The primary color for important buttons
</ResponseField>
<ResponseField name="background" type="object">
The color of the background in both light and dark mode
<Expandable title="Object">
<ResponseField name="light" type="string" required>
The hex color code of the background in light mode
</ResponseField>
<ResponseField name="dark" type="string" required>
The hex color code of the background in dark mode
</ResponseField>
</Expandable>
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="topbarLinks" type="TopbarLink[]">
Array of `name`s and `url`s of links you want to include in the topbar
<Expandable title="TopbarLink">
<ResponseField name="name" type="string">
The name of the button.
Example: `Contact us`
</ResponseField>
<ResponseField name="url" type="string">
The url once you click on the button. Example: `https://mintlify.com/docs`
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="topbarCtaButton" type="Call to Action">
<Expandable title="Topbar Call to Action">
<ResponseField name="type" type={'"link" or "github"'} default="link">
Link shows a button. GitHub shows the repo information at the url provided including the number of GitHub stars.
</ResponseField>
<ResponseField name="url" type="string">
If `link`: What the button links to.
If `github`: Link to the repository to load GitHub information from.
</ResponseField>
<ResponseField name="name" type="string">
Text inside the button. Only required if `type` is a `link`.
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="versions" type="string[]">
Array of version names. Only use this if you want to show different versions
of docs with a dropdown in the navigation bar.
</ResponseField>
<ResponseField name="anchors" type="Anchor[]">
An array of the anchors, includes the `icon`, `color`, and `url`.
<Expandable title="Anchor">
<ResponseField name="icon" type="string">
The [Font Awesome](https://fontawesome.com/search?q=heart) icon used to feature the anchor.
Example: `comments`
</ResponseField>
<ResponseField name="name" type="string">
The name of the anchor label.
Example: `Community`
</ResponseField>
<ResponseField name="url" type="string">
The start of the URL that marks what pages go in the anchor. Generally, this is the name of the folder you put your pages in.
</ResponseField>
<ResponseField name="color" type="string">
The hex color of the anchor icon background. Can also be a gradient if you pass an object with the properties `from` and `to` that are each a hex color.
</ResponseField>
<ResponseField name="version" type="string">
Used if you want to hide an anchor until the correct docs version is selected.
</ResponseField>
<ResponseField name="isDefaultHidden" type="boolean" default="false">
Pass `true` if you want to hide the anchor until you directly link someone to docs inside it.
</ResponseField>
<ResponseField name="iconType" default="duotone" type="string">
One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin"
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="topAnchor" type="Object">
Override the default configurations for the top-most anchor.
<Expandable title="Object">
<ResponseField name="name" default="Documentation" type="string">
The name of the top-most anchor
</ResponseField>
<ResponseField name="icon" default="book-open" type="string">
Font Awesome icon.
</ResponseField>
<ResponseField name="iconType" default="duotone" type="string">
One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin"
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="tabs" type="Tabs[]">
An array of navigational tabs.
<Expandable title="Tabs">
<ResponseField name="name" type="string">
The name of the tab label.
</ResponseField>
<ResponseField name="url" type="string">
The start of the URL that marks what pages go in the tab. Generally, this
is the name of the folder you put your pages in.
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="api" type="API">
Configuration for API settings. Learn more about API pages at [API Components](/api-playground/demo).
<Expandable title="API">
<ResponseField name="baseUrl" type="string">
The base url for all API endpoints. If `baseUrl` is an array, it will enable for multiple base url
options that the user can toggle.
</ResponseField>
<ResponseField name="auth" type="Auth">
<Expandable title="Auth">
<ResponseField name="method" type='"bearer" | "basic" | "key"'>
The authentication strategy used for all API endpoints.
</ResponseField>
<ResponseField name="name" type="string">
The name of the authentication parameter used in the API playground.
If method is `basic`, the format should be `[usernameName]:[passwordName]`
</ResponseField>
<ResponseField name="inputPrefix" type="string">
The default value that's designed to be a prefix for the authentication input field.
E.g. If an `inputPrefix` of `AuthKey` would inherit the default input result of the authentication field as `AuthKey`.
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="playground" type="Playground">
Configurations for the API playground
<Expandable title="Playground">
<ResponseField name="mode" default="show" type='"show" | "simple" | "hide"'>
Whether the playground is showing, hidden, or only displaying the endpoint with no added user interactivity `simple`
Learn more at the [playground guides](/api-playground/demo)
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="maintainOrder" type="boolean">
Enabling this flag ensures that key ordering in OpenAPI pages matches the key ordering defined in the OpenAPI file.
<Warning>This behavior will soon be enabled by default, at which point this field will be deprecated.</Warning>
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="openapi" type="string | string[]">
A string or an array of strings of URL(s) or relative path(s) pointing to your
OpenAPI file.
Examples:
<CodeGroup>
```json Absolute
"openapi": "https://example.com/openapi.json"
```
```json Relative
"openapi": "/openapi.json"
```
```json Multiple
"openapi": ["https://example.com/openapi1.json", "/openapi2.json", "/openapi3.json"]
```
</CodeGroup>
</ResponseField>
<ResponseField name="footerSocials" type="FooterSocials">
An object of social media accounts where the key:property pair represents the social media platform and the account url.
Example:
```json
{
"x": "https://x.com/mintlify",
"website": "https://mintlify.com"
}
```
<Expandable title="FooterSocials">
<ResponseField name="[key]" type="string">
One of the following values `website`, `facebook`, `x`, `discord`, `slack`, `github`, `linkedin`, `instagram`, `hacker-news`
Example: `x`
</ResponseField>
<ResponseField name="property" type="string">
The URL to the social platform.
Example: `https://x.com/mintlify`
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="feedback" type="Feedback">
Configurations to enable feedback buttons
<Expandable title="Feedback">
<ResponseField name="suggestEdit" type="boolean" default="false">
Enables a button to allow users to suggest edits via pull requests
</ResponseField>
<ResponseField name="raiseIssue" type="boolean" default="false">
Enables a button to allow users to raise an issue about the documentation
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="modeToggle" type="ModeToggle">
Customize the dark mode toggle.
<Expandable title="ModeToggle">
<ResponseField name="default" type={'"light" or "dark"'}>
Set if you always want to show light or dark mode for new users. When not
set, we default to the same mode as the user's operating system.
</ResponseField>
<ResponseField name="isHidden" type="boolean" default="false">
Set to true to hide the dark/light mode toggle. You can combine `isHidden` with `default` to force your docs to only use light or dark mode. For example:
<CodeGroup>
```json Only Dark Mode
"modeToggle": {
"default": "dark",
"isHidden": true
}
```
```json Only Light Mode
"modeToggle": {
"default": "light",
"isHidden": true
}
```
</CodeGroup>
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="backgroundImage" type="string">
A background image to be displayed behind every page. See example with
[Infisical](https://infisical.com/docs) and [FRPC](https://frpc.io).
</ResponseField>

19
docs/favicon.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.06145 23.1079C5.26816 22.3769 -3.39077 20.6274 1.4173 5.06384C9.6344 6.09939 16.9728 14.0644 9.06145 23.1079Z" fill="url(#paint0_linear_17557_2021)"/>
<path d="M8.91928 23.0939C5.27642 21.2223 0.78371 4.20891 17.0071 0C20.7569 7.19341 19.6212 16.5452 8.91928 23.0939Z" fill="url(#paint1_linear_17557_2021)"/>
<path d="M8.91388 23.0788C8.73534 19.8817 10.1585 9.08525 23.5699 13.1107C23.1812 20.1229 18.984 26.4182 8.91388 23.0788Z" fill="url(#paint2_linear_17557_2021)"/>
<defs>
<linearGradient id="paint0_linear_17557_2021" x1="3.77557" y1="5.91571" x2="5.23185" y2="21.5589" gradientUnits="userSpaceOnUse">
<stop stop-color="#18E299"/>
<stop offset="1" stop-color="#15803D"/>
</linearGradient>
<linearGradient id="paint1_linear_17557_2021" x1="12.1711" y1="-0.718425" x2="10.1897" y2="22.9832" gradientUnits="userSpaceOnUse">
<stop stop-color="#16A34A"/>
<stop offset="1" stop-color="#4ADE80"/>
</linearGradient>
<linearGradient id="paint2_linear_17557_2021" x1="23.1327" y1="15.353" x2="9.33841" y2="18.5196" gradientUnits="userSpaceOnUse">
<stop stop-color="#4ADE80"/>
<stop offset="1" stop-color="#0D9373"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
docs/images/hero-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
docs/images/hero-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

71
docs/index.mdx Normal file
View File

@@ -0,0 +1,71 @@
---
title: Introduction
description: "Welcome to the home of your new documentation"
---
<img
className="block dark:hidden"
src="/images/hero-light.png"
alt="Hero Light"
/>
<img
className="hidden dark:block"
src="/images/hero-dark.png"
alt="Hero Dark"
/>
## Setting up
The first step to world-class documentation is setting up your editing environments.
<CardGroup cols={2}>
<Card
title="Edit Your Docs"
icon="pen-to-square"
href="https://mintlify.com/docs/quickstart"
>
Get your docs set up locally for easy development
</Card>
<Card
title="Preview Changes"
icon="image"
href="https://mintlify.com/docs/development"
>
Preview your changes before you push to make sure they're perfect
</Card>
</CardGroup>
## Make it yours
Update your docs to your brand and add valuable content for the best user conversion.
<CardGroup cols={2}>
<Card
title="Customize Style"
icon="palette"
href="https://mintlify.com/docs/settings/global"
>
Customize your docs to your company's colors and brands
</Card>
<Card
title="Reference APIs"
icon="code"
href="https://mintlify.com/docs/api-playground/openapi"
>
Automatically generate endpoints from an OpenAPI spec
</Card>
<Card
title="Add Components"
icon="screwdriver-wrench"
href="https://mintlify.com/docs/content/components/accordions"
>
Build interactive features and designs to guide your users
</Card>
<Card
title="Get Inspiration"
icon="stars"
href="https://mintlify.com/customers"
>
Check out our showcase of our favorite documentation
</Card>
</CardGroup>

21
docs/logo/dark.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

21
docs/logo/light.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

97
docs/quickstart.mdx Normal file
View File

@@ -0,0 +1,97 @@
---
title: 'Quickstart'
description: 'Start building awesome documentation in under 5 minutes'
---
## Setup your development
Learn how to update your docs locally and deploy them to the public.
### Edit and preview
<AccordionGroup>
<Accordion icon="github" title="Clone your docs locally">
During the onboarding process, we created a repository on your Github with
your docs content. You can find this repository on our
[dashboard](https://dashboard.mintlify.com). To clone the repository
locally, follow these
[instructions](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository)
in your terminal.
</Accordion>
<Accordion icon="rectangle-terminal" title="Preview changes">
Previewing helps you make sure your changes look as intended. We built a
command line interface to render these changes locally.
1. Install the
[Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the
documentation changes locally with this command: ``` npm i -g mintlify ```
2. Run the following command at the root of your documentation (where
`docs.json` is): ``` mintlify dev ```
<Note>
If youre currently using the legacy ```mint.json``` configuration file, please update the Mintlify CLI:
```npm i -g mintlify@latest```
And run the new upgrade command in your docs repository:
```mintlify upgrade```
You should now be using the new ```docs.json``` configuration file. Feel free to delete the ```mint.json``` file from your repository.
</Note>
</Accordion>
</AccordionGroup>
### Deploy your changes
<AccordionGroup>
<Accordion icon="message-bot" title="Install our Github app">
Our Github app automatically deploys your changes to your docs site, so you
don't need to manage deployments yourself. You can find the link to install on
your [dashboard](https://dashboard.mintlify.com). Once the bot has been
successfully installed, there should be a check mark next to the commit hash
of the repo.
</Accordion>
<Accordion icon="rocket" title="Push your changes">
[Commit and push your changes to
Git](https://docs.github.com/en/get-started/using-git/pushing-commits-to-a-remote-repository#about-git-push)
for your changes to update in your docs site. If you push and don't see that
the Github app successfully deployed your changes, you can also manually
update your docs through our [dashboard](https://dashboard.mintlify.com).
</Accordion>
</AccordionGroup>
## Update your docs
Add content directly in your files with MDX syntax and React components. You can use any of our components, or even build your own.
<CardGroup>
<Card title="Add Content With MDX" icon="file" href="/essentials/markdown">
Add content to your docs with MDX syntax.
</Card>
<Card
title="Add Code Blocks"
icon="square-code"
href="/essentials/code"
>
Add code directly to your docs with syntax highlighting.
</Card>
<Card
title="Add Images"
icon="image"
href="/essentials/images"
>
Add images to your docs to make them more engaging.
</Card>
<Card
title="Add Custom Components"
icon="puzzle-piece"
href="/essentials/reusable-snippets"
>
Add templates to your docs to make them more reusable.
</Card>
</CardGroup>

View File

@@ -0,0 +1,4 @@
One of the core principles of software development is DRY (Don't Repeat
Yourself). This is a principle that apply to documentation as
well. If you find yourself repeating the same content in multiple places, you
should consider creating a custom snippet to keep your content in sync.

View File

@@ -4,7 +4,19 @@ NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
echo "Setting npm registry to ${NPM_REGISTRY}"
npm config set registry "$NPM_REGISTRY"
# 处理 HTTP_PROXY 和 HTTPS_PROXY 环境变量
if [ -n "$HTTP_PROXY" ]; then
echo "Setting HTTP proxy to ${HTTP_PROXY}"
npm config set proxy "$HTTP_PROXY"
export HTTP_PROXY="$HTTP_PROXY"
fi
if [ -n "$HTTPS_PROXY" ]; then
echo "Setting HTTPS proxy to ${HTTPS_PROXY}"
npm config set https-proxy "$HTTPS_PROXY"
export HTTPS_PROXY="$HTTPS_PROXY"
fi
echo "Using REQUEST_TIMEOUT: $REQUEST_TIMEOUT"
echo "Using UV_PYTHON_INSTALL_MIRROR: $UV_PYTHON_INSTALL_MIRROR"
exec "$@"

BIN
frontend/favicon.ico Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ToastProvider } from './contexts/ToastContext';
import { ThemeProvider } from './contexts/ThemeContext';
import MainLayout from './layouts/MainLayout';
import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
@@ -8,30 +10,41 @@ import DashboardPage from './pages/Dashboard';
import ServersPage from './pages/ServersPage';
import GroupsPage from './pages/GroupsPage';
import SettingsPage from './pages/SettingsPage';
import MarketPage from './pages/MarketPage';
import LogsPage from './pages/LogsPage';
import { getBasePath } from './utils/runtime';
function App() {
const basename = getBasePath();
return (
<AuthProvider>
<Router>
<Routes>
{/* 公共路由 */}
<Route path="/login" element={<LoginPage />} />
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
<Route element={<ProtectedRoute />}>
<Route element={<MainLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
{/* 未匹配的路由重定向到首页 */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<ToastProvider>
<Router basename={basename}>
<Routes>
{/* 公共路由 */}
<Route path="/login" element={<LoginPage />} />
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
<Route element={<ProtectedRoute />}>
<Route element={<MainLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/market" element={<MarketPage />} />
<Route path="/market/:serverName" element={<MarketPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
{/* 未匹配的路由重定向到首页 */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
</ToastProvider>
</AuthProvider>
</ThemeProvider>
);
}

View File

@@ -65,7 +65,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ServerForm from './ServerForm'
import { getApiUrl } from '../utils/runtime';
interface AddServerFormProps {
onAdd: () => void
@@ -20,7 +21,7 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
const response = await fetch(getApiUrl('/servers'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -68,13 +69,13 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
<div>
<button
onClick={toggleModal}
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4"
>
{t('server.addServer')}
</button>
{modalVisible && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<ServerForm
onSubmit={handleSubmit}
onCancel={toggleModal}

View File

@@ -82,7 +82,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { getApiUrl } from '../utils/runtime'
import ServerForm from './ServerForm'
interface EditServerFormProps {
@@ -17,7 +18,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/servers/${server.name}`, {
const response = await fetch(getApiUrl(`/servers/${server.name}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -61,7 +62,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<ServerForm
onSubmit={handleSubmit}
onCancel={onCancel}

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { Group, Server } from '@/types'
import { Edit, Trash, Copy, Check } from '@/components/icons/LucideIcons'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
interface GroupCardProps {
group: Group
@@ -18,6 +19,7 @@ const GroupCard = ({
onDelete
}: GroupCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copied, setCopied] = useState(false)
@@ -55,7 +57,7 @@ const GroupCard = ({
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
alert(t('common.copyFailed') || 'Copy failed')
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)

View File

@@ -0,0 +1,191 @@
import React, { useEffect, useRef, useState } from 'react';
import { LogEntry } from '../services/logService';
import { Button } from './ui/Button';
import { Badge } from './ui/Badge';
import { useTranslation } from 'react-i18next';
interface LogViewerProps {
logs: LogEntry[];
isLoading?: boolean;
error?: Error | null;
onClear?: () => void;
}
const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error = null, onClear }) => {
const { t } = useTranslation();
const logContainerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const [filter, setFilter] = useState<string>('');
const [typeFilter, setTypeFilter] = useState<Array<'info' | 'error' | 'warn' | 'debug'>>(['info', 'error', 'warn', 'debug']);
const [sourceFilter, setSourceFilter] = useState<Array<'main' | 'child'>>(['main', 'child']);
// Auto scroll to bottom when new logs come in if autoScroll is enabled
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
// Filter logs based on current filter settings
const filteredLogs = logs.filter(log => {
const matchesText = filter ? log.message.toLowerCase().includes(filter.toLowerCase()) : true;
const matchesType = typeFilter.includes(log.type);
const matchesSource = sourceFilter.includes(log.source as 'main' | 'child');
return matchesText && matchesType && matchesSource;
});
// Format timestamp to readable format
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
};
// Get badge color based on log type
const getLogTypeColor = (type: string) => {
switch (type) {
case 'error': return 'bg-red-400';
case 'warn': return 'bg-yellow-400';
case 'debug': return 'bg-purple-400';
default: return 'bg-blue-400';
}
};
// Get badge color based on log source
const getSourceColor = (source: string) => {
switch (source) {
case 'main': return 'bg-green-400';
case 'child': return 'bg-orange-400';
default: return 'bg-gray-400';
}
};
return (
<div className="flex flex-col h-full">
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
{/* Text search filter */}
<input
type="text"
placeholder={t('logs.search')}
className="px-2 py-1 text-sm border rounded"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/* Log type filters */}
<div className="flex gap-1 items-center">
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
<Badge
key={type}
variant={typeFilter.includes(type) ? 'default' : 'outline'}
className={`cursor-pointer ${typeFilter.includes(type) ? getLogTypeColor(type) : ''}`}
onClick={() => {
if (typeFilter.includes(type)) {
setTypeFilter(prev => prev.filter(t => t !== type));
} else {
setTypeFilter(prev => [...prev, type]);
}
}}
>
{type}
</Badge>
))}
</div>
{/* Log source filters */}
<div className="flex gap-1 items-center ml-2">
{(['main', 'child'] as const).map(source => (
<Badge
key={source}
variant={sourceFilter.includes(source) ? 'default' : 'outline'}
className={`cursor-pointer ${sourceFilter.includes(source) ? getSourceColor(source) : ''}`}
onClick={() => {
if (sourceFilter.includes(source)) {
setSourceFilter(prev => prev.filter(s => s !== source));
} else {
setSourceFilter(prev => [...prev, source]);
}
}}
>
{source === 'main' ? t('logs.mainProcess') : t('logs.childProcess')}
</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={autoScroll}
onChange={() => setAutoScroll(!autoScroll)}
className="form-checkbox h-4 w-4"
/>
{t('logs.autoScroll')}
</label>
<Button
variant="outline"
size="sm"
onClick={onClear}
disabled={isLoading || logs.length === 0}
>
{t('logs.clearLogs')}
</Button>
</div>
</div>
<div
ref={logContainerRef}
className="flex-grow p-2 overflow-auto bg-card rounded-b-md font-mono text-sm"
style={{ maxHeight: 'calc(100vh - 300px)' }}
>
{isLoading ? (
<div className="flex justify-center items-center h-full">
<span>{t('logs.loading')}</span>
</div>
) : error ? (
<div className="text-red-500 p-2">
{error.message}
</div>
) : filteredLogs.length === 0 ? (
<div className="text-center text-muted-foreground p-8">
{filter || typeFilter.length < 4 || sourceFilter.length < 2
? t('logs.noMatch')
: t('logs.noLogs')}
</div>
) : (
filteredLogs.map((log, index) => (
<div
key={`${log.timestamp}-${index}`}
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
log.type === 'warn' ? 'text-yellow-500' : ''
}`}
>
<span className="text-gray-400">[{formatTimestamp(log.timestamp)}]</span>
<Badge className={`ml-2 mr-1 ${getLogTypeColor(log.type)}`}>
{log.type}
</Badge>
<Badge
variant="default"
className={`mr-2 ${getSourceColor(log.source)}`}
>
{log.source === 'main' ? t('logs.main') : t('logs.child')}
{log.processId ? ` (${log.processId})` : ''}
</Badge>
<span className="whitespace-pre-wrap">{log.message}</span>
</div>
))
)}
</div>
</div>
);
};
export default LogViewer;

View File

@@ -0,0 +1,153 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer } from '@/types';
interface MarketServerCardProps {
server: MarketServer;
onClick: (server: MarketServer) => void;
}
const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick }) => {
const { t } = useTranslation();
// Intelligently calculate how many tags to display to ensure they fit in a single line
const getTagsToDisplay = () => {
if (!server.tags || server.tags.length === 0) {
return { tagsToShow: [], hasMore: false, moreCount: 0 };
}
// Estimate available width in the card (in characters)
const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line
// Calculate the character space needed for tags and plus sign (including # and spacing)
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
// Loop to determine the maximum number of tags that can be displayed
let totalWidth = 0;
let i = 0;
// First, sort tags by length to prioritize displaying shorter tags
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
// Calculate how many tags can fit
for (i = 0; i < sortedTags.length; i++) {
const tagWidth = calculateTagWidth(sortedTags[i]);
// If this tag would make the total width exceed available width, stop adding
if (totalWidth + tagWidth > estimatedAvailableWidth) {
break;
}
totalWidth += tagWidth;
// If this is the last tag but there's still space, no need to show "more"
if (i === sortedTags.length - 1) {
return {
tagsToShow: sortedTags,
hasMore: false,
moreCount: 0
};
}
}
// If there's not enough space to display any tags, show at least one
if (i === 0 && sortedTags.length > 0) {
i = 1;
}
// Calculate space needed for the "more" tag
const moreCount = sortedTags.length - i;
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
// If there's enough remaining space to display the "more" tag
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
return {
tagsToShow: sortedTags.slice(0, i),
hasMore: true,
moreCount
};
}
// If there's not enough space for even the "more" tag, reduce one tag to make room
return {
tagsToShow: sortedTags.slice(0, Math.max(1, i - 1)),
hasMore: true,
moreCount: moreCount + 1
};
};
const { tagsToShow, hasMore, moreCount } = getTagsToDisplay();
return (
<div
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col h-full"
onClick={() => onClick(server)}
>
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0">
{t('market.official')}
</span>
)}
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
{/* Categories */}
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
{server.categories?.length > 0 ? (
server.categories.map((category, index) => (
<span
key={index}
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
>
{category}
</span>
))
) : (
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
{/* Tags */}
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
{server.tags?.length > 0 ? (
<div className="flex gap-1 items-center whitespace-nowrap">
{tagsToShow.map((tag, index) => (
<span
key={index}
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
>
#{tag}
</span>
))}
{hasMore && (
<span className="bg-gray-100 text-gray-600 text-xs px-1.5 py-1 rounded flex-shrink-0">
+{moreCount} {t('market.moreTags')}
</span>
)}
</div>
) : (
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500 border-t border-gray-100">
<div className="overflow-hidden">
<span className="whitespace-nowrap">{t('market.by')} </span>
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">
{server.author?.name || t('market.unknown')}
</span>
</div>
<div className="flex items-center flex-shrink-0">
<svg className="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
<span>{server.tools?.length || 0} {t('market.tools')}</span>
</div>
</div>
</div>
);
};
export default MarketServerCard;

View File

@@ -0,0 +1,297 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, MarketServerInstallation } from '@/types';
import ServerForm from './ServerForm';
interface MarketServerDetailProps {
server: MarketServer;
onBack: () => void;
onInstall: (server: MarketServer) => void;
installing?: boolean;
isInstalled?: boolean;
}
const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
server,
onBack,
onInstall,
installing = false,
isInstalled = false
}) => {
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
const [error, setError] = useState<string | null>(null);
// Helper function to determine button state
const getButtonProps = () => {
if (isInstalled) {
return {
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
disabled: true,
text: t('market.installed')
};
} else if (installing) {
return {
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
disabled: true,
text: t('market.installing')
};
} else {
return {
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
disabled: false,
text: t('market.install')
};
}
};
const toggleModal = () => {
setModalVisible(!modalVisible);
setError(null); // Clear any previous errors when toggling modal
};
const handleInstall = () => {
if (!isInstalled) {
toggleModal();
}
};
// Get the preferred installation configuration based on priority:
// npm > uvx > default
const getPreferredInstallation = (): MarketServerInstallation | undefined => {
if (!server.installations) {
return undefined;
}
if (server.installations.npm) {
return server.installations.npm;
} else if (server.installations.uvx) {
return server.installations.uvx;
} else if (server.installations.default) {
return server.installations.default;
}
// If none of the preferred types are available, get the first available installation type
const installTypes = Object.keys(server.installations);
if (installTypes.length > 0) {
return server.installations[installTypes[0]];
}
return undefined;
};
const handleSubmit = async (payload: any) => {
try {
setError(null);
// Pass the server object to the parent component for installation
onInstall(server);
setModalVisible(false);
} catch (err) {
console.error('Error installing server:', err);
setError(t('errors.serverInstall'));
}
};
const buttonProps = getButtonProps();
const preferredInstallation = getPreferredInstallation();
return (
<div className="bg-white rounded-lg shadow-md p-6">
<div className="mb-4">
<button
onClick={onBack}
className="text-gray-600 hover:text-gray-900 flex items-center"
>
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
{t('market.backToList')}
</button>
</div>
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center flex-wrap">
{server.display_name}
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
<span className="text-sm font-normal text-gray-600 ml-4">
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline ml-1"
>
{t('market.repository')}
</a>
</span>
</h2>
</div>
<div className="flex items-center">
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-4 py-2 rounded mr-2 flex items-center">
{t('market.official')}
</span>
)}
<button
onClick={handleInstall}
disabled={buttonProps.disabled}
className={buttonProps.className}
>
{buttonProps.text}
</button>
</div>
</div>
<p className="text-gray-700 mb-6">{server.description}</p>
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
<div className="flex flex-wrap gap-2">
{server.categories?.map((category, index) => (
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
{category}
</span>
))}
{server.tags && server.tags.map((tag, index) => (
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
#{tag}
</span>
))}
</div>
</div>
{server.arguments && Object.keys(server.arguments).length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t('market.arguments')}</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.argumentName')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.description')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.required')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.example')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{Object.entries(server.arguments).map(([name, arg], index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{name}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{arg.description}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{arg.required ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<code className="bg-gray-100 px-2 py-1 rounded">{arg.example}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t('market.tools')}</h3>
<div className="space-y-4">
{server.tools?.map((tool, index) => (
<div key={index} className="border border-gray-200 rounded p-4">
<h4 className="font-medium mb-2">
{tool.name}
<button
type="button"
onClick={() => {
// Toggle visibility of schema (simplified for this implementation)
const element = document.getElementById(`schema-${index}`);
if (element) {
element.classList.toggle('hidden');
}
}}
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
>
{t('market.viewSchema')}
</button>
</h4>
<p className="text-gray-600 mb-2">{tool.description}</p>
<div className="mt-2">
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
</div>
))}
</div>
</div>
{server.examples && server.examples.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t('market.examples')}</h3>
<div className="space-y-4">
{server.examples.map((example, index) => (
<div key={index} className="border border-gray-200 rounded p-4">
<h4 className="font-medium mb-2">{example.title}</h4>
<p className="text-gray-600 mb-2">{example.description}</p>
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
{example.prompt}
</pre>
</div>
))}
</div>
</div>
)}
<div className="mt-6 flex justify-end">
<button
onClick={handleInstall}
disabled={buttonProps.disabled}
className={buttonProps.className}
>
{buttonProps.text}
</button>
</div>
{modalVisible && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<ServerForm
onSubmit={handleSubmit}
onCancel={toggleModal}
modalTitle={t('market.installServer', { name: server.display_name })}
formError={error}
initialData={{
name: server.name,
status: 'disconnected',
config: preferredInstallation
? {
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
: undefined
}}
/>
</div>
)}
</div>
);
};
export default MarketServerDetail;

View File

@@ -1,10 +1,11 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
import Badge from '@/components/ui/Badge'
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
import { StatusBadge } from '@/components/ui/Badge'
import ToolCard from '@/components/ui/ToolCard'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
interface ServerCardProps {
server: Server
@@ -15,9 +16,26 @@ interface ServerCardProps {
const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const [isExpanded, setIsExpanded] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isToggling, setIsToggling] = useState(false)
const [showErrorPopover, setShowErrorPopover] = useState(false)
const [copied, setCopied] = useState(false)
const errorPopoverRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (errorPopoverRef.current && !errorPopoverRef.current.contains(event.target as Node)) {
setShowErrorPopover(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
@@ -41,6 +59,44 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
}
}
const handleErrorIconClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowErrorPopover(!showErrorPopover)
}
const copyToClipboard = (e: React.MouseEvent) => {
e.stopPropagation()
if (!server.error) return
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(server.error).then(() => {
setCopied(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopied(false), 2000)
})
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = server.error
// Avoid scrolling to bottom
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
setCopied(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopied(false), 2000)
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)
}
}
const handleConfirmDelete = () => {
onRemove(server.name)
setShowDeleteDialog(false)
@@ -55,7 +111,60 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
>
<div className="flex items-center space-x-3">
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
<Badge status={server.status} />
<StatusBadge status={server.status} />
{server.error && (
<div className="relative">
<div
className="cursor-pointer"
onClick={handleErrorIconClick}
aria-label={t('server.viewErrorDetails')}
>
<AlertCircle className="text-red-500 hover:text-red-600" size={18} />
</div>
{showErrorPopover && (
<div
ref={errorPopoverRef}
className="absolute z-10 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-0 w-120"
style={{
left: '-231px',
top: '24px',
maxHeight: '300px',
overflowY: 'auto',
width: '480px',
transform: 'translateX(50%)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
</div>
<button
onClick={(e) => {
e.stopPropagation()
setShowErrorPopover(false)
}}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="p-4 pt-2">
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre>
</div>
</div>
)}
</div>
)}
</div>
<div className="flex space-x-2">
<button

View File

@@ -12,9 +12,21 @@ interface ServerFormProps {
const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formError = null }: ServerFormProps) => {
const { t } = useTranslation()
const [serverType, setServerType] = useState<'sse' | 'stdio'>(
initialData && initialData.config && initialData.config.url ? 'sse' : 'stdio',
)
// Determine the initial server type from the initialData
const getInitialServerType = () => {
if (!initialData || !initialData.config) return 'stdio';
if (initialData.config.type) {
return initialData.config.type; // Use explicit type if available
} else if (initialData.config.url) {
return 'sse'; // Fallback to SSE if URL exists
} else {
return 'stdio'; // Default to stdio
}
};
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http'>(getInitialServerType());
const [formData, setFormData] = useState<ServerFormData>({
name: (initialData && initialData.name) || '',
@@ -27,6 +39,8 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
: String(initialData.config.args)
: '',
args: (initialData && initialData.config && initialData.config.args) || [],
type: getInitialServerType(), // Initialize the type field
env: []
})
const [envVars, setEnvVars] = useState<EnvVar[]>(
@@ -49,6 +63,11 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
setFormData({ ...formData, arguments: value, args })
}
const updateServerType = (type: 'stdio' | 'sse' | 'streamable-http') => {
setServerType(type);
setFormData(prev => ({ ...prev, type }));
}
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
@@ -80,14 +99,17 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
const payload = {
name: formData.name,
config:
serverType === 'sse'
config: {
type: serverType, // Always include the type
...(serverType === 'sse' || serverType === 'streamable-http'
? { url: formData.url }
: {
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
},
command: formData.command,
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
}
)
}
}
onSubmit(payload)
@@ -139,10 +161,10 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
name="serverType"
value="command"
checked={serverType === 'stdio'}
onChange={() => setServerType('stdio')}
onChange={() => updateServerType('stdio')}
className="mr-1"
/>
<label htmlFor="command">stdio</label>
<label htmlFor="command">STDIO</label>
</div>
<div>
<input
@@ -151,15 +173,27 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
name="serverType"
value="url"
checked={serverType === 'sse'}
onChange={() => setServerType('sse')}
onChange={() => updateServerType('sse')}
className="mr-1"
/>
<label htmlFor="url">sse</label>
<label htmlFor="url">SSE</label>
</div>
<div>
<input
type="radio"
id="streamable-http"
name="serverType"
value="streamable-http"
checked={serverType === 'streamable-http'}
onChange={() => updateServerType('streamable-http')}
className="mr-1"
/>
<label htmlFor="streamable-http">Streamable HTTP</label>
</div>
</div>
</div>
{serverType === 'sse' ? (
{serverType === 'sse' || serverType === 'streamable-http' ? (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="url">
{t('server.url')}
@@ -171,8 +205,8 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
value={formData.url}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
placeholder="e.g.: http://localhost:3000/sse"
required={serverType === 'sse'}
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
required={serverType === 'sse' || serverType === 'streamable-http'}
/>
</div>
) : (

View File

@@ -0,0 +1,20 @@
import React from 'react';
export const DiscordIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
return (
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
{...props}
>
<title>Discord</title>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
);
};
export default DiscordIcon;

View File

@@ -0,0 +1,21 @@
import React from 'react';
export const GitHubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
return (
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
{...props}
>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
};
export default GitHubIcon;

View File

@@ -1,6 +1,6 @@
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check } from 'lucide-react'
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info } from 'lucide-react'
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check }
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info }
const LucideIcons = {
ChevronDown,
@@ -8,7 +8,11 @@ const LucideIcons = {
Edit,
Trash,
Copy,
Check
Check,
User,
Settings,
LogOut,
Info
}
export default LucideIcons

View File

@@ -0,0 +1,20 @@
import React from 'react';
export const SponsorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
return (
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
{...props}
>
<title>Sponsor</title>
<path d="M17.625 1.499c-2.32 0-4.354 1.203-5.625 3.03-1.271-1.827-3.305-3.03-5.625-3.03C3.129 1.499 0 4.253 0 8.249c0 4.275 3.068 7.847 5.828 10.227a33.14 33.14 0 0 0 5.616 3.876l.028.017.008.003-.001.003c.163.085.342.126.521.125.179.001.358-.041.521-.125l-.001-.003.008-.003.028-.017a33.14 33.14 0 0 0 5.616-3.876C20.932 16.096 24 12.524 24 8.249c0-3.996-3.129-6.75-6.375-6.75zm-.919 15.275a30.766 30.766 0 0 1-4.703 3.316l-.004-.002-.004.002a30.955 30.955 0 0 1-4.703-3.316c-2.677-2.307-5.047-5.298-5.047-8.523 0-2.754 2.121-4.5 4.125-4.5 2.06 0 3.914 1.479 4.544 3.684.143.495.596.797 1.086.796.49.001.943-.302 1.085-.796.63-2.205 2.484-3.684 4.544-3.684 2.004 0 4.125 1.746 4.125 4.5 0 3.225-2.37 6.216-5.048 8.523z" />
</svg>
);
};
export default SponsorIcon;

View File

@@ -0,0 +1,20 @@
import React from 'react';
export const WeChatIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
return (
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
{...props}
>
<title>WeChat</title>
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z" />
</svg>
);
};
export default WeChatIcon;

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 822 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub Sponsors</title><path d="M17.625 1.499c-2.32 0-4.354 1.203-5.625 3.03-1.271-1.827-3.305-3.03-5.625-3.03C3.129 1.499 0 4.253 0 8.249c0 4.275 3.068 7.847 5.828 10.227a33.14 33.14 0 0 0 5.616 3.876l.028.017.008.003-.001.003c.163.085.342.126.521.125.179.001.358-.041.521-.125l-.001-.003.008-.003.028-.017a33.14 33.14 0 0 0 5.616-3.876C20.932 16.096 24 12.524 24 8.249c0-3.996-3.129-6.75-6.375-6.75zm-.919 15.275a30.766 30.766 0 0 1-4.703 3.316l-.004-.002-.004.002a30.955 30.955 0 0 1-4.703-3.316c-2.677-2.307-5.047-5.298-5.047-8.523 0-2.754 2.121-4.5 4.125-4.5 2.06 0 3.914 1.479 4.544 3.684.143.495.596.797 1.086.796.49.001.943-.302 1.085-.796.63-2.205 2.484-3.684 4.544-3.684 2.004 0 4.125 1.746 4.125 4.5 0 3.225-2.37 6.216-5.048 8.523z"/></svg>

After

Width:  |  Height:  |  Size: 829 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WeChat</title><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -6,8 +6,8 @@ interface ContentProps {
const Content: React.FC<ContentProps> = ({ children }) => {
return (
<main className="flex-1 p-6 overflow-auto">
<div className="max-w-5xl mx-auto">
<main className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
<div className="container mx-auto">
{children}
</div>
</main>

View File

@@ -1,59 +1,90 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import GitHubIcon from '@/components/icons/GitHubIcon';
import SponsorIcon from '@/components/icons/SponsorIcon';
import WeChatIcon from '@/components/icons/WeChatIcon';
import DiscordIcon from '@/components/icons/DiscordIcon';
import SponsorDialog from '@/components/ui/SponsorDialog';
import WeChatDialog from '@/components/ui/WeChatDialog';
interface HeaderProps {
onToggleSidebar: () => void;
}
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { auth, logout } = useAuth();
const handleLogout = () => {
logout();
navigate('/login');
};
const { t, i18n } = useTranslation();
const { auth } = useAuth();
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
return (
<header className="bg-white shadow-sm z-10">
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
<div className="flex justify-between items-center px-4 py-3">
<div className="flex items-center">
{/* 侧边栏切换按钮 */}
<button
<button
onClick={onToggleSidebar}
className="p-2 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-100 focus:outline-none"
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
aria-label={t('app.toggleSidebar')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* 应用标题 */}
<h1 className="ml-4 text-xl font-bold text-gray-900">{t('app.title')}</h1>
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
</div>
{/* 用户信息和操作 */}
{/* Theme Switch and Version */}
<div className="flex items-center space-x-4">
{auth.user && (
<span className="text-sm text-gray-700">
{t('app.welcomeUser', { username: auth.user.username })}
</span>
)}
<div className="flex space-x-2">
<span className="text-sm text-gray-500 dark:text-gray-400">
{import.meta.env.PACKAGE_VERSION === 'dev'
? import.meta.env.PACKAGE_VERSION
: `v${import.meta.env.PACKAGE_VERSION}`}
</span>
<a
href="https://github.com/samanhappy/mcphub"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
aria-label="GitHub Repository"
>
<GitHubIcon className="h-5 w-5" />
</a>
{i18n.language === 'zh' ? (
<button
onClick={handleLogout}
className="px-3 py-1.5 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
onClick={() => setWechatDialogOpen(true)}
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
aria-label={t('wechat.label')}
>
{t('app.logout')}
<WeChatIcon className="h-5 w-5" />
</button>
</div>
) : (
<a
href="https://discord.gg/qMKNsn5Q"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
aria-label={t('discord.label')}
>
<DiscordIcon className="h-5 w-5" />
</a>
)}
<button
onClick={() => setSponsorDialogOpen(true)}
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
aria-label={t('sponsor.label')}
>
<SponsorIcon className="h-5 w-5" />
</button>
<ThemeSwitch />
</div>
</div>
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
</header>
);
};

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, useLocation } from 'react-router-dom';
import UserProfileMenu from '@/components/ui/UserProfileMenu';
interface SidebarProps {
collapsed: boolean;
@@ -16,7 +17,10 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
const { t } = useTranslation();
const location = useLocation();
// 菜单项配置
// Application version from package.json (accessed via Vite environment variables)
const appVersion = import.meta.env.PACKAGE_VERSION as string;
// Menu item configuration
const menuItems: MenuItem[] = [
{
path: '/',
@@ -47,11 +51,20 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
),
},
{
path: '/settings',
label: t('nav.settings'),
path: '/market',
label: t('nav.market'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" />
</svg>
),
},
{
path: '/logs',
label: t('nav.logs'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
</svg>
),
},
@@ -59,29 +72,37 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
return (
<aside
className={`bg-white shadow-sm transition-all duration-300 ease-in-out ${
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${
collapsed ? 'w-16' : 'w-64'
}`}
>
<nav className="p-3 space-y-1">
{menuItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md transition-colors ${
isActive
? 'bg-blue-100 text-blue-800'
: 'text-gray-700 hover:bg-gray-100'
}`
}
end={item.path === '/'}
>
<span className="flex-shrink-0">{item.icon}</span>
{!collapsed && <span className="ml-3">{item.label}</span>}
</NavLink>
))}
</nav>
{/* Scrollable navigation area */}
<div className="overflow-y-auto flex-grow">
<nav className="p-3 space-y-1">
{menuItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md transition-colors ${
isActive
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`
}
end={item.path === '/'}
>
<span className="flex-shrink-0">{item.icon}</span>
{!collapsed && <span className="ml-3">{item.label}</span>}
</NavLink>
))}
</nav>
</div>
{/* User profile menu fixed at the bottom */}
<div className="p-3 bg-white dark:bg-gray-800">
<UserProfileMenu collapsed={collapsed} version={appVersion} />
</div>
</aside>
);
};

View File

@@ -0,0 +1,112 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, RefreshCw } from 'lucide-react';
import { checkLatestVersion, compareVersions } from '@/utils/version';
interface AboutDialogProps {
isOpen: boolean;
onClose: () => void;
version: string;
}
const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) => {
const { t } = useTranslation();
const [hasNewVersion, setHasNewVersion] = useState(false);
const [latestVersion, setLatestVersion] = useState("");
const [isChecking, setIsChecking] = useState(false);
const checkForUpdates = async () => {
setIsChecking(true);
try {
const latest = await checkLatestVersion();
if (latest) {
setLatestVersion(latest);
setHasNewVersion(compareVersions(version, latest) > 0);
}
} catch (error) {
console.error('Failed to check for updates:', error);
} finally {
setIsChecking(false);
}
};
useEffect(() => {
if (isOpen) {
checkForUpdates();
}
}, [isOpen, version]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 bg-opacity-30 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full">
<div className="p-6 relative">
{/* Close button (X) in the top-right corner */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
aria-label={t('common.close')}
>
<X className="h-5 w-5" />
</button>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('about.title')}
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-gray-700 dark:text-gray-300">
{t('about.currentVersion')}:
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{version}
</span>
</div>
{hasNewVersion && latestVersion && (
<div className="bg-blue-50 dark:bg-blue-900 p-3 rounded">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-600 dark:text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-3 flex-1 text-sm text-blue-700 dark:text-blue-300">
<p>{t('about.newVersionAvailable', { version: latestVersion })}</p>
<p className="mt-1">
<a
href="https://github.com/samanhappy/mcphub"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{t('about.viewOnGitHub')}
</a>
</p>
</div>
</div>
</div>
)}
<button
onClick={checkForUpdates}
disabled={isChecking}
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium
${isChecking
? 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600'
} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isChecking ? 'animate-spin' : ''}`} />
{isChecking ? t('about.checking') : t('about.checkForUpdates')}
</button>
</div>
</div>
</div>
</div>
);
};
export default AboutDialog;

View File

@@ -1,25 +1,61 @@
import { useTranslation } from 'react-i18next'
import { ServerStatus } from '@/types'
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ServerStatus } from '@/types';
import { cn } from '../../utils/cn';
interface BadgeProps {
status: ServerStatus
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
type BadgeProps = {
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
onClick?: () => void;
};
const badgeVariants = {
default: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
outline: 'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
destructive: 'bg-red-500 text-white hover:bg-red-600',
};
export function Badge({
children,
variant = 'default',
className,
onClick
}: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
badgeVariants[variant],
onClick ? 'cursor-pointer' : '',
className
)}
onClick={onClick}
>
{children}
</span>
);
}
const Badge = ({ status }: BadgeProps) => {
const { t } = useTranslation()
// For backward compatibility with existing code
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
const { t } = useTranslation();
const colors = {
connecting: 'bg-yellow-100 text-yellow-800',
connected: 'bg-green-100 text-green-800',
disconnected: 'bg-red-100 text-red-800',
}
};
// Map status to translation keys
const statusTranslations = {
connected: 'status.online',
disconnected: 'status.offline',
connecting: 'status.connecting'
}
};
return (
<span
@@ -27,7 +63,5 @@ const Badge = ({ status }: BadgeProps) => {
>
{t(statusTranslations[status] || status)}
</span>
)
}
export default Badge
);
};

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { cn } from '../../utils/cn';
type ButtonVariant = 'default' | 'outline' | 'ghost' | 'link' | 'destructive';
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
asChild?: boolean;
children: React.ReactNode;
}
const variantStyles: Record<ButtonVariant, string> = {
default: 'bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-500',
outline: 'border border-gray-300 dark:border-gray-700 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
link: 'bg-transparent underline-offset-4 hover:underline text-blue-500 hover:text-blue-600',
destructive: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500',
};
const sizeStyles: Record<ButtonSize, string> = {
default: 'h-10 py-2 px-4',
sm: 'h-8 px-3 text-sm',
lg: 'h-12 px-6',
icon: 'h-10 w-10 p-0',
};
export function Button({
variant = 'default',
size = 'default',
className,
disabled,
children,
...props
}: ButtonProps) {
return (
<button
className={cn(
'rounded-md inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none',
variantStyles[variant],
sizeStyles[size],
className
)}
disabled={disabled}
{...props}
>
{children}
</button>
);
}

View File

@@ -14,14 +14,14 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-30 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50 bg-opacity-30 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
</h3>
<p className="text-gray-500 mb-6">
{isGroup
{isGroup
? t('groups.deleteWarning', { name: serverName })
: t('server.deleteWarning', { name: serverName })}
</p>

View File

@@ -0,0 +1,128 @@
import React from 'react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
}) => {
// Generate page buttons
const getPageButtons = () => {
const buttons = [];
const maxDisplayedPages = 5; // Maximum number of page buttons to display
// Always display first page
buttons.push(
<button
key="first"
onClick={() => onPageChange(1)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === 1
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
>
1
</button>
);
// Start range
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
// If we're showing ellipsis after first page
if (startPage > 2) {
buttons.push(
<span key="ellipsis1" className="px-3 py-1">
...
</span>
);
}
// Middle pages
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
buttons.push(
<button
key={i}
onClick={() => onPageChange(i)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === i
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
>
{i}
</button>
);
}
// If we're showing ellipsis before last page
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
buttons.push(
<span key="ellipsis2" className="px-3 py-1">
...
</span>
);
}
// Always display last page if there's more than one page
if (totalPages > 1) {
buttons.push(
<button
key="last"
onClick={() => onPageChange(totalPages)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === totalPages
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
>
{totalPages}
</button>
);
}
return buttons;
};
// If there's only one page, don't render pagination
if (totalPages <= 1) {
return null;
}
return (
<div className="flex justify-center items-center my-6">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={`px-3 py-1 rounded mr-2 ${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
>
&laquo; Prev
</button>
<div className="flex">{getPageButtons()}</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={`px-3 py-1 rounded ml-2 ${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
>
Next &raquo;
</button>
</div>
);
};
export default Pagination;

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
interface SponsorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const SponsorDialog: React.FC<SponsorDialogProps> = ({ open, onOpenChange }) => {
const { i18n, t } = useTranslation();
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full">
<div className="p-6 relative">
{/* Close button (X) in the top-right corner */}
<button
onClick={() => onOpenChange(false)}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
aria-label={t('common.close')}
>
<X className="h-5 w-5" />
</button>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('sponsor.title')}
</h3>
<div className="flex flex-col items-center justify-center py-4">
{i18n.language === 'zh' ? (
<img
src="./assets/reward.png"
alt={t('sponsor.rewardAlt')}
className="max-w-full h-auto"
style={{ maxHeight: '400px' }}
/>
) : (
<div className="text-center">
<p className="mb-4 text-gray-700 dark:text-gray-300">{t('sponsor.supportMessage')}</p>
<a
href="https://ko-fi.com/samanhappy"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center bg-[#13C3FF] text-white px-4 py-2 rounded-md hover:bg-[#00A5E5] transition-colors"
>
{t('sponsor.supportButton')}
</a>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default SponsorDialog;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/contexts/ThemeContext';
import { Sun, Moon, Monitor } from 'lucide-react';
const ThemeSwitch: React.FC = () => {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
return (
<div className="flex items-center space-x-2">
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setTheme('light')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'
? 'bg-white text-yellow-600 shadow'
: 'text-black dark:text-gray-300 hover:text-yellow-600 dark:hover:text-yellow-500'
}`}
title={t('theme.light')}
aria-label={t('theme.light')}
>
<Sun size={18} />
</button>
<button
onClick={() => setTheme('dark')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'dark'
? 'bg-gray-800 text-blue-400 shadow'
: 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'
}`}
title={t('theme.dark')}
aria-label={t('theme.dark')}
>
<Moon size={18} />
</button>
{/* <button
onClick={() => setTheme('system')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'system'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow'
: 'text-black dark:text-gray-300 hover:text-green-600 dark:hover:text-green-400'
}`}
title={t('theme.system')}
aria-label={t('theme.system')}
>
<Monitor size={18} />
</button> */}
</div>
</div>
);
};
export default ThemeSwitch;

View File

@@ -0,0 +1,96 @@
import React, { useEffect, useState } from 'react';
import { Check, X } from 'lucide-react';
import { cn } from '@/utils/cn';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface ToastProps {
message: string;
type?: ToastType;
duration?: number;
onClose: () => void;
visible: boolean;
}
const Toast: React.FC<ToastProps> = ({
message,
type = 'info',
duration = 3000,
onClose,
visible
}) => {
useEffect(() => {
if (visible) {
const timer = setTimeout(() => {
onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [visible, duration, onClose]);
const icons = {
success: <Check className="w-5 h-5 text-green-500" />,
error: <X className="w-5 h-5 text-red-500" />,
info: (
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-5 h-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)
};
const bgColors = {
success: 'bg-green-50 border-green-200',
error: 'bg-red-50 border-red-200',
info: 'bg-blue-50 border-blue-200',
warning: 'bg-yellow-50 border-yellow-200'
};
const textColors = {
success: 'text-green-800',
error: 'text-red-800',
info: 'text-blue-800',
warning: 'text-yellow-800'
};
return (
<div className={cn(
"fixed top-4 right-4 z-50 max-w-sm p-4 rounded-md shadow-lg border",
bgColors[type],
"transform transition-all duration-300 ease-in-out",
visible ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
)}>
<div className="flex items-start">
<div className="flex-shrink-0">
{icons[type]}
</div>
<div className="ml-3">
<p className={cn("text-sm font-medium", textColors[type])}>
{message}
</p>
</div>
<div className="ml-auto pl-3">
<div className="-mx-1.5 -my-1.5">
<button
onClick={onClose}
className={cn(
"inline-flex rounded-md p-1.5",
`hover:bg-${type}-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-${type}-500`
)}
>
<span className="sr-only">Dismiss</span>
<X className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
);
};
export default Toast;

View File

@@ -97,4 +97,38 @@ export const ToggleGroup: React.FC<ToggleGroupProps> = ({
)}
</div>
);
};
interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
}
export const Switch: React.FC<SwitchProps> = ({
checked,
onCheckedChange,
disabled = false
}) => {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500",
checked ? "bg-blue-600" : "bg-gray-200",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
)}
onClick={() => !disabled && onCheckedChange(!checked)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
checked ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
);
};

View File

@@ -0,0 +1,131 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { User, Settings, LogOut, Info } from 'lucide-react';
import AboutDialog from './AboutDialog';
import { checkLatestVersion, compareVersions } from '@/utils/version';
interface UserProfileMenuProps {
collapsed: boolean;
version: string;
}
const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { auth, logout } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const [showNewVersionInfo, setShowNewVersionInfo] = useState(false);
const [showAboutDialog, setShowAboutDialog] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Check for new version on login and component mount
useEffect(() => {
const checkForNewVersion = async () => {
try {
const latestVersion = await checkLatestVersion();
if (latestVersion) {
setShowNewVersionInfo(compareVersions(version, latestVersion) > 0);
}
} catch (error) {
console.error('Error checking for new version:', error);
}
};
checkForNewVersion();
}, [version]);
// Close the menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleSettingsClick = () => {
navigate('/settings');
setIsOpen(false);
};
const handleLogoutClick = () => {
logout();
navigate('/login');
};
const handleAboutClick = () => {
setShowAboutDialog(true);
setIsOpen(false);
};
return (
<div ref={menuRef} className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex ${collapsed ? 'justify-center' : 'items-center'} w-full p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors rounded-md ${isOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
}`}
>
<div className="flex-shrink-0 relative">
<div className="w-7 h-7 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
</div>
{showNewVersionInfo && (
<span className="absolute -top-1 -right-1 block w-2 h-2 bg-red-500 rounded-full"></span>
)}
</div>
{!collapsed && (
<div className="ml-3 flex flex-col items-start">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{auth.user?.username || t('auth.user')}
</span>
</div>
)}
</button>
{isOpen && (
<div className="absolute top-0 transform -translate-y-full left-0 w-48 bg-white dark:bg-gray-800 shadow-lg rounded-md py-1 z-50">
<button
onClick={handleSettingsClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Settings className="h-4 w-4 mr-2" />
{t('nav.settings')}
</button>
<button
onClick={handleAboutClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 relative"
>
<Info className="h-4 w-4 mr-2" />
{t('about.title')}
{showNewVersionInfo && (
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
)}
</button>
<button
onClick={handleLogoutClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<LogOut className="h-4 w-4 mr-2" />
{t('app.logout')}
</button>
</div>
)}
{/* About dialog */}
<AboutDialog
isOpen={showAboutDialog}
onClose={() => setShowAboutDialog(false)}
version={version}
/>
</div>
);
};
export default UserProfileMenu;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
interface WeChatDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const WeChatDialog: React.FC<WeChatDialogProps> = ({ open, onOpenChange }) => {
const { t } = useTranslation();
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full">
<div className="p-6 relative">
{/* Close button (X) in the top-right corner */}
<button
onClick={() => onOpenChange(false)}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
aria-label={t('common.close')}
>
<X className="h-5 w-5" />
</button>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('wechat.title')}
</h3>
<div className="flex flex-col items-center justify-center py-4">
<img
src="./assets/wexin.png"
alt={t('wechat.qrCodeAlt')}
className="max-w-full h-auto"
style={{ maxHeight: '400px' }}
/>
<p className="mt-4 text-center text-gray-700 dark:text-gray-300">
{t('wechat.scanMessage')}
</p>
</div>
</div>
</div>
</div>
);
};
export default WeChatDialog;

View File

@@ -0,0 +1,76 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark'; // The actual theme used after resolving system preference
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
// Get theme from localStorage or default to 'system'
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme') as Theme;
return savedTheme || 'system';
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
// Function to set theme and save to localStorage
const handleSetTheme = (newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
// Effect to handle system theme changes and apply theme to document
useEffect(() => {
const updateTheme = () => {
const root = window.document.documentElement;
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
// Determine which theme to use
const themeToApply = theme === 'system' ? systemTheme : theme;
setResolvedTheme(themeToApply as 'light' | 'dark');
// Apply or remove dark class based on theme
if (themeToApply === 'dark') {
console.log('Applying dark mode to HTML root element'); // 添加日志
root.classList.add('dark');
document.body.style.backgroundColor = '#111827'; // Force a dark background to ensure visible effect
} else {
console.log('Removing dark mode from HTML root element'); // 添加日志
root.classList.remove('dark');
document.body.style.backgroundColor = ''; // Reset background color
}
};
// Set up listeners for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', updateTheme);
// Initial theme setup
updateTheme();
// Cleanup
return () => {
mediaQuery.removeEventListener('change', updateTheme);
};
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme: handleSetTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,60 @@
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
import Toast, { ToastType } from '@/components/ui/Toast';
interface ToastContextProps {
showToast: (message: string, type?: ToastType, duration?: number) => void;
}
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
interface ToastProviderProps {
children: ReactNode;
}
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [toast, setToast] = useState<{
message: string;
type: ToastType;
visible: boolean;
duration: number;
}>({
message: '',
type: 'info',
visible: false,
duration: 3000,
});
const showToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
setToast({
message,
type,
visible: true,
duration,
});
}, []);
const hideToast = useCallback(() => {
setToast((prev) => ({ ...prev, visible: false }));
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<Toast
message={toast.message}
type={toast.type}
duration={toast.duration}
onClose={hideToast}
visible={toast.visible}
/>
</ToastContext.Provider>
);
};

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/runtime';
export const useGroupData = () => {
const { t } = useTranslation();
@@ -13,25 +14,25 @@ export const useGroupData = () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/groups', {
const response = await fetch(getApiUrl('/groups'), {
headers: {
'x-auth-token': token || ''
}
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<Group[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setGroups(data.data);
} else {
console.error('Invalid group data format:', data);
setGroups([]);
}
setError(null);
} catch (err) {
console.error('Error fetching groups:', err);
@@ -44,29 +45,29 @@ export const useGroupData = () => {
// Trigger a refresh of the groups data
const triggerRefresh = useCallback(() => {
setRefreshKey(prev => prev + 1);
setRefreshKey((prev) => prev + 1);
}, []);
// Create a new group with server associations
const createGroup = async (name: string, description?: string, servers: string[] = []) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/groups', {
const response = await fetch(getApiUrl('/groups'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
'x-auth-token': token || '',
},
body: JSON.stringify({ name, description, servers }),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.createError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
@@ -76,25 +77,28 @@ export const useGroupData = () => {
};
// Update an existing group with server associations
const updateGroup = async (id: string, data: { name?: string; description?: string; servers?: string[] }) => {
const updateGroup = async (
id: string,
data: { name?: string; description?: string; servers?: string[] },
) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${id}`, {
const response = await fetch(getApiUrl(`/groups/${id}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
'x-auth-token': token || '',
},
body: JSON.stringify(data),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.updateError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
@@ -107,22 +111,22 @@ export const useGroupData = () => {
const updateGroupServers = async (groupId: string, servers: string[]) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers/batch`, {
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/batch`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
'x-auth-token': token || '',
},
body: JSON.stringify({ servers }),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.updateError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
@@ -135,20 +139,20 @@ export const useGroupData = () => {
const deleteGroup = async (id: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${id}`, {
const response = await fetch(getApiUrl(`/groups/${id}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || ''
}
'x-auth-token': token || '',
},
});
const result = await response.json();
if (!response.ok) {
setError(result.message || t('groups.deleteError'));
return false;
}
triggerRefresh();
return true;
} catch (err) {
@@ -161,22 +165,22 @@ export const useGroupData = () => {
const addServerToGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers`, {
const response = await fetch(getApiUrl(`/groups/${groupId}/servers`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
'x-auth-token': token || '',
},
body: JSON.stringify({ serverName }),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverAddError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
@@ -189,20 +193,20 @@ export const useGroupData = () => {
const removeServerFromGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers/${serverName}`, {
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/${serverName}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || ''
}
'x-auth-token': token || '',
},
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverRemoveError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
@@ -227,6 +231,6 @@ export const useGroupData = () => {
updateGroupServers,
deleteGroup,
addServerToGroup,
removeServerFromGroup
removeServerFromGroup,
};
};
};

View File

@@ -0,0 +1,448 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/runtime';
export const useMarketData = () => {
const { t } = useTranslation();
const [servers, setServers] = useState<MarketServer[]>([]);
const [allServers, setAllServers] = useState<MarketServer[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [selectedTag, setSelectedTag] = useState<string>('');
const [searchQuery, setSearchQuery] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentServer, setCurrentServer] = useState<MarketServer | null>(null);
const [installedServers, setInstalledServers] = useState<string[]>([]);
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const [serversPerPage, setServersPerPage] = useState(9);
const [totalPages, setTotalPages] = useState(1);
// Fetch all market servers
const fetchMarketServers = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/servers'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
// Apply pagination to the fetched data
applyPagination(data.data, currentPage);
} else {
console.error('Invalid market servers data format:', data);
setError(t('market.fetchError'));
}
} catch (err) {
console.error('Error fetching market servers:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [t]);
// Apply pagination to data
const applyPagination = useCallback(
(data: MarketServer[], page: number, itemsPerPage = serversPerPage) => {
const totalItems = data.length;
const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage);
setTotalPages(calculatedTotalPages);
// Ensure current page is valid
const validPage = Math.max(1, Math.min(page, calculatedTotalPages));
if (validPage !== page) {
setCurrentPage(validPage);
}
const startIndex = (validPage - 1) * itemsPerPage;
const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage);
setServers(paginatedServers);
},
[serversPerPage],
);
// Change page
const changePage = useCallback(
(page: number) => {
setCurrentPage(page);
applyPagination(allServers, page, serversPerPage);
},
[allServers, applyPagination, serversPerPage],
);
// Fetch all categories
const fetchCategories = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/categories'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<string[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setCategories(data.data);
} else {
console.error('Invalid categories data format:', data);
}
} catch (err) {
console.error('Error fetching categories:', err);
}
}, []);
// Fetch all tags
const fetchTags = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/tags'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<string[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setTags(data.data);
} else {
console.error('Invalid tags data format:', data);
}
} catch (err) {
console.error('Error fetching tags:', err);
}
}, []);
// Fetch server by name
const fetchServerByName = useCallback(
async (name: string) => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/market/servers/${name}`), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer> = await response.json();
if (data && data.success && data.data) {
setCurrentServer(data.data);
return data.data;
} else {
console.error('Invalid server data format:', data);
setError(t('market.serverNotFound'));
return null;
}
} catch (err) {
console.error(`Error fetching server ${name}:`, err);
setError(err instanceof Error ? err.message : String(err));
return null;
} finally {
setLoading(false);
}
},
[t],
);
// Search servers by query
const searchServers = useCallback(
async (query: string) => {
try {
setLoading(true);
setSearchQuery(query);
if (!query.trim()) {
// Fetch fresh data from server instead of just applying pagination
fetchMarketServers();
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(
getApiUrl(`/market/servers/search?query=${encodeURIComponent(query)}`),
{
headers: {
'x-auth-token': token || '',
},
},
);
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
applyPagination(data.data, 1);
} else {
console.error('Invalid search results format:', data);
setError(t('market.searchError'));
}
} catch (err) {
console.error('Error searching servers:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[t, allServers, applyPagination, fetchMarketServers],
);
// Filter servers by category
const filterByCategory = useCallback(
async (category: string) => {
try {
setLoading(true);
setSelectedCategory(category);
setSelectedTag(''); // Reset tag filter when filtering by category
if (!category) {
fetchMarketServers();
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(
getApiUrl(`/market/categories/${encodeURIComponent(category)}`),
{
headers: {
'x-auth-token': token || '',
},
},
);
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
applyPagination(data.data, 1);
} else {
console.error('Invalid category filter results format:', data);
setError(t('market.filterError'));
}
} catch (err) {
console.error('Error filtering servers by category:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[t, fetchMarketServers, applyPagination],
);
// Filter servers by tag
const filterByTag = useCallback(
async (tag: string) => {
try {
setLoading(true);
setSelectedTag(tag);
setSelectedCategory(''); // Reset category filter when filtering by tag
if (!tag) {
fetchMarketServers();
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/market/tags/${encodeURIComponent(tag)}`), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
applyPagination(data.data, 1);
} else {
console.error('Invalid tag filter results format:', data);
setError(t('market.tagFilterError'));
}
} catch (err) {
console.error('Error filtering servers by tag:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[t, fetchMarketServers, applyPagination],
);
// Fetch installed servers
const fetchInstalledServers = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data = await response.json();
if (data && data.success && Array.isArray(data.data)) {
// Extract server names
const installedServerNames = data.data.map((server: any) => server.name);
setInstalledServers(installedServerNames);
}
} catch (err) {
console.error('Error fetching installed servers:', err);
}
}, []);
// Check if a server is already installed
const isServerInstalled = useCallback(
(serverName: string) => {
return installedServers.includes(serverName);
},
[installedServers],
);
// Install server to the local environment
const installServer = useCallback(
async (server: MarketServer) => {
try {
const installType = server.installations?.npm
? 'npm'
: Object.keys(server.installations || {}).length > 0
? Object.keys(server.installations)[0]
: null;
if (!installType || !server.installations?.[installType]) {
setError(t('market.noInstallationMethod'));
return false;
}
const installation = server.installations[installType];
// Prepare server configuration
const serverConfig = {
name: server.name,
config: {
command: installation.command,
args: installation.args,
env: installation.env || {},
},
};
// Call the createServer API
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify(serverConfig),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Status: ${response.status}`);
}
// Update installed servers list after successful installation
await fetchInstalledServers();
return true;
} catch (err) {
console.error('Error installing server:', err);
setError(err instanceof Error ? err.message : String(err));
return false;
}
},
[t, fetchInstalledServers],
);
// Change servers per page
const changeServersPerPage = useCallback(
(perPage: number) => {
setServersPerPage(perPage);
setCurrentPage(1);
applyPagination(allServers, 1, perPage);
},
[allServers, applyPagination],
);
// Load initial data
useEffect(() => {
fetchMarketServers();
fetchCategories();
fetchTags();
fetchInstalledServers();
}, [fetchMarketServers, fetchCategories, fetchTags, fetchInstalledServers]);
return {
servers,
allServers,
categories,
tags,
selectedCategory,
selectedTag,
searchQuery,
loading,
error,
setError,
currentServer,
fetchMarketServers,
fetchServerByName,
searchServers,
filterByCategory,
filterByTag,
installServer,
// Pagination properties and methods
currentPage,
totalPages,
serversPerPage,
changePage,
changeServersPerPage,
// Installed servers methods
isServerInstalled,
};
};

View File

@@ -1,18 +1,19 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Server, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/runtime';
// 配置选项
// Configuration options
const CONFIG = {
// 初始化启动阶段的配置
// Initialization phase configuration
startup: {
maxAttempts: 60, // 初始化阶段最大尝试次数
pollingInterval: 3000 // 初始阶段轮询间隔 (3秒)
maxAttempts: 60, // Maximum number of attempts during initialization
pollingInterval: 3000, // Polling interval during initialization (3 seconds)
},
// 正常运行阶段的配置
// Normal operation phase configuration
normal: {
pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒)
}
pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
},
};
export const useServerData = () => {
@@ -22,13 +23,13 @@ export const useServerData = () => {
const [refreshKey, setRefreshKey] = useState(0);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [fetchAttempts, setFetchAttempts] = useState(0);
// 轮询定时器引用
// Timer reference for polling
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// 保存当前尝试次数,避免依赖循环
// Track current attempt count to avoid dependency cycles
const attemptsRef = useRef<number>(0);
// 清理定时器
// Clear the timer
const clearTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
@@ -36,21 +37,21 @@ export const useServerData = () => {
}
};
// 开始正常轮询
// Start normal polling
const startNormalPolling = useCallback(() => {
// 确保没有其他定时器在运行
// Ensure no other timers are running
clearTimer();
const fetchServers = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || ''
}
'x-auth-token': token || '',
},
});
const data = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
} else if (data && Array.isArray(data)) {
@@ -59,158 +60,158 @@ export const useServerData = () => {
console.error('Invalid server data format:', data);
setServers([]);
}
// 重置错误状态
// Reset error state
setError(null);
} catch (err) {
console.error('Error fetching servers during normal polling:', err);
// 使用友好的错误消息
// Use friendly error message
if (!navigator.onLine) {
setError(t('errors.network'));
} else if (err instanceof TypeError && (
err.message.includes('NetworkError') ||
err.message.includes('Failed to fetch')
)) {
} else if (
err instanceof TypeError &&
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
) {
setError(t('errors.serverConnection'));
} else {
setError(t('errors.serverFetch'));
}
}
};
// 立即执行一次
// Execute immediately
fetchServers();
// 设置定期轮询
// Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
}, [t]);
useEffect(() => {
// 重置尝试计数
// Reset attempt count
if (refreshKey > 0) {
attemptsRef.current = 0;
setFetchAttempts(0);
}
// 初始化加载阶段的请求函数
// Initialization phase request function
const fetchInitialData = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || ''
}
'x-auth-token': token || '',
},
});
const data = await response.json();
// 处理API响应中的包装对象提取data字段
// Handle API response wrapper object, extract data field
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
setIsInitialLoading(false);
// 初始化成功,开始正常轮询
// Initialization successful, start normal polling
startNormalPolling();
return true;
} else if (data && Array.isArray(data)) {
// 兼容性处理如果API直接返回数组
// Compatibility handling, if API directly returns array
setServers(data);
setIsInitialLoading(false);
// 初始化成功,开始正常轮询
// Initialization successful, start normal polling
startNormalPolling();
return true;
} else {
// 如果数据格式不符合预期,设置为空数组
// If data format is not as expected, set to empty array
console.error('Invalid server data format:', data);
setServers([]);
setIsInitialLoading(false);
// 初始化成功但数据为空,开始正常轮询
// Initialization successful but data is empty, start normal polling
startNormalPolling();
return true;
}
} catch (err) {
// 增加尝试次数计数,使用 ref 避免触发 effect 重新运行
// Increment attempt count, use ref to avoid triggering effect rerun
attemptsRef.current += 1;
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
// 更新状态用于显示
// Update state for display
setFetchAttempts(attemptsRef.current);
// 设置适当的错误消息
// Set appropriate error message
if (!navigator.onLine) {
setError(t('errors.network'));
} else {
setError(t('errors.initialStartup'));
}
// 如果已超过最大尝试次数,放弃初始化并切换到正常轮询
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
console.log('Maximum startup attempts reached, switching to normal polling');
setIsInitialLoading(false);
// 清除初始化的轮询
// Clear initialization polling
clearTimer();
// 切换到正常轮询模式
// Switch to normal polling mode
startNormalPolling();
}
return false;
}
};
// 组件挂载时,根据当前状态设置适当的轮询
// On component mount, set appropriate polling based on current state
if (isInitialLoading) {
// 确保没有其他定时器在运行
// Ensure no other timers are running
clearTimer();
// 立即执行一次初始请求
// Execute initial request immediately
fetchInitialData();
// 设置初始阶段的轮询间隔
// Set polling interval for initialization phase
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
} else {
// 已经初始化完成,开始正常轮询
// Initialization completed, start normal polling
startNormalPolling();
}
// 清理函数
// Cleanup function
return () => {
clearTimer();
};
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
// 手动触发刷新
// Manually trigger refresh
const triggerRefresh = () => {
// 清除当前的定时器
// Clear current timer
clearTimer();
// 如果在初始化阶段,重置初始化状态
// If in initialization phase, reset initialization state
if (isInitialLoading) {
setIsInitialLoading(true);
attemptsRef.current = 0;
setFetchAttempts(0);
}
// refreshKey 的改变会触发 useEffect 再次运行
setRefreshKey(prevKey => prevKey + 1);
// Change in refreshKey will trigger useEffect to run again
setRefreshKey((prevKey) => prevKey + 1);
};
// 服务器相关操作
// Server related operations
const handleServerAdd = () => {
setRefreshKey(prevKey => prevKey + 1);
setRefreshKey((prevKey) => prevKey + 1);
};
const handleServerEdit = async (server: Server) => {
try {
// Fetch settings to get the full server config before editing
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/settings`, {
const response = await fetch(getApiUrl('/settings'), {
headers: {
'x-auth-token': token || ''
}
'x-auth-token': token || '',
},
});
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
if (
settingsData &&
settingsData.success &&
@@ -240,11 +241,11 @@ export const useServerData = () => {
const handleServerRemove = async (serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/servers/${serverName}`, {
const response = await fetch(getApiUrl(`/servers/${serverName}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || ''
}
'x-auth-token': token || '',
},
});
const result = await response.json();
@@ -253,7 +254,7 @@ export const useServerData = () => {
return false;
}
setRefreshKey(prevKey => prevKey + 1);
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
@@ -264,11 +265,11 @@ export const useServerData = () => {
const handleServerToggle = async (server: Server, enabled: boolean) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/servers/${server.name}/toggle`, {
const response = await fetch(getApiUrl(`/servers/${server.name}/toggle`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
'x-auth-token': token || '',
},
body: JSON.stringify({ enabled }),
});
@@ -282,7 +283,7 @@ export const useServerData = () => {
}
// Update the UI immediately to reflect the change
setRefreshKey(prevKey => prevKey + 1);
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
console.error('Error toggling server:', err);
@@ -301,6 +302,6 @@ export const useServerData = () => {
handleServerAdd,
handleServerEdit,
handleServerRemove,
handleServerToggle
handleServerToggle,
};
};
};

View File

@@ -0,0 +1,357 @@
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiResponse } from '@/types';
import { useToast } from '@/contexts/ToastContext';
import { getApiUrl } from '../utils/runtime';
// Define types for the settings data
interface RoutingConfig {
enableGlobalRoute: boolean;
enableGroupNameRoute: boolean;
enableBearerAuth: boolean;
bearerAuthKey: string;
}
interface InstallConfig {
pythonIndexUrl: string;
npmRegistry: string;
}
interface SmartRoutingConfig {
enabled: boolean;
dbUrl: string;
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
}
interface SystemSettings {
systemConfig?: {
routing?: RoutingConfig;
install?: InstallConfig;
smartRouting?: SmartRoutingConfig;
};
}
interface TempRoutingConfig {
bearerAuthKey: string;
}
export const useSettingsData = () => {
const { t } = useTranslation();
const { showToast } = useToast();
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
});
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
bearerAuthKey: '',
});
const [installConfig, setInstallConfig] = useState<InstallConfig>({
pythonIndexUrl: '',
npmRegistry: '',
});
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
enabled: false,
dbUrl: '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
// Trigger a refresh of the settings data
const triggerRefresh = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
// Fetch current settings
const fetchSettings = useCallback(async () => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/settings'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data: ApiResponse<SystemSettings> = await response.json();
if (data.success && data.data?.systemConfig?.routing) {
setRoutingConfig({
enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true,
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
});
}
if (data.success && data.data?.systemConfig?.install) {
setInstallConfig({
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
});
}
if (data.success && data.data?.systemConfig?.smartRouting) {
setSmartRoutingConfig({
enabled: data.data.systemConfig.smartRouting.enabled ?? false,
dbUrl: data.data.systemConfig.smartRouting.dbUrl || '',
openaiApiBaseUrl: data.data.systemConfig.smartRouting.openaiApiBaseUrl || '',
openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '',
openaiApiEmbeddingModel:
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
});
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
// 使用一个稳定的 showToast 引用,避免将其加入依赖数组
showToast(t('errors.failedToFetchSettings'));
} finally {
setLoading(false);
}
}, [t]); // 移除 showToast 依赖
// Update routing configuration
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
key: T,
value: RoutingConfig[T],
) => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
routing: {
[key]: value,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setRoutingConfig({
...routingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateRouteConfig'));
return false;
}
} catch (error) {
console.error('Failed to update routing config:', error);
setError(error instanceof Error ? error.message : 'Failed to update routing config');
showToast(t('errors.failedToUpdateRouteConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update install configuration
const updateInstallConfig = async (key: keyof InstallConfig, value: string) => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
install: {
[key]: value,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setInstallConfig({
...installConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update system config:', error);
setError(error instanceof Error ? error.message : 'Failed to update system config');
showToast(t('errors.failedToUpdateSystemConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update smart routing configuration
const updateSmartRoutingConfig = async <T extends keyof SmartRoutingConfig>(
key: T,
value: SmartRoutingConfig[T],
) => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
smartRouting: {
[key]: value,
},
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update smart routing config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update smart routing config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update multiple smart routing configuration fields at once
const updateSmartRoutingConfigBatch = async (updates: Partial<SmartRoutingConfig>) => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
smartRouting: updates,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update smart routing config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update smart routing config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Fetch settings when the component mounts or refreshKey changes
useEffect(() => {
fetchSettings();
}, [fetchSettings, refreshKey]);
useEffect(() => {
if (routingConfig) {
setTempRoutingConfig({
bearerAuthKey: routingConfig.bearerAuthKey,
});
}
}, [routingConfig]);
return {
routingConfig,
tempRoutingConfig,
setTempRoutingConfig,
installConfig,
smartRoutingConfig,
loading,
error,
setError,
triggerRefresh,
fetchSettings,
updateRoutingConfig,
updateInstallConfig,
updateSmartRoutingConfig,
updateSmartRoutingConfigBatch,
};
};

View File

@@ -32,8 +32,8 @@ i18n
},
detection: {
// Order of detection; we put 'navigator' first to use browser language
order: ['navigator', 'localStorage', 'cookie', 'htmlTag'],
// Order of detection; prioritize localStorage to respect user language choice
order: ['localStorage', 'cookie', 'htmlTag', 'navigator'],
// Cache the language in localStorage
caches: ['localStorage', 'cookie'],
}

View File

@@ -1,4 +1,4 @@
/* Use standard Tailwind directives */
/* Use project's custom Tailwind import */
@import "tailwindcss";
/* Add some custom styles to verify CSS is working correctly */
@@ -11,6 +11,52 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* Dark mode override styles - these will apply when dark class is on html element */
.dark body {
background-color: #111827;
color: #e5e7eb;
}
.dark .bg-white {
background-color: #1f2937 !important;
}
.dark .text-gray-900 {
color: #f9fafb !important;
}
.dark .text-gray-800 {
color: #f3f4f6 !important;
}
.dark .text-gray-700 {
color: #e5e7eb !important;
}
.dark .text-gray-600 {
color: #d1d5db !important;
}
.dark .text-gray-500 {
color: #9ca3af !important;
}
.dark .border-gray-300 {
border-color: #4b5563 !important;
}
.dark .bg-gray-100 {
background-color: #374151 !important;
}
.dark .bg-gray-50 {
background-color: #1f2937 !important;
}
.dark .shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
}
.bg-custom-blue {
background-color: #4a90e2;
}

View File

@@ -13,7 +13,7 @@ const MainLayout: React.FC = () => {
};
return (
<div className="flex flex-col min-h-screen bg-gray-100">
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900">
{/* 顶部导航 */}
<Header onToggleSidebar={toggleSidebar} />

View File

@@ -9,7 +9,46 @@
"profile": "Profile",
"changePassword": "Change Password",
"toggleSidebar": "Toggle Sidebar",
"welcomeUser": "Welcome, {{username}}"
"welcomeUser": "Welcome, {{username}}",
"name": "MCP Hub"
},
"about": {
"title": "About",
"versionInfo": "MCP Hub Version: {{version}}",
"newVersion": "New version available!",
"currentVersion": "Current version",
"newVersionAvailable": "New version {{version}} is available",
"viewOnGitHub": "View on GitHub",
"checkForUpdates": "Check for Updates",
"checking": "Checking for updates..."
},
"profile": {
"viewProfile": "View profile",
"userCenter": "User Center"
},
"sponsor": {
"label": "Sponsor",
"title": "Support the Project",
"rewardAlt": "Reward QR Code",
"supportMessage": "Support the development of MCP Hub by buying me a coffee!",
"supportButton": "Support on Ko-fi"
},
"wechat": {
"label": "WeChat",
"title": "Connect via WeChat",
"qrCodeAlt": "WeChat QR Code",
"scanMessage": "Scan this QR code to connect with us on WeChat"
},
"discord": {
"label": "Discord",
"title": "Join our Discord server",
"community": "Join our growing community on Discord for support, discussions, and updates!"
},
"theme": {
"title": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"auth": {
"login": "Login",
@@ -26,7 +65,9 @@
"passwordsNotMatch": "New password and confirmation do not match",
"changePasswordSuccess": "Password changed successfully",
"changePasswordError": "Failed to change password",
"changePassword": "Change Password"
"changePassword": "Change Password",
"passwordChanged": "Password changed successfully",
"passwordChangeError": "Failed to change password"
},
"server": {
"addServer": "Add Server",
@@ -40,7 +81,7 @@
"name": "Server Name",
"url": "Server URL",
"apiKey": "API Key",
"save": "Save Changes",
"save": "Save",
"cancel": "Cancel",
"invalidConfig": "Could not find configuration data for {{serverName}}",
"addError": "Failed to add server",
@@ -61,7 +102,13 @@
"toggleError": "Failed to toggle server {{serverName}}",
"alreadyExists": "Server {{serverName}} already exists",
"invalidData": "Invalid server data provided",
"notFound": "Server {{serverName}} not found"
"notFound": "Server {{serverName}} not found",
"namePlaceholder": "Enter server name",
"urlPlaceholder": "Enter server URL",
"commandPlaceholder": "Enter command",
"argumentsPlaceholder": "Enter arguments",
"errorDetails": "Error Details",
"viewErrorDetails": "View error details"
},
"status": {
"online": "Online",
@@ -75,7 +122,11 @@
"serverAdd": "Failed to add server. Please check the server status",
"serverUpdate": "Failed to edit server {{serverName}}. Please check the server status",
"serverFetch": "Failed to retrieve server data. Please try again later",
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch..."
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch...",
"serverInstall": "Failed to install server",
"failedToFetchSettings": "Failed to fetch settings",
"failedToUpdateRouteConfig": "Failed to update route configuration",
"failedToUpdateSmartRoutingConfig": "Failed to update smart routing configuration"
},
"common": {
"processing": "Processing...",
@@ -84,22 +135,28 @@
"refresh": "Refresh",
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete"
"delete": "Delete",
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
"close": "Close"
},
"nav": {
"dashboard": "Dashboard",
"servers": "Servers",
"groups": "Groups",
"settings": "Settings",
"changePassword": "Change Password"
"changePassword": "Change Password",
"market": "Market",
"logs": "Logs"
},
"pages": {
"dashboard": {
"title": "Dashboard",
"totalServers": "Total Servers",
"onlineServers": "Online Servers",
"offlineServers": "Offline Servers",
"connectingServers": "Connecting Servers",
"totalServers": "Total",
"onlineServers": "Online",
"offlineServers": "Offline",
"connectingServers": "Connecting",
"recentServers": "Recent Servers"
},
"servers": {
@@ -110,9 +167,34 @@
},
"settings": {
"title": "Settings",
"language": "Language"
"language": "Language",
"account": "Account Settings",
"password": "Change Password",
"appearance": "Appearance",
"routeConfig": "Security",
"installConfig": "Installation",
"smartRouting": "Smart Routing"
},
"market": {
"title": "Server Market - (Data from mcpm.sh)"
},
"logs": {
"title": "System Logs"
}
},
"logs": {
"filters": "Filters",
"search": "Search logs...",
"autoScroll": "Auto-scroll",
"clearLogs": "Clear logs",
"loading": "Loading logs...",
"noLogs": "No logs available.",
"noMatch": "No logs match the current filters.",
"mainProcess": "Main Process",
"childProcess": "Child Process",
"main": "Main",
"child": "Child"
},
"groups": {
"add": "Add",
"addNew": "Add New Group",
@@ -138,5 +220,79 @@
"noServers": "No servers in this group.",
"noServerOptions": "No servers available",
"serverCount": "{{count}} Servers"
},
"market": {
"title": "Server Market",
"official": "Official",
"by": "By",
"unknown": "Unknown",
"tools": "tools",
"search": "Search",
"searchPlaceholder": "Search for servers by name, category, or tags",
"clearFilters": "Clear",
"clearCategoryFilter": "",
"clearTagFilter": "",
"categories": "Categories",
"tags": "Tags",
"showTags": "Show tags",
"hideTags": "Hide tags",
"moreTags": "",
"noServers": "No servers found matching your search",
"backToList": "Back to list",
"install": "Install",
"installing": "Installing...",
"installed": "Installed",
"installServer": "Install Server: {{name}}",
"installSuccess": "Server {{serverName}} installed successfully",
"author": "Author",
"license": "License",
"repository": "Repository",
"examples": "Examples",
"arguments": "Arguments",
"argumentName": "Name",
"description": "Description",
"required": "Required",
"example": "Example",
"viewSchema": "View schema",
"fetchError": "Error fetching market servers",
"serverNotFound": "Server not found",
"searchError": "Error searching servers",
"filterError": "Error filtering servers by category",
"tagFilterError": "Error filtering servers by tag",
"noInstallationMethod": "No installation method available for this server",
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
"perPage": "Per page"
},
"settings": {
"enableGlobalRoute": "Enable Global Route",
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
"enableGroupNameRoute": "Enable Group Name Route",
"enableGroupNameRouteDescription": "Allow connections to /sse endpoint using group names instead of just group IDs",
"enableBearerAuth": "Enable Bearer Authentication",
"enableBearerAuthDescription": "Require bearer token authentication for MCP requests",
"bearerAuthKey": "Bearer Authentication Key",
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
"bearerAuthKeyPlaceholder": "Enter bearer authentication key",
"pythonIndexUrl": "Python Package Repository URL",
"pythonIndexUrlDescription": "Set UV_DEFAULT_INDEX environment variable for Python package installation",
"pythonIndexUrlPlaceholder": "e.g. https://pypi.org/simple",
"npmRegistry": "NPM Registry URL",
"npmRegistryDescription": "Set npm_config_registry environment variable for NPM package installation",
"npmRegistryPlaceholder": "e.g. https://registry.npmjs.org/",
"installConfig": "Installation",
"systemConfigUpdated": "System configuration updated successfully",
"enableSmartRouting": "Enable Smart Routing",
"enableSmartRoutingDescription": "Enable smart routing feature to search the most suitable tool based on input (using $smart group name)",
"dbUrl": "PostgreSQL URL (with pgvector support)",
"dbUrlPlaceholder": "e.g. postgresql://user:password@localhost:5432/dbname",
"openaiApiBaseUrl": "OpenAI API Base URL",
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKey": "OpenAI API Key",
"openaiApiKeyPlaceholder": "Enter OpenAI API key",
"openaiApiEmbeddingModel": "OpenAI Embedding Model",
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
"smartRoutingConfigUpdated": "Smart routing configuration updated successfully",
"smartRoutingRequiredFields": "Database URL and OpenAI API Key are required to enable smart routing",
"smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}"
}
}

View File

@@ -9,7 +9,46 @@
"profile": "个人资料",
"changePassword": "修改密码",
"toggleSidebar": "切换侧边栏",
"welcomeUser": "欢迎, {{username}}"
"welcomeUser": "欢迎, {{username}}",
"name": "MCP Hub"
},
"about": {
"title": "关于",
"versionInfo": "MCP Hub 版本: {{version}}",
"newVersion": "有新版本可用!",
"currentVersion": "当前版本",
"newVersionAvailable": "新版本 {{version}} 已发布",
"viewOnGitHub": "在 GitHub 上查看",
"checkForUpdates": "检查更新",
"checking": "检查更新中..."
},
"profile": {
"viewProfile": "查看个人中心",
"userCenter": "个人中心"
},
"sponsor": {
"label": "赞助",
"title": "支持项目",
"rewardAlt": "赞赏码",
"supportMessage": "通过捐赠支持 MCP Hub 的开发!",
"supportButton": "在 Ko-fi 上支持"
},
"wechat": {
"label": "微信",
"title": "微信联系",
"qrCodeAlt": "微信二维码",
"scanMessage": "扫描二维码添加微信"
},
"discord": {
"label": "Discord",
"title": "加入我们的 Discord 服务器",
"community": "加入我们不断壮大的 Discord 社区,获取支持、参与讨论并了解最新动态!"
},
"theme": {
"title": "主题",
"light": "浅色",
"dark": "深色",
"system": "系统"
},
"auth": {
"login": "登录",
@@ -26,7 +65,9 @@
"passwordsNotMatch": "新密码与确认密码不一致",
"changePasswordSuccess": "密码修改成功",
"changePasswordError": "修改密码失败",
"changePassword": "修改密码"
"changePassword": "修改密码",
"passwordChanged": "密码修改成功",
"passwordChangeError": "修改密码失败"
},
"server": {
"addServer": "添加服务器",
@@ -40,7 +81,7 @@
"name": "服务器名称",
"url": "服务器 URL",
"apiKey": "API 密钥",
"save": "保存更改",
"save": "保存",
"cancel": "取消",
"addError": "添加服务器失败",
"editError": "编辑服务器 {{serverName}} 失败",
@@ -61,7 +102,13 @@
"toggleError": "切换服务器 {{serverName}} 状态失败",
"alreadyExists": "服务器 {{serverName}} 已经存在",
"invalidData": "提供的服务器数据无效",
"notFound": "找不到服务器 {{serverName}}"
"notFound": "找不到服务器 {{serverName}}",
"namePlaceholder": "请输入服务器名称",
"urlPlaceholder": "请输入服务器URL",
"commandPlaceholder": "请输入命令",
"argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情"
},
"status": {
"online": "在线",
@@ -75,7 +122,12 @@
"serverAdd": "添加服务器失败,请检查服务器状态",
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
"serverFetch": "获取服务器数据失败,请稍后重试",
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候..."
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
"serverInstall": "安装服务器失败",
"failedToFetchSettings": "获取设置失败",
"failedToUpdateSystemConfig": "更新系统配置失败",
"failedToUpdateRouteConfig": "更新路由配置失败",
"failedToUpdateSmartRoutingConfig": "更新智能路由配置失败"
},
"common": {
"processing": "处理中...",
@@ -84,22 +136,28 @@
"refresh": "刷新",
"create": "创建",
"submitting": "提交中...",
"delete": "删除"
"delete": "删除",
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败",
"close": "关闭"
},
"nav": {
"dashboard": "仪表盘",
"servers": "服务器",
"settings": "设置",
"changePassword": "修改密码",
"groups": "分组"
"groups": "分组",
"market": "市场",
"logs": "日志"
},
"pages": {
"dashboard": {
"title": "仪表盘",
"totalServers": "服务器总数",
"onlineServers": "在线服务器",
"offlineServers": "离线服务器",
"connectingServers": "连接中服务",
"totalServers": "总数",
"onlineServers": "在线",
"offlineServers": "离线",
"connectingServers": "连接中",
"recentServers": "最近的服务器"
},
"servers": {
@@ -107,12 +165,37 @@
},
"settings": {
"title": "设置",
"language": "语言"
"language": "语言",
"account": "账户设置",
"password": "修改密码",
"appearance": "外观",
"routeConfig": "安全配置",
"installConfig": "安装",
"smartRouting": "智能路由"
},
"groups": {
"title": "分组管理"
},
"market": {
"title": "服务器市场 - (数据来源于 mcpm.sh"
},
"logs": {
"title": "系统日志"
}
},
"logs": {
"filters": "筛选",
"search": "搜索日志...",
"autoScroll": "自动滚动",
"clearLogs": "清除日志",
"loading": "加载日志中...",
"noLogs": "暂无日志。",
"noMatch": "没有匹配当前筛选条件的日志。",
"mainProcess": "主进程",
"childProcess": "子进程",
"main": "主",
"child": "子"
},
"groups": {
"add": "添加",
"addNew": "添加新分组",
@@ -138,5 +221,80 @@
"noServers": "此分组中没有服务器。",
"noServerOptions": "没有可用的服务器",
"serverCount": "{{count}} 台服务器"
},
"market": {
"title": "服务器市场",
"official": "官方",
"by": "作者",
"unknown": "未知",
"tools": "工具",
"search": "搜索",
"searchPlaceholder": "搜索服务器名称、分类或标签",
"clearFilters": "清除",
"clearCategoryFilter": "",
"clearTagFilter": "",
"categories": "分类",
"tags": "标签",
"showTags": "显示标签",
"hideTags": "隐藏标签",
"moreTags": "",
"noServers": "未找到匹配的服务器",
"backToList": "返回列表",
"install": "安装",
"installing": "安装中...",
"installed": "已安装",
"installServer": "安装服务器: {{name}}",
"installSuccess": "服务器 {{serverName}} 安装成功",
"author": "作者",
"license": "许可证",
"repository": "代码仓库",
"examples": "示例",
"arguments": "参数",
"argumentName": "名称",
"description": "描述",
"required": "必填",
"example": "示例",
"viewSchema": "查看结构",
"fetchError": "获取服务器市场数据失败",
"serverNotFound": "未找到服务器",
"searchError": "搜索服务器失败",
"filterError": "按分类筛选服务器失败",
"tagFilterError": "按标签筛选服务器失败",
"noInstallationMethod": "该服务器没有可用的安装方法",
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
"perPage": "每页显示"
},
"settings": {
"enableGlobalRoute": "启用全局路由",
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",
"enableGroupNameRoute": "启用组名路由",
"enableGroupNameRouteDescription": "允许使用组名而不仅仅是组 ID 连接到 /sse 端点",
"enableBearerAuth": "启用 Bearer 认证",
"enableBearerAuthDescription": "对 MCP 请求启用 Bearer 令牌认证",
"bearerAuthKey": "Bearer 认证密钥",
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
"pythonIndexUrl": "Python 包仓库地址",
"pythonIndexUrlDescription": "设置 UV_DEFAULT_INDEX 环境变量,用于 Python 包安装",
"pythonIndexUrlPlaceholder": "例如: https://mirrors.aliyun.com/pypi/simple",
"npmRegistry": "NPM 仓库地址",
"npmRegistryDescription": "设置 npm_config_registry 环境变量,用于 NPM 包安装",
"npmRegistryPlaceholder": "例如: https://registry.npmmirror.com/",
"installConfig": "安装配置",
"systemConfigUpdated": "系统配置更新成功",
"enableSmartRouting": "启用智能路由",
"enableSmartRoutingDescription": "开启智能路由功能,根据输入自动搜索最合适的工具(使用 $smart 分组)",
"dbUrl": "PostgreSQL 连接地址(支持 pgvector",
"dbUrlPlaceholder": "例如: postgresql://user:password@localhost:5432/dbname",
"openaiApiBaseUrl": "OpenAI API 基础地址",
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKey": "OpenAI API 密钥",
"openaiApiKeyDescription": "用于访问 OpenAI API 的密钥",
"openaiApiKeyPlaceholder": "请输入 OpenAI API 密钥",
"openaiApiEmbeddingModel": "OpenAI 嵌入模型",
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
"smartRoutingConfigUpdated": "智能路由配置更新成功",
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}"
}
}

View File

@@ -1,12 +1,45 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
// Import the i18n configuration
import './i18n'
import './i18n';
import { loadRuntimeConfig } from './utils/runtime';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
// Load runtime configuration before starting the app
async function initializeApp() {
try {
console.log('Loading runtime configuration...');
const config = await loadRuntimeConfig();
console.log('Runtime configuration loaded:', config);
// Store config in window object
window.__MCPHUB_CONFIG__ = config;
// Start React app
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
} catch (error) {
console.error('Failed to initialize app:', error);
// Fallback: start app with default config
console.log('Starting app with default configuration...');
window.__MCPHUB_CONFIG__ = {
basePath: '',
version: 'dev',
name: 'mcphub',
};
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
}
// Initialize the app
initializeApp();

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