Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc184afc6 | ||
|
|
1acacfab88 | ||
|
|
63c55875b5 | ||
|
|
80c6aae116 | ||
|
|
e2c5cc8ed1 | ||
|
|
b0a65cc6d0 | ||
|
|
e019c9e8b3 | ||
|
|
39b69bf550 | ||
|
|
a1047321d1 | ||
|
|
268ce5cce6 | ||
|
|
37bb3414c8 | ||
|
|
c3e1fa1eee | ||
|
|
edfbdb7123 | ||
|
|
9e5c2b5525 | ||
|
|
27b7e766af | ||
|
|
9c55f63bb1 | ||
|
|
b1674cc630 | ||
|
|
3570232ccc | ||
|
|
f50bb08816 | ||
|
|
721674e7eb | ||
|
|
1337eeed84 | ||
|
|
b14be9955b | ||
|
|
da45d49a5e | ||
|
|
f46e2c22fc | ||
|
|
ae3fe1c6f1 | ||
|
|
76a27a454e | ||
|
|
b15b71f407 | ||
|
|
298d96d593 | ||
|
|
d44886b81b | ||
|
|
bbe44fc540 | ||
|
|
c60b98e3d6 | ||
|
|
a447fe5b41 | ||
|
|
94d51fa03a | ||
|
|
b88a7240c6 | ||
|
|
3c875590ce | ||
|
|
26fa61fcfc | ||
|
|
d689541fc4 | ||
|
|
30895c4b9a | ||
|
|
37c3fd9e06 | ||
|
|
59454ca250 | ||
|
|
63efa0038c | ||
|
|
040782da8d | ||
|
|
f1a5f692cc | ||
|
|
5d798cfe6a | ||
|
|
0490d98c9e |
29
.github/ISSUE_TEMPLATE/bug-report---bug-报告.md
vendored
Normal 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? / 还有其他信息吗?
|
||||
20
.github/ISSUE_TEMPLATE/feature-request---功能请求.md
vendored
Normal 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
@@ -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"
|
||||
12
.github/workflows/build.yml
vendored
@@ -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:
|
||||
|
||||
1
.github/workflows/release.yml
vendored
@@ -13,6 +13,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
19
Dockerfile
@@ -18,20 +18,23 @@ 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
|
||||
|
||||
|
||||
84
README.md
@@ -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 Streamable HTTP (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.
|
||||
|
||||

|
||||
|
||||
## 🚀 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,7 +18,7 @@ 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:
|
||||
|
||||
@@ -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,46 +69,81 @@ 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
|
||||
|
||||
### 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:**
|
||||
|
||||

|
||||
|
||||
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)**:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -127,16 +151,25 @@ Where `{server}` is the name of the server you want to connect to. This allows y
|
||||
### SSE Endpoint (Deprecated in Future)
|
||||
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
For smart routing, use:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/$smart
|
||||
```
|
||||
|
||||
For targeted access to specific server groups, use the group-based SSE endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
For direct access to individual servers, use the server-specific SSE endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{server}
|
||||
```
|
||||
@@ -157,6 +190,7 @@ This starts both frontend and backend in development mode with hot-reloading.
|
||||
## 🛠️ 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
|
||||
@@ -172,13 +206,25 @@ proxy_buffering off
|
||||
|
||||
## 👥 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:
|
||||
|
||||
[](https://ko-fi.com/samanhappy)
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
[](https://www.star-history.com/#samanhappy/mcphub&Date)
|
||||
|
||||
## 📄 License
|
||||
|
||||
Licensed under the [Apache 2.0 License](LICENSE).
|
||||
|
||||
57
README.zh.md
@@ -2,13 +2,13 @@
|
||||
|
||||
[English Version](README.md) | 中文版
|
||||
|
||||
MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议)服务器聚合平台,可以根据场景将多个服务器聚合到不同的流式 HTTP(SSE)端点。它通过直观的界面和强大的协议处理能力,简化了您的 AI 工具集成流程。
|
||||
MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活的流式 HTTP(SSE)端点,简化了管理与扩展工作。系统支持按需访问全部服务器、单个服务器或按场景分组的服务器集合。
|
||||
|
||||

|
||||
|
||||
## 🚀 功能亮点
|
||||
|
||||
- **开箱即用的 MCP 服务器支持**:无缝集成 `amap-maps`、`playwright`、`fetch`、`slack` 等常见服务器。
|
||||
- **广泛的 MCP 服务器支持**:无缝集成任何 MCP 服务器,配置简单。
|
||||
- **集中式管理控制台**:在一个简洁的 Web UI 中实时监控所有服务器的状态和性能指标。
|
||||
- **灵活的协议兼容**:完全支持 stdio 和 SSE 两种 MCP 协议。
|
||||
- **热插拔式配置**:在运行时动态添加、移除或更新服务器配置,无需停机。
|
||||
@@ -18,7 +18,7 @@ MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议
|
||||
|
||||
## 🔧 快速开始
|
||||
|
||||
### 可选配置
|
||||
### 配置
|
||||
|
||||
通过创建 `mcp_settings.json` 自定义服务器设置:
|
||||
|
||||
@@ -48,23 +48,10 @@ MCPHub 是一个统一的 MCP(Model 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 部署
|
||||
|
||||
**推荐**:挂载自定义配置:
|
||||
@@ -109,6 +96,31 @@ http://localhost:3000/mcp
|
||||
- 轻松与各种 AI 客户端和工具集成
|
||||
- 对所有服务器使用相同的端点,简化集成过程
|
||||
|
||||
**智能路由(实验性功能)**:
|
||||
|
||||
智能路由是 MCPHub 的智能工具发现系统,使用向量语义搜索自动为任何给定任务找到最相关的工具。
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/$smart
|
||||
```
|
||||
|
||||
**工作原理:**
|
||||
|
||||
1. **工具索引**:所有 MCP 工具自动转换为向量嵌入并存储在 PostgreSQL 与 pgvector 中
|
||||
2. **语义搜索**:用户查询转换为向量并使用余弦相似度与工具嵌入匹配
|
||||
3. **智能筛选**:动态阈值确保相关结果且无噪声
|
||||
4. **精确执行**:找到的工具可以直接执行并进行适当的参数验证
|
||||
|
||||
**设置要求:**
|
||||
|
||||

|
||||
|
||||
为了启用智能路由,您需要:
|
||||
|
||||
- 支持 pgvector 扩展的 PostgreSQL
|
||||
- OpenAI API 密钥(或兼容的嵌入服务)
|
||||
- 在 MCPHub 设置中启用智能路由
|
||||
|
||||
**基于分组的 HTTP 端点(推荐)**:
|
||||

|
||||
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
|
||||
@@ -144,6 +156,12 @@ http://localhost:3000/mcp/{server}
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
要启用智能路由,请使用:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/$smart
|
||||
```
|
||||
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
|
||||
|
||||
```
|
||||
@@ -204,8 +222,13 @@ proxy_buffering off
|
||||
<img src="assets/reward.png" width="350">
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下人员的赞赏:小白、琛。你们的支持是我继续前进的动力!
|
||||
|
||||
## 🌟 Star 历史趋势
|
||||
|
||||
[](https://www.star-history.com/#samanhappy/mcphub&Date)
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [Apache 2.0 许可证](LICENSE)。
|
||||
|
||||
BIN
articals/assets/sr-conf.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
articals/assets/sr-dc.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
articals/assets/sr-map-call.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
articals/assets/sr-map-result.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
articals/assets/sr-map-search.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
articals/assets/sr-servers.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
articals/assets/sr-time.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
articals/assets/sr-tools.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
articals/assets/sr-web.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
177
articals/smart-routing.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# 无限工具,智能路由:MCPHub 引领 AI 工具使用新范式
|
||||
|
||||
## 概述
|
||||
|
||||
在现代 AI 应用中,随着 MCP 服务器数量的快速增长和工具种类的不断丰富,如何从数百个可用工具中快速定位最适合当前任务的工具,成为开发者和 AI 助手面临的一项重要挑战。
|
||||
|
||||
传统做法要么将所有工具暴露给 AI 助手处理,导致 token 消耗巨大、响应延迟严重;要么依赖开发者手动分组配置,灵活性和智能性不足。
|
||||
|
||||
MCPHub 推出的智能路由功能,基于向量语义搜索技术,实现了自然语言驱动的工具发现与精准推荐。它让 AI 助手能够像人类专家一样,根据任务描述自动选择最优工具组合,大幅提升了系统效率和用户体验。
|
||||
|
||||
## 什么是智能路由
|
||||
|
||||
### 技术原理
|
||||
|
||||
智能路由是 MCPHub 的核心功能之一。它将每个 MCP 工具的名称和描述嵌入为高维语义向量。当用户发起自然语言任务请求时,系统会将该请求也转换为向量,通过计算相似度,快速返回最相关的工具列表。
|
||||
|
||||
这一过程摒弃了传统的关键词匹配,具备更强的语义理解能力,能够处理自然语言的模糊性和多样性。
|
||||
|
||||
### 核心组件
|
||||
|
||||
- **向量嵌入引擎**:支持如 `text-embedding-3-small`、`bge-m3` 等主流模型,将文本描述转为语义向量。
|
||||
- **PostgreSQL + pgvector**:使用开源向量数据库方案,支持高效的向量索引和搜索。
|
||||
- **两步工作流分离**:
|
||||
- `search_tools`:负责语义工具发现
|
||||
- `call_tool`:执行实际工具调用逻辑
|
||||
|
||||
## 为什么需要智能路由
|
||||
|
||||
### 1. 减少认知负荷
|
||||
|
||||
- 当工具数量超过 100 个,AI 模型难以处理所有工具上下文。
|
||||
- 智能路由通过语义压缩,将候选工具缩小至 5~10 个,提高决策效率。
|
||||
|
||||
### 2. 显著降低 token 消耗
|
||||
|
||||
- 传统做法传入全量工具描述,可能消耗上千 token。
|
||||
- 使用智能路由,通常可将 token 使用降低 70~90%。
|
||||
|
||||
### 3. 提升调用准确率
|
||||
|
||||
- 理解任务语义:如“图片增强”→选择图像处理工具,而不是依赖命名关键词。
|
||||
- 上下文感知:考虑输入/输出格式和工具组合能力,匹配更合理的执行链路。
|
||||
|
||||
## 智能路由配置指南
|
||||
|
||||
### 1. 启动支持 `pgvector` 的 PostgreSQL 数据库
|
||||
|
||||
```bash
|
||||
docker run --name mcphub-postgres \
|
||||
-e POSTGRES_DB=mcphub \
|
||||
-e POSTGRES_USER=mcphub \
|
||||
-e POSTGRES_PASSWORD=your_password \
|
||||
-p 5432:5432 \
|
||||
-d pgvector/pgvector:pg17
|
||||
```
|
||||
|
||||
如已部署 PostgreSQL,可直接创建数据库并启用 `pgvector` 扩展:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE mcphub;
|
||||
CREATE EXTENSION vector;
|
||||
```
|
||||
|
||||
### 2. 获取 embedding 模型的 API Key
|
||||
|
||||
前往 OpenAI 或其他提供商获取嵌入模型的 API Key。国内用户推荐使用硅基流动 `bge-m3` 免费模型,没有注册过的用户可以使用我的邀请链接:[https://cloud.siliconflow.cn/i/TQhVYBvA](https://cloud.siliconflow.cn/i/TQhVYBvA)。
|
||||
|
||||
### 3. 控制台配置
|
||||
|
||||

|
||||
|
||||
在 MCPHub 控制台中,进入「智能路由」配置页面,填写以下信息:
|
||||
|
||||
- **数据库 URL**:`postgresql://mcphub:your_password@localhost:5432/mcphub`
|
||||
- **OpenAI API Key** :填写你获取的嵌入模型 API Key
|
||||
- **OpenAI 基础 URL**:`https://api.siliconflow.cn/v1`
|
||||
- **嵌入模型**:推荐使用 `BAAI/bge-m3`
|
||||
|
||||
开启「启用智能路由」后系统会自动:
|
||||
|
||||
- 对所有工具生成向量嵌入
|
||||
- 构建向量索引
|
||||
- 自动监听新增工具,更新索引
|
||||
|
||||
## 工具定义
|
||||
|
||||
### search_tools - 工具搜索
|
||||
|
||||
```ts
|
||||
{
|
||||
"name": "search_tools",
|
||||
"arguments": {
|
||||
"query": "help me resize and convert images",
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### call_tool - 工具执行
|
||||
|
||||
```ts
|
||||
{
|
||||
"name": "call_tool",
|
||||
"arguments": {
|
||||
"toolName": "image_resizer",
|
||||
"arguments": {
|
||||
"input_path": "/path/to/image.png",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 演示
|
||||
|
||||
下面我将通过几个示例来展示如何使用智能路由。
|
||||
|
||||
首先,我们需要在 mcphub 添加几个不同类型的 MCP 服务器:`amap`、`time-map`、`fetch`。
|
||||
|
||||

|
||||
|
||||
然后我们需要选择一个支持 MCP 的客户端,这里选择国产的 DeepChat,聊天模型选择 `Qwen3-14B`。
|
||||
|
||||
接着,在 DeepChat 中添加 mcphub 的智能路由端点:
|
||||
|
||||

|
||||
|
||||
添加成功后,就可以在工具中看到 `search_tools` 和 `call_tool` 两个工具了:
|
||||
|
||||

|
||||
|
||||
### 示例 1:地图导航
|
||||
|
||||
输入:从北京如何导航到上海。
|
||||
|
||||
结果:
|
||||
|
||||

|
||||
|
||||
可以看到,DeepChat 先调用了 `search_tools` 工具:
|
||||
|
||||

|
||||
|
||||
然后再调用 `call_tool` 查询具体的导航信息:
|
||||
|
||||

|
||||
|
||||
### 示例 2:查询时间
|
||||
|
||||
输入:使用工具查询美国现在的时间是几点
|
||||
|
||||
结果:
|
||||
|
||||

|
||||
|
||||
需要说明的是,由于不同的模型对工具调用的支持程度不同,可能会出现一些差异。比如在这个例子中,为了提高准确性,我在输入中明确提到了“使用工具”。
|
||||
|
||||
### 示例 3:查看网页
|
||||
|
||||
输入:打开 baidu.com 看看有什么
|
||||
|
||||
结果:
|
||||
|
||||

|
||||
|
||||
可以看到,DeepChat 成功调用了工具,不过由于百度的 robots.txt 限制,无法获取到具体内容。
|
||||
|
||||
## 结语
|
||||
|
||||
借助 MCPHub 的智能路由功能,AI 助手能够更高效地处理复杂任务,显著减少不必要的 token 消耗,同时提升工具调用的准确性与灵活性。作为面向未来的 AI 工具发现与调用基础设施,智能路由不仅使 AI 更聪明地选择和组合工具,还为多 Agent 协同提供了清晰、可控且可扩展的底层能力支撑。
|
||||
|
||||
> MCPHub 只是我一时兴起开发的小项目,没想到收获了这么多关注,非常感谢大家的支持!目前 MCPHub 还有不少地方需要优化和完善,我也专门建了个交流群,感兴趣的可以添加下面的微信。
|
||||
|
||||

|
||||
|
||||
> 同时,欢迎大家一起参与建设!项目地址为:[https://github.com/samanhappy/mcphub](https://github.com/samanhappy/mcphub)。
|
||||
BIN
assets/smart-routing.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/smart-routing.zh.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
32
docs/README.md
Normal 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`
|
||||
4
docs/api-reference/endpoint/create.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: 'Create Plant'
|
||||
openapi: 'POST /plants'
|
||||
---
|
||||
4
docs/api-reference/endpoint/delete.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: 'Delete Plant'
|
||||
openapi: 'DELETE /plants/{id}'
|
||||
---
|
||||
4
docs/api-reference/endpoint/get.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: 'Get Plants'
|
||||
openapi: 'GET /plants'
|
||||
---
|
||||
4
docs/api-reference/endpoint/webhook.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: 'New Plant'
|
||||
openapi: 'WEBHOOK /plant/webhook'
|
||||
---
|
||||
33
docs/api-reference/introduction.mdx
Normal 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": []
|
||||
}
|
||||
]
|
||||
```
|
||||
217
docs/api-reference/openapi.json
Normal 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
@@ -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
@@ -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
@@ -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!");
|
||||
}
|
||||
}
|
||||
```
|
||||
````
|
||||
59
docs/essentials/images.mdx
Normal 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
|
||||

|
||||
```
|
||||
|
||||
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>
|
||||
```
|
||||
88
docs/essentials/markdown.mdx
Normal 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>
|
||||
```
|
||||
87
docs/essentials/navigation.mdx
Normal 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.
|
||||
110
docs/essentials/reusable-snippets.mdx
Normal 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'} />
|
||||
```
|
||||
318
docs/essentials/settings.mdx
Normal 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
@@ -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 |
BIN
docs/images/checks-passed.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
docs/images/hero-dark.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/images/hero-light.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
71
docs/index.mdx
Normal 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
|
After Width: | Height: | Size: 12 KiB |
21
docs/logo/light.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
97
docs/quickstart.mdx
Normal 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 you’re 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>
|
||||
4
docs/snippets/snippet-intro.mdx
Normal 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.
|
||||
BIN
frontend/favicon.ico
Normal file → Executable file
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
BIN
frontend/public/assets/reward.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/public/assets/wexin.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
@@ -12,17 +12,19 @@ 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 (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Router>
|
||||
<Router basename={basename}>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
@@ -35,7 +37,7 @@ function App() {
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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,7 +69,7 @@ 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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -17,7 +17,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
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-process'>>(['main', 'child-process']);
|
||||
const [sourceFilter, setSourceFilter] = useState<Array<'main' | 'child'>>(['main', 'child']);
|
||||
|
||||
// Auto scroll to bottom when new logs come in if autoScroll is enabled
|
||||
useEffect(() => {
|
||||
@@ -30,7 +30,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
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-process');
|
||||
const matchesSource = sourceFilter.includes(log.source as 'main' | 'child');
|
||||
return matchesText && matchesType && matchesSource;
|
||||
});
|
||||
|
||||
@@ -48,10 +48,19 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
// Get badge color based on log type
|
||||
const getLogTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error': return 'bg-red-500';
|
||||
case 'warn': return 'bg-yellow-500';
|
||||
case 'debug': return 'bg-purple-500';
|
||||
default: return 'bg-blue-500';
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,11 +101,11 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
|
||||
{/* Log source filters */}
|
||||
<div className="flex gap-1 items-center ml-2">
|
||||
{(['main', 'child-process'] as const).map(source => (
|
||||
{(['main', 'child'] as const).map(source => (
|
||||
<Badge
|
||||
key={source}
|
||||
variant={sourceFilter.includes(source) ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
className={`cursor-pointer ${sourceFilter.includes(source) ? getSourceColor(source) : ''}`}
|
||||
onClick={() => {
|
||||
if (sourceFilter.includes(source)) {
|
||||
setSourceFilter(prev => prev.filter(s => s !== source));
|
||||
@@ -156,14 +165,17 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
|
||||
<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' : ''
|
||||
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="outline" className="mr-2">
|
||||
<Badge
|
||||
variant="default"
|
||||
className={`mr-2 ${getSourceColor(log.source)}`}
|
||||
>
|
||||
{log.source === 'main' ? t('logs.main') : t('logs.child')}
|
||||
{log.processId ? ` (${log.processId})` : ''}
|
||||
</Badge>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
20
frontend/src/components/icons/DiscordIcon.tsx
Normal 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;
|
||||
21
frontend/src/components/icons/GitHubIcon.tsx
Normal 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;
|
||||
20
frontend/src/components/icons/SponsorIcon.tsx
Normal 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;
|
||||
20
frontend/src/components/icons/WeChatIcon.tsx
Normal 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;
|
||||
1
frontend/src/components/icons/discord.svg
Normal 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 |
1
frontend/src/components/icons/github.svg
Normal 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 |
1
frontend/src/components/icons/sponsor.svg
Normal 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 |
1
frontend/src/components/icons/wechat.svg
Normal 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 |
@@ -1,15 +1,23 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { t, i18n } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
|
||||
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
|
||||
@@ -30,12 +38,53 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Theme Switch */}
|
||||
{/* Theme Switch and Version */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<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={() => 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')}
|
||||
>
|
||||
<WeChatIcon className="h-5 w-5" />
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
import { X, RefreshCw } from 'lucide-react';
|
||||
import { checkLatestVersion, compareVersions } from '@/utils/version';
|
||||
|
||||
interface AboutDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,30 +13,28 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if there's a new version available
|
||||
// In a real application, this would make an API call to check for updates
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Mock implementation - in real implementation, you would fetch from an API
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
// This is a placeholder - in a real app you would call an API
|
||||
// const response = await fetch('/api/check-updates');
|
||||
// const data = await response.json();
|
||||
|
||||
// For demo purposes, we'll just pretend there's a new version
|
||||
// TODO: Replace this placeholder logic with a real API call to check for updates
|
||||
setHasNewVersion(false);
|
||||
setLatestVersion("0.0.28"); // Just a higher version for demo
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, version]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -66,7 +65,7 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasNewVersion && (
|
||||
{hasNewVersion && latestVersion && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900 p-3 rounded">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -74,14 +73,35 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
|
||||
<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">
|
||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
{t('about.newVersion')} (v{latestVersion})
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
60
frontend/src/components/ui/SponsorDialog.tsx
Normal 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;
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -19,17 +20,21 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
const [showAboutDialog, setShowAboutDialog] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check for new version - this could be replaced with a real API call
|
||||
// Check for new version on login and component mount
|
||||
useEffect(() => {
|
||||
// Mock implementation - in a real app, you would check against an API
|
||||
const checkForNewVersion = async () => {
|
||||
// For demo purposes, we're just showing the notification
|
||||
// In real implementation, you would compare current version with latest version
|
||||
setShowNewVersionInfo(false);
|
||||
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(() => {
|
||||
@@ -99,7 +104,6 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
|
||||
>
|
||||
<Info className="h-4 w-4 mr-2" />
|
||||
{t('about.title')}
|
||||
({version})
|
||||
{showNewVersionInfo && (
|
||||
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
|
||||
49
frontend/src/components/ui/WeChatDialog.tsx
Normal 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
|
||||
import Toast, { ToastType } from '@/components/ui/Toast';
|
||||
|
||||
interface ToastContextProps {
|
||||
@@ -32,18 +32,18 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
const showToast = (message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
setToast({
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
duration,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hideToast = () => {
|
||||
const hideToast = useCallback(() => {
|
||||
setToast((prev) => ({ ...prev, visible: false }));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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();
|
||||
@@ -15,7 +16,7 @@ export const useMarketData = () => {
|
||||
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);
|
||||
@@ -26,18 +27,18 @@ export const useMarketData = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/market/servers', {
|
||||
const response = await fetch(getApiUrl('/market/servers'), {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
'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
|
||||
@@ -55,44 +56,50 @@ export const useMarketData = () => {
|
||||
}, [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);
|
||||
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);
|
||||
}
|
||||
// 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]);
|
||||
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]);
|
||||
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('/api/market/categories', {
|
||||
const response = await fetch(getApiUrl('/market/categories'), {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
'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 {
|
||||
@@ -107,18 +114,18 @@ export const useMarketData = () => {
|
||||
const fetchTags = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/market/tags', {
|
||||
const response = await fetch(getApiUrl('/market/tags'), {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
}
|
||||
'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 {
|
||||
@@ -130,178 +137,196 @@ export const useMarketData = () => {
|
||||
}, []);
|
||||
|
||||
// Fetch server by name
|
||||
const fetchServerByName = useCallback(async (name: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch(`/api/market/servers/${name}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching server ${name}:`, err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[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(`/api/market/servers/search?query=${encodeURIComponent(query)}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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]);
|
||||
},
|
||||
[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(`/api/market/categories/${encodeURIComponent(category)}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
const filterByCategory = useCallback(
|
||||
async (category: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSelectedCategory(category);
|
||||
setSelectedTag(''); // Reset tag filter when filtering by category
|
||||
|
||||
if (!category) {
|
||||
fetchMarketServers();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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]);
|
||||
},
|
||||
[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(`/api/market/tags/${encodeURIComponent(tag)}`, {
|
||||
headers: {
|
||||
'x-auth-token': token || ''
|
||||
const filterByTag = useCallback(
|
||||
async (tag: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setSelectedTag(tag);
|
||||
setSelectedCategory(''); // Reset category filter when filtering by tag
|
||||
|
||||
if (!tag) {
|
||||
fetchMarketServers();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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]);
|
||||
},
|
||||
[t, fetchMarketServers, applyPagination],
|
||||
);
|
||||
|
||||
// Fetch installed servers
|
||||
const fetchInstalledServers = useCallback(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 || '',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
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);
|
||||
@@ -313,64 +338,77 @@ export const useMarketData = () => {
|
||||
}, []);
|
||||
|
||||
// Check if a server is already installed
|
||||
const isServerInstalled = useCallback((serverName: string) => {
|
||||
return installedServers.includes(serverName);
|
||||
}, [installedServers]);
|
||||
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'));
|
||||
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;
|
||||
}
|
||||
|
||||
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('/api/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]);
|
||||
},
|
||||
[t, fetchInstalledServers],
|
||||
);
|
||||
|
||||
// Change servers per page
|
||||
const changeServersPerPage = useCallback((perPage: number) => {
|
||||
setServersPerPage(perPage);
|
||||
setCurrentPage(1);
|
||||
applyPagination(allServers, 1, perPage);
|
||||
}, [allServers, applyPagination]);
|
||||
const changeServersPerPage = useCallback(
|
||||
(perPage: number) => {
|
||||
setServersPerPage(perPage);
|
||||
setCurrentPage(1);
|
||||
applyPagination(allServers, 1, perPage);
|
||||
},
|
||||
[allServers, applyPagination],
|
||||
);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
@@ -405,6 +443,6 @@ export const useMarketData = () => {
|
||||
changePage,
|
||||
changeServersPerPage,
|
||||
// Installed servers methods
|
||||
isServerInstalled
|
||||
isServerInstalled,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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, // Maximum number of attempts during initialization
|
||||
pollingInterval: 3000 // Polling interval during initialization (3 seconds)
|
||||
maxAttempts: 60, // Maximum number of attempts during initialization
|
||||
pollingInterval: 3000, // Polling interval during initialization (3 seconds)
|
||||
},
|
||||
// Normal operation phase configuration
|
||||
normal: {
|
||||
pollingInterval: 10000 // Polling interval during normal operation (10 seconds)
|
||||
}
|
||||
pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
|
||||
},
|
||||
};
|
||||
|
||||
export const useServerData = () => {
|
||||
@@ -22,7 +23,7 @@ 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
|
||||
@@ -40,17 +41,17 @@ export const useServerData = () => {
|
||||
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,29 +60,29 @@ 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]);
|
||||
@@ -92,18 +93,18 @@ export const useServerData = () => {
|
||||
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();
|
||||
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
@@ -131,17 +132,17 @@ export const useServerData = () => {
|
||||
// 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');
|
||||
@@ -151,19 +152,19 @@ export const useServerData = () => {
|
||||
// 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`);
|
||||
@@ -171,7 +172,7 @@ export const useServerData = () => {
|
||||
// Initialization completed, start normal polling
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimer();
|
||||
@@ -182,35 +183,35 @@ export const useServerData = () => {
|
||||
const triggerRefresh = () => {
|
||||
// Clear current timer
|
||||
clearTimer();
|
||||
|
||||
|
||||
// If in initialization phase, reset initialization state
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
|
||||
// Change in refreshKey will trigger useEffect to run again
|
||||
setRefreshKey(prevKey => prevKey + 1);
|
||||
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,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,24 +2,41 @@ 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();
|
||||
@@ -27,10 +44,25 @@ export const useSettingsData = () => {
|
||||
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);
|
||||
@@ -49,7 +81,7 @@ export const useSettingsData = () => {
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/settings', {
|
||||
const response = await fetch(getApiUrl('/settings'), {
|
||||
headers: {
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
@@ -65,31 +97,47 @@ export const useSettingsData = () => {
|
||||
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]);
|
||||
}, [t]); // 移除 showToast 依赖
|
||||
|
||||
// Update routing configuration
|
||||
const updateRoutingConfig = async (key: keyof RoutingConfig, value: boolean) => {
|
||||
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('/api/system-config', {
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -116,13 +164,13 @@ export const useSettingsData = () => {
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
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'));
|
||||
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);
|
||||
@@ -136,7 +184,7 @@ export const useSettingsData = () => {
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/system-config', {
|
||||
const response = await fetch(getApiUrl('/system-config'), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -176,14 +224,126 @@ export const useSettingsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
@@ -191,5 +351,7 @@ export const useSettingsData = () => {
|
||||
fetchSettings,
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig,
|
||||
updateSmartRoutingConfig,
|
||||
updateSmartRoutingConfigBatch,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,12 +16,34 @@
|
||||
"title": "About",
|
||||
"versionInfo": "MCP Hub Version: {{version}}",
|
||||
"newVersion": "New version available!",
|
||||
"currentVersion": "Current version"
|
||||
"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",
|
||||
@@ -103,7 +125,8 @@
|
||||
"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"
|
||||
"failedToUpdateRouteConfig": "Failed to update route configuration",
|
||||
"failedToUpdateSmartRoutingConfig": "Failed to update smart routing configuration"
|
||||
},
|
||||
"common": {
|
||||
"processing": "Processing...",
|
||||
@@ -130,10 +153,10 @@
|
||||
"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": {
|
||||
@@ -148,8 +171,9 @@
|
||||
"account": "Account Settings",
|
||||
"password": "Change Password",
|
||||
"appearance": "Appearance",
|
||||
"routeConfig": "Route Configuration",
|
||||
"installConfig": "Installation Configuration"
|
||||
"routeConfig": "Security",
|
||||
"installConfig": "Installation",
|
||||
"smartRouting": "Smart Routing"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market - (Data from mcpm.sh)"
|
||||
@@ -244,10 +268,31 @@
|
||||
"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",
|
||||
"installConfig": "Installation Configuration",
|
||||
"systemConfigUpdated": "System configuration updated successfully"
|
||||
"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 (requires 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}}"
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,34 @@
|
||||
"title": "关于",
|
||||
"versionInfo": "MCP Hub 版本: {{version}}",
|
||||
"newVersion": "有新版本可用!",
|
||||
"currentVersion": "当前版本"
|
||||
"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": "浅色",
|
||||
@@ -104,7 +126,8 @@
|
||||
"serverInstall": "安装服务器失败",
|
||||
"failedToFetchSettings": "获取设置失败",
|
||||
"failedToUpdateSystemConfig": "更新系统配置失败",
|
||||
"failedToUpdateRouteConfig": "更新路由配置失败"
|
||||
"failedToUpdateRouteConfig": "更新路由配置失败",
|
||||
"failedToUpdateSmartRoutingConfig": "更新智能路由配置失败"
|
||||
},
|
||||
"common": {
|
||||
"processing": "处理中...",
|
||||
@@ -131,10 +154,10 @@
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
"totalServers": "服务器总数",
|
||||
"onlineServers": "在线服务器",
|
||||
"offlineServers": "离线服务器",
|
||||
"connectingServers": "连接中服务",
|
||||
"totalServers": "总数",
|
||||
"onlineServers": "在线",
|
||||
"offlineServers": "离线",
|
||||
"connectingServers": "连接中",
|
||||
"recentServers": "最近的服务器"
|
||||
},
|
||||
"servers": {
|
||||
@@ -146,8 +169,9 @@
|
||||
"account": "账户设置",
|
||||
"password": "修改密码",
|
||||
"appearance": "外观",
|
||||
"routeConfig": "路由配置",
|
||||
"installConfig": "安装配置"
|
||||
"routeConfig": "安全配置",
|
||||
"installConfig": "安装",
|
||||
"smartRouting": "智能路由"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
@@ -245,10 +269,32 @@
|
||||
"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": "系统配置更新成功"
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -10,16 +10,16 @@ import { useServerData } from '@/hooks/useServerData';
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
const {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
triggerRefresh
|
||||
triggerRefresh
|
||||
} = useServerData();
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
@@ -54,7 +54,7 @@ const ServersPage: React.FC = () => {
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => navigate('/market')}
|
||||
className="px-4 py-2 bg-emerald-100 text-emerald-800 rounded hover:bg-emerald-200 flex items-center"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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 1H3z" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -19,16 +20,35 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
});
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
tempRoutingConfig,
|
||||
setTempRoutingConfig,
|
||||
installConfig: savedInstallConfig,
|
||||
smartRoutingConfig,
|
||||
loading,
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig
|
||||
updateInstallConfig,
|
||||
updateSmartRoutingConfig,
|
||||
updateSmartRoutingConfigBatch
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
@@ -38,32 +58,118 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||
useEffect(() => {
|
||||
if (smartRoutingConfig) {
|
||||
setTempSmartRoutingConfig({
|
||||
dbUrl: smartRoutingConfig.dbUrl || '',
|
||||
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||
});
|
||||
}
|
||||
}, [smartRoutingConfig]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
installConfig: false,
|
||||
smartRoutingConfig: false,
|
||||
password: false
|
||||
});
|
||||
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'password') => {
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute', value: boolean) => {
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey', value: boolean | string) => {
|
||||
await updateRoutingConfig(key, value);
|
||||
|
||||
// If enableBearerAuth is turned on and there's no key, generate one
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey) {
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
await updateRoutingConfig('bearerAuthKey', newKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (value: string) => {
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig(prev => ({
|
||||
...prev,
|
||||
bearerAuthKey: value
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry', value: string) => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
pythonIndexUrl: value
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async () => {
|
||||
await updateInstallConfig('pythonIndexUrl', installConfig.pythonIndexUrl);
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry') => {
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
const handleSmartRoutingConfigChange = (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel', value: string) => {
|
||||
setTempSmartRoutingConfig({
|
||||
...tempSmartRoutingConfig,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveSmartRoutingConfig = async (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel') => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
|
||||
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||
if (value) {
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||
const currentOpenaiApiKey = tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = [];
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
|
||||
|
||||
showToast(t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', ')
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare updates object with unsaved changes and enabled status
|
||||
const updates: any = { enabled: value };
|
||||
|
||||
// Check for unsaved changes and include them in the batch update
|
||||
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiEmbeddingModel !== smartRoutingConfig.openaiApiEmbeddingModel) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Save all changes in a single batch update
|
||||
await updateSmartRoutingConfigBatch(updates);
|
||||
} else {
|
||||
// If disabling, just update the enabled status
|
||||
await updateSmartRoutingConfig('enabled', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
@@ -87,21 +193,19 @@ const SettingsPage: React.FC = () => {
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${
|
||||
currentLanguage.startsWith('en')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('en')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${
|
||||
currentLanguage.startsWith('zh')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('zh')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
>
|
||||
中文
|
||||
@@ -110,6 +214,131 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Routing Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('smartRoutingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.smartRoutingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={smartRoutingConfig.enabled}
|
||||
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.dbUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
|
||||
placeholder={t('settings.dbUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('dbUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="password"
|
||||
value={tempSmartRoutingConfig.openaiApiKey}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiKey', e.target.value)}
|
||||
placeholder={t('settings.openaiApiKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
||||
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
||||
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
@@ -124,6 +353,44 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableBearerAuthDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.bearerAuthKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
@@ -147,6 +414,7 @@ const SettingsPage: React.FC = () => {
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -174,13 +442,37 @@ const SettingsPage: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.pythonIndexUrl}
|
||||
onChange={(e) => handleInstallConfigChange(e.target.value)}
|
||||
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
|
||||
placeholder={t('settings.pythonIndexUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={saveInstallConfig}
|
||||
onClick={() => saveInstallConfig('pythonIndexUrl')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.npmRegistry')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.npmRegistryDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.npmRegistry}
|
||||
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
|
||||
placeholder={t('settings.npmRegistryPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveInstallConfig('npmRegistry')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
@@ -210,7 +502,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { AuthResponse, LoginCredentials, RegisterCredentials, ChangePasswordCredentials } from '../types';
|
||||
|
||||
// Base URL for API requests
|
||||
const API_URL = '';
|
||||
import {
|
||||
AuthResponse,
|
||||
LoginCredentials,
|
||||
RegisterCredentials,
|
||||
ChangePasswordCredentials,
|
||||
} from '../types';
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
|
||||
// Token key in localStorage
|
||||
const TOKEN_KEY = 'mcphub_token';
|
||||
@@ -24,7 +27,8 @@ export const removeToken = (): void => {
|
||||
// Login user
|
||||
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
console.log(getApiUrl('/auth/login'));
|
||||
const response = await fetch(getApiUrl('/auth/login'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -33,11 +37,11 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
|
||||
});
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
|
||||
|
||||
if (data.success && data.token) {
|
||||
setToken(data.token);
|
||||
}
|
||||
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
@@ -51,7 +55,7 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
|
||||
// Register user
|
||||
export const register = async (credentials: RegisterCredentials): Promise<AuthResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/register`, {
|
||||
const response = await fetch(getApiUrl('/auth/register'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -60,11 +64,11 @@ export const register = async (credentials: RegisterCredentials): Promise<AuthRe
|
||||
});
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
|
||||
|
||||
if (data.success && data.token) {
|
||||
setToken(data.token);
|
||||
}
|
||||
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
@@ -78,16 +82,16 @@ export const register = async (credentials: RegisterCredentials): Promise<AuthRe
|
||||
// Get current user
|
||||
export const getCurrentUser = async (): Promise<AuthResponse> => {
|
||||
const token = getToken();
|
||||
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No authentication token',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/user`, {
|
||||
const response = await fetch(getApiUrl('/auth/user'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-auth-token': token,
|
||||
@@ -105,18 +109,20 @@ export const getCurrentUser = async (): Promise<AuthResponse> => {
|
||||
};
|
||||
|
||||
// Change password
|
||||
export const changePassword = async (credentials: ChangePasswordCredentials): Promise<AuthResponse> => {
|
||||
export const changePassword = async (
|
||||
credentials: ChangePasswordCredentials,
|
||||
): Promise<AuthResponse> => {
|
||||
const token = getToken();
|
||||
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No authentication token',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/change-password`, {
|
||||
const response = await fetch(getApiUrl('/auth/change-password'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -138,4 +144,4 @@ export const changePassword = async (credentials: ChangePasswordCredentials): Pr
|
||||
// Logout user
|
||||
export const logout = (): void => {
|
||||
removeToken();
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getToken } from './authService'; // Import getToken function
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
@@ -18,18 +19,18 @@ export const fetchLogs = async (): Promise<LogEntry[]> => {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/logs', {
|
||||
const response = await fetch(getApiUrl('/logs'), {
|
||||
headers: {
|
||||
'x-auth-token': token
|
||||
}
|
||||
'x-auth-token': token,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch logs');
|
||||
}
|
||||
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
@@ -46,15 +47,15 @@ export const clearLogs = async (): Promise<void> => {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/logs', {
|
||||
const response = await fetch(getApiUrl('/logs'), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token
|
||||
}
|
||||
'x-auth-token': token,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to clear logs');
|
||||
}
|
||||
@@ -90,19 +91,19 @@ export const useLogs = () => {
|
||||
}
|
||||
|
||||
// Connect to SSE endpoint with auth token in URL
|
||||
eventSource = new EventSource(`/api/logs/stream?token=${token}`);
|
||||
eventSource = new EventSource(getApiUrl(`/logs/stream?token=${token}`));
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
if (!isMounted) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
|
||||
if (data.type === 'initial') {
|
||||
setLogs(data.logs);
|
||||
setLoading(false);
|
||||
} else if (data.type === 'log') {
|
||||
setLogs(prevLogs => [...prevLogs, data.log]);
|
||||
setLogs((prevLogs) => [...prevLogs, data.log]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing SSE message:', err);
|
||||
@@ -111,13 +112,13 @@ export const useLogs = () => {
|
||||
|
||||
eventSource.onerror = () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
// Attempt to reconnect after a delay
|
||||
setTimeout(connectToLogStream, 5000);
|
||||
}
|
||||
|
||||
|
||||
setError(new Error('Connection to log stream lost, attempting to reconnect...'));
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -149,4 +150,4 @@ export const useLogs = () => {
|
||||
};
|
||||
|
||||
return { logs, loading, error, clearLogs: clearAllLogs };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface Tool {
|
||||
|
||||
// Server config types
|
||||
export interface ServerConfig {
|
||||
type?: 'stdio' | 'sse' | 'streamable-http';
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
@@ -108,6 +109,8 @@ export interface ServerFormData {
|
||||
url: string;
|
||||
command: string;
|
||||
arguments: string;
|
||||
args?: string[]; // Added explicit args field
|
||||
type?: 'stdio' | 'sse' | 'streamable-http'; // Added type field
|
||||
env: EnvVar[];
|
||||
}
|
||||
|
||||
@@ -157,4 +160,4 @@ export interface AuthResponse {
|
||||
token?: string;
|
||||
user?: IUser;
|
||||
message?: string;
|
||||
}
|
||||
}
|
||||
|
||||
15
frontend/src/types/runtime.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Global runtime configuration interface
|
||||
export interface RuntimeConfig {
|
||||
basePath: string;
|
||||
version: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Extend Window interface to include runtime config
|
||||
declare global {
|
||||
interface Window {
|
||||
__MCPHUB_CONFIG__?: RuntimeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
28
frontend/src/utils/api.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* API utility functions for constructing URLs with proper base path support
|
||||
*
|
||||
* @deprecated Use functions from utils/runtime.ts instead for runtime configuration support
|
||||
*/
|
||||
|
||||
import { getApiBaseUrl as getRuntimeApiBaseUrl, getApiUrl as getRuntimeApiUrl } from './runtime';
|
||||
|
||||
/**
|
||||
* Get the API base URL including base path and /api prefix
|
||||
* @returns The complete API base URL
|
||||
* @deprecated Use getApiBaseUrl from utils/runtime.ts instead
|
||||
*/
|
||||
export const getApiBaseUrl = (): string => {
|
||||
console.warn('getApiBaseUrl from utils/api.ts is deprecated, use utils/runtime.ts instead');
|
||||
return getRuntimeApiBaseUrl();
|
||||
};
|
||||
|
||||
/**
|
||||
* Construct a full API URL with the given endpoint
|
||||
* @param endpoint - The API endpoint (should start with /, e.g., '/auth/login')
|
||||
* @returns The complete API URL
|
||||
* @deprecated Use getApiUrl from utils/runtime.ts instead
|
||||
*/
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
console.warn('getApiUrl from utils/api.ts is deprecated, use utils/runtime.ts instead');
|
||||
return getRuntimeApiUrl(endpoint);
|
||||
};
|
||||
8
frontend/src/utils/key.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function generateRandomKey(length: number = 32): string {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((x) => characters.charAt(x % characters.length))
|
||||
.join('');
|
||||
}
|
||||
105
frontend/src/utils/runtime.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { RuntimeConfig } from '../types/runtime';
|
||||
|
||||
/**
|
||||
* Get runtime configuration from window object
|
||||
*/
|
||||
export const getRuntimeConfig = (): RuntimeConfig => {
|
||||
return (
|
||||
window.__MCPHUB_CONFIG__ || {
|
||||
basePath: '',
|
||||
version: 'dev',
|
||||
name: 'mcphub',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the base path from runtime configuration
|
||||
*/
|
||||
export const getBasePath = (): string => {
|
||||
const config = getRuntimeConfig();
|
||||
const basePath = config.basePath || '';
|
||||
|
||||
// Ensure the path starts with / if it's not empty and doesn't already start with /
|
||||
if (basePath && !basePath.startsWith('/')) {
|
||||
return '/' + basePath;
|
||||
}
|
||||
return basePath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the API base URL including base path and /api prefix
|
||||
*/
|
||||
export const getApiBaseUrl = (): string => {
|
||||
const basePath = getBasePath();
|
||||
// Always append /api to the base path for API endpoints
|
||||
return basePath + '/api';
|
||||
};
|
||||
|
||||
/**
|
||||
* Construct a full API URL with the given endpoint
|
||||
*/
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
// Ensure endpoint starts with /
|
||||
const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : '/' + endpoint;
|
||||
return baseUrl + normalizedEndpoint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load runtime configuration from server
|
||||
*/
|
||||
export const loadRuntimeConfig = async (): Promise<RuntimeConfig> => {
|
||||
try {
|
||||
// For initial config load, we need to determine the correct path
|
||||
// Try different possible paths based on current location
|
||||
const currentPath = window.location.pathname;
|
||||
const possibleConfigPaths = [
|
||||
// If we're already on a subpath, try to use it
|
||||
currentPath.replace(/\/[^\/]*$/, '') + '/config',
|
||||
// Try root config
|
||||
'/config',
|
||||
// Try with potential base paths
|
||||
...(currentPath.includes('/')
|
||||
? [currentPath.split('/')[1] ? `/${currentPath.split('/')[1]}/config` : '/config']
|
||||
: ['/config']),
|
||||
];
|
||||
|
||||
for (const configPath of possibleConfigPaths) {
|
||||
try {
|
||||
const response = await fetch(configPath, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue to next path
|
||||
console.debug(`Failed to load config from ${configPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default config
|
||||
console.warn('Could not load runtime config from server, using defaults');
|
||||
return {
|
||||
basePath: '',
|
||||
version: 'dev',
|
||||
name: 'mcphub',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading runtime config:', error);
|
||||
return {
|
||||
basePath: '',
|
||||
version: 'dev',
|
||||
name: 'mcphub',
|
||||
};
|
||||
}
|
||||
};
|
||||
32
frontend/src/utils/version.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
||||
const PACKAGE_NAME = '@samanhappy/mcphub';
|
||||
|
||||
export const checkLatestVersion = async (): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch(`${NPM_REGISTRY}/${PACKAGE_NAME}/latest`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch latest version: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.version || null;
|
||||
} catch (error) {
|
||||
console.error('Error checking for latest version:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const compareVersions = (current: string, latest: string): number => {
|
||||
if (current === 'dev') return -1;
|
||||
const currentParts = current.split('.').map(Number);
|
||||
const latestParts = latest.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const currentPart = currentParts[i] || 0;
|
||||
const latestPart = latestParts[i] || 0;
|
||||
|
||||
if (currentPart > latestPart) return -1;
|
||||
if (currentPart < latestPart) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
@@ -6,12 +6,15 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// Get package.json version
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8')
|
||||
);
|
||||
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'));
|
||||
|
||||
// For runtime configuration, we'll always use relative paths
|
||||
// BASE_PATH will be determined at runtime
|
||||
const basePath = '';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: './', // Always use relative paths for runtime configuration
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -20,15 +23,19 @@ export default defineConfig({
|
||||
},
|
||||
define: {
|
||||
// Make package version available as global variable
|
||||
// BASE_PATH will be loaded at runtime
|
||||
'import.meta.env.PACKAGE_VERSION': JSON.stringify(packageJson.version),
|
||||
},
|
||||
build: {
|
||||
sourcemap: true, // Enable source maps for production build
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
[`${basePath}/api`]: {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth': {
|
||||
[`${basePath}/auth`]: {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
|
||||
72
nginx.conf.example
Normal file
@@ -0,0 +1,72 @@
|
||||
# Nginx configuration example for MCPHub with subpath routing
|
||||
# This example shows how to deploy MCPHub under a subpath like /mcphub
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# MCPHub under /mcphub subpath
|
||||
location /mcphub/ {
|
||||
# Remove the subpath prefix before forwarding to MCPHub
|
||||
rewrite ^/mcphub/(.*)$ /$1 break;
|
||||
|
||||
proxy_pass http://mcphub:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Important: Disable buffering for SSE connections
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# Support for Server-Sent Events (SSE)
|
||||
proxy_read_timeout 24h;
|
||||
proxy_send_timeout 24h;
|
||||
}
|
||||
|
||||
# Alternative configuration if you want to keep the subpath in the backend
|
||||
# In this case, set BASE_PATH=/mcphub
|
||||
# location /mcphub/ {
|
||||
# proxy_pass http://mcphub:3000/mcphub/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection 'upgrade';
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# proxy_cache_bypass $http_upgrade;
|
||||
#
|
||||
# # Important: Disable buffering for SSE connections
|
||||
# proxy_buffering off;
|
||||
# proxy_cache off;
|
||||
#
|
||||
# # Support for Server-Sent Events (SSE)
|
||||
# proxy_read_timeout 24h;
|
||||
# proxy_send_timeout 24h;
|
||||
# }
|
||||
}
|
||||
|
||||
# Docker Compose example with subpath
|
||||
# version: '3.8'
|
||||
# services:
|
||||
# mcphub:
|
||||
# image: samanhappy/mcphub
|
||||
# environment:
|
||||
# - BASE_PATH=/mcphub
|
||||
# volumes:
|
||||
# - ./mcp_settings.json:/app/mcp_settings.json
|
||||
#
|
||||
# nginx:
|
||||
# image: nginx:alpine
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# volumes:
|
||||
# - ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
# depends_on:
|
||||
# - mcphub
|
||||
30
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@samanhappy/mcphub",
|
||||
"version": "0.5.4",
|
||||
"version": "dev",
|
||||
"description": "A hub server for mcp servers",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -21,6 +21,7 @@
|
||||
"backend:build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"backend:dev": "tsx watch src/index.ts",
|
||||
"backend:debug": "tsx watch src/index.ts --inspect",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"test": "jest",
|
||||
@@ -28,6 +29,7 @@
|
||||
"frontend:build": "cd frontend && vite build",
|
||||
"frontend:preview": "cd frontend && vite preview",
|
||||
"dev": "concurrently \"pnpm backend:dev\" \"pnpm frontend:dev\"",
|
||||
"debug": "concurrently \"pnpm backend:debug\" \"pnpm frontend:dev\"",
|
||||
"prepublishOnly": "npm run build && node scripts/verify-dist.js"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -39,12 +41,20 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.2",
|
||||
"@modelcontextprotocol/sdk": "^1.11.1",
|
||||
"@types/pg": "^8.15.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"openai": "^4.103.0",
|
||||
"pg": "^8.16.0",
|
||||
"pgvector": "^0.2.1",
|
||||
"postgres": "^3.4.7",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.24",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -52,18 +62,18 @@
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^20.8.2",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -87,11 +97,11 @@
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.4.18",
|
||||
"vite": "^6.3.5",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha256.fa0f513aa8191764d2b6b432420788c270f07b4f999099b65bb2010eec702a30"
|
||||
}
|
||||
"packageManager": "pnpm@10.11.0+sha256.a69e9cb077da419d47d18f1dd52e207245b29cac6e076acedbeb8be3b1a67bd7"
|
||||
}
|
||||
2186
pnpm-lock.yaml
generated
@@ -2,6 +2,7 @@ import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import { McpSettings } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -9,8 +10,9 @@ const defaultConfig = {
|
||||
port: process.env.PORT || 3000,
|
||||
initTimeout: process.env.INIT_TIMEOUT || 300000,
|
||||
timeout: process.env.REQUEST_TIMEOUT || 60000,
|
||||
basePath: process.env.BASE_PATH || '',
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: '0.0.1',
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
};
|
||||
|
||||
export const getSettingsPath = (): string => {
|
||||
@@ -39,6 +41,14 @@ export const saveSettings = (settings: McpSettings): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
export const replaceEnvVars = (env: Record<string, any>): Record<string, any> => {
|
||||
const res: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
res[key] = expandEnvVars(value);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
export const expandEnvVars = (value: string): string => {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
|
||||
};
|
||||
|
||||
30
src/controllers/configController.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Request, Response } from 'express';
|
||||
import config from '../config/index.js';
|
||||
|
||||
/**
|
||||
* Get runtime configuration for frontend
|
||||
*/
|
||||
export const getRuntimeConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const runtimeConfig = {
|
||||
basePath: config.basePath,
|
||||
version: config.mcpHubVersion,
|
||||
name: config.mcpHubName,
|
||||
};
|
||||
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: runtimeConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting runtime config:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get runtime configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
toggleServerStatus,
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||
|
||||
export const getAllServers = (_: Request, res: Response): void => {
|
||||
try {
|
||||
@@ -69,6 +70,24 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the server type if specified
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http'].includes(config.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that URL is provided for sse and streamable-http types
|
||||
if ((config.type === 'sse' || config.type === 'streamable-http') && !config.url) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `URL is required for ${config.type} server type`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await addServer(name, config);
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
@@ -150,6 +169,24 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the server type if specified
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http'].includes(config.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that URL is provided for sse and streamable-http types
|
||||
if ((config.type === 'sse' || config.type === 'streamable-http') && !config.url) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `URL is required for ${config.type} server type`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await updateMcpServer(name, config);
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
@@ -247,65 +284,177 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing, install } = req.body;
|
||||
|
||||
if ((!routing || (typeof routing.enableGlobalRoute !== 'boolean' && typeof routing.enableGroupNameRoute !== 'boolean'))
|
||||
&& (!install || typeof install.pythonIndexUrl !== 'string')) {
|
||||
const { routing, install, smartRouting } = req.body;
|
||||
|
||||
if (
|
||||
(!routing ||
|
||||
(typeof routing.enableGlobalRoute !== 'boolean' &&
|
||||
typeof routing.enableGroupNameRoute !== 'boolean' &&
|
||||
typeof routing.enableBearerAuth !== 'boolean' &&
|
||||
typeof routing.bearerAuthKey !== 'string')) &&
|
||||
(!install ||
|
||||
(typeof install.pythonIndexUrl !== 'string' && typeof install.npmRegistry !== 'string')) &&
|
||||
(!smartRouting ||
|
||||
(typeof smartRouting.enabled !== 'boolean' &&
|
||||
typeof smartRouting.dbUrl !== 'string' &&
|
||||
typeof smartRouting.openaiApiBaseUrl !== 'string' &&
|
||||
typeof smartRouting.openaiApiKey !== 'string' &&
|
||||
typeof smartRouting.openaiApiEmbeddingModel !== 'string'))
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid system configuration provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.systemConfig) {
|
||||
settings.systemConfig = {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
install: {
|
||||
pythonIndexUrl: ''
|
||||
}
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
},
|
||||
smartRouting: {
|
||||
enabled: false,
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (!settings.systemConfig.routing) {
|
||||
settings.systemConfig.routing = {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (!settings.systemConfig.install) {
|
||||
settings.systemConfig.install = {
|
||||
pythonIndexUrl: ''
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (!settings.systemConfig.smartRouting) {
|
||||
settings.systemConfig.smartRouting = {
|
||||
enabled: false,
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (routing) {
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
}
|
||||
|
||||
|
||||
if (typeof routing.enableGroupNameRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableBearerAuth === 'boolean') {
|
||||
settings.systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
|
||||
}
|
||||
|
||||
if (typeof routing.bearerAuthKey === 'string') {
|
||||
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (install) {
|
||||
if (typeof install.pythonIndexUrl === 'string') {
|
||||
settings.systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
|
||||
}
|
||||
if (typeof install.npmRegistry === 'string') {
|
||||
settings.systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Track smartRouting state and configuration changes
|
||||
const wasSmartRoutingEnabled = settings.systemConfig.smartRouting.enabled || false;
|
||||
const previousSmartRoutingConfig = { ...settings.systemConfig.smartRouting };
|
||||
let needsSync = false;
|
||||
|
||||
if (smartRouting) {
|
||||
if (typeof smartRouting.enabled === 'boolean') {
|
||||
// If enabling Smart Routing, validate required fields
|
||||
if (smartRouting.enabled) {
|
||||
const currentDbUrl = smartRouting.dbUrl || settings.systemConfig.smartRouting.dbUrl;
|
||||
const currentOpenaiApiKey =
|
||||
smartRouting.openaiApiKey || settings.systemConfig.smartRouting.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = [];
|
||||
if (!currentDbUrl) missingFields.push('Database URL');
|
||||
if (!currentOpenaiApiKey) missingFields.push('OpenAI API Key');
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `Smart Routing requires the following fields: ${missingFields.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
settings.systemConfig.smartRouting.enabled = smartRouting.enabled;
|
||||
}
|
||||
if (typeof smartRouting.dbUrl === 'string') {
|
||||
settings.systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiBaseUrl === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiKey === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiEmbeddingModel === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiEmbeddingModel =
|
||||
smartRouting.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Check if we need to sync embeddings
|
||||
const isNowEnabled = settings.systemConfig.smartRouting.enabled || false;
|
||||
const hasConfigChanged =
|
||||
previousSmartRoutingConfig.dbUrl !== settings.systemConfig.smartRouting.dbUrl ||
|
||||
previousSmartRoutingConfig.openaiApiBaseUrl !==
|
||||
settings.systemConfig.smartRouting.openaiApiBaseUrl ||
|
||||
previousSmartRoutingConfig.openaiApiKey !==
|
||||
settings.systemConfig.smartRouting.openaiApiKey ||
|
||||
previousSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
settings.systemConfig.smartRouting.openaiApiEmbeddingModel;
|
||||
|
||||
// Sync if: first time enabling OR smart routing is enabled and any config changed
|
||||
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
|
||||
}
|
||||
|
||||
if (saveSettings(settings)) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings.systemConfig,
|
||||
message: 'System configuration updated successfully',
|
||||
});
|
||||
|
||||
// If smart routing configuration changed, sync all existing server tools
|
||||
if (needsSync) {
|
||||
console.log('SmartRouting configuration changed - syncing all existing server tools...');
|
||||
// Run sync asynchronously to avoid blocking the response
|
||||
syncAllServerToolsEmbeddings().catch((error) => {
|
||||
console.error('Failed to sync server tools embeddings:', error);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
|
||||
331
src/db/connection.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import 'reflect-metadata'; // Ensure reflect-metadata is imported here too
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import dotenv from 'dotenv';
|
||||
import dotenvExpand from 'dotenv-expand';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import entities from './entities/index.js';
|
||||
import { registerPostgresVectorType } from './types/postgresVectorType.js';
|
||||
import { VectorEmbeddingSubscriber } from './subscribers/VectorEmbeddingSubscriber.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
// Helper function to create required PostgreSQL extensions
|
||||
const createRequiredExtensions = async (dataSource: DataSource): Promise<void> => {
|
||||
try {
|
||||
await dataSource.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";');
|
||||
console.log('UUID extension created or already exists.');
|
||||
} catch (err: any) {
|
||||
console.warn('Failed to create uuid-ossp extension:', err.message);
|
||||
console.warn('UUID generation functionality may not be available.');
|
||||
}
|
||||
|
||||
try {
|
||||
await dataSource.query('CREATE EXTENSION IF NOT EXISTS vector;');
|
||||
console.log('Vector extension created or already exists.');
|
||||
} catch (err: any) {
|
||||
console.warn('Failed to create vector extension:', err.message);
|
||||
console.warn('Vector functionality may not be available.');
|
||||
}
|
||||
};
|
||||
|
||||
// Get database URL from smart routing config or fallback to environment variable
|
||||
const getDatabaseUrl = (): string => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const smartRouting = settings.systemConfig?.smartRouting;
|
||||
|
||||
// Use smart routing dbUrl if smart routing is enabled and dbUrl is configured
|
||||
if (smartRouting?.enabled && smartRouting?.dbUrl) {
|
||||
console.log('Using smart routing database URL');
|
||||
return smartRouting.dbUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to load settings for smart routing database URL, falling back to environment variable:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// Default database configuration
|
||||
const defaultConfig: DataSourceOptions = {
|
||||
type: 'postgres',
|
||||
url: getDatabaseUrl(),
|
||||
synchronize: true,
|
||||
entities: entities,
|
||||
subscribers: [VectorEmbeddingSubscriber],
|
||||
};
|
||||
|
||||
// AppDataSource is the TypeORM data source
|
||||
let AppDataSource = new DataSource(defaultConfig);
|
||||
|
||||
// Function to create a new DataSource with updated configuration
|
||||
export const updateDataSourceConfig = (): DataSource => {
|
||||
const newConfig: DataSourceOptions = {
|
||||
...defaultConfig,
|
||||
url: getDatabaseUrl(),
|
||||
};
|
||||
|
||||
// If the configuration has changed, we need to create a new DataSource
|
||||
const currentUrl = (AppDataSource.options as any).url;
|
||||
if (currentUrl !== newConfig.url) {
|
||||
console.log('Database URL configuration changed, updating DataSource...');
|
||||
AppDataSource = new DataSource(newConfig);
|
||||
}
|
||||
|
||||
return AppDataSource;
|
||||
};
|
||||
|
||||
// Get the current AppDataSource instance
|
||||
export const getAppDataSource = (): DataSource => {
|
||||
return AppDataSource;
|
||||
};
|
||||
|
||||
// Reconnect database with updated configuration
|
||||
export const reconnectDatabase = async (): Promise<DataSource> => {
|
||||
try {
|
||||
// Close existing connection if it exists
|
||||
if (AppDataSource.isInitialized) {
|
||||
console.log('Closing existing database connection...');
|
||||
await AppDataSource.destroy();
|
||||
}
|
||||
|
||||
// Update configuration and reconnect
|
||||
AppDataSource = updateDataSourceConfig();
|
||||
return await initializeDatabase();
|
||||
} catch (error) {
|
||||
console.error('Error during database reconnection:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize database connection
|
||||
export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
try {
|
||||
// Update configuration before initializing
|
||||
AppDataSource = updateDataSourceConfig();
|
||||
|
||||
if (!AppDataSource.isInitialized) {
|
||||
console.log('Initializing database connection...');
|
||||
// Register the vector type with TypeORM
|
||||
await AppDataSource.initialize();
|
||||
registerPostgresVectorType(AppDataSource);
|
||||
|
||||
// Create required PostgreSQL extensions
|
||||
await createRequiredExtensions(AppDataSource);
|
||||
|
||||
// Set up vector column and index with a more direct approach
|
||||
try {
|
||||
|
||||
// Check if table exists first
|
||||
const tableExists = await AppDataSource.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'vector_embeddings'
|
||||
);
|
||||
`);
|
||||
|
||||
if (tableExists[0].exists) {
|
||||
// Add pgvector support via raw SQL commands
|
||||
console.log('Configuring vector support for embeddings table...');
|
||||
|
||||
// Step 1: Drop any existing index on the column
|
||||
try {
|
||||
await AppDataSource.query(`DROP INDEX IF EXISTS idx_vector_embeddings_embedding;`);
|
||||
} catch (dropError: any) {
|
||||
console.warn('Note: Could not drop existing index:', dropError.message);
|
||||
}
|
||||
|
||||
// Step 2: Alter column type to vector (if it's not already)
|
||||
try {
|
||||
// Check column type first
|
||||
const columnType = await AppDataSource.query(`
|
||||
SELECT data_type FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'vector_embeddings'
|
||||
AND column_name = 'embedding';
|
||||
`);
|
||||
|
||||
if (columnType.length > 0 && columnType[0].data_type !== 'vector') {
|
||||
await AppDataSource.query(`
|
||||
ALTER TABLE vector_embeddings
|
||||
ALTER COLUMN embedding TYPE vector USING embedding::vector;
|
||||
`);
|
||||
console.log('Vector embedding column type updated successfully.');
|
||||
}
|
||||
} catch (alterError: any) {
|
||||
console.warn('Could not alter embedding column type:', alterError.message);
|
||||
console.warn('Will try to recreate the table later.');
|
||||
}
|
||||
|
||||
// Step 3: Try to create appropriate indices
|
||||
try {
|
||||
// First, let's check if there are any records to determine the dimensions
|
||||
const records = await AppDataSource.query(`
|
||||
SELECT dimensions FROM vector_embeddings LIMIT 1;
|
||||
`);
|
||||
|
||||
let dimensions = 1536; // Default to common OpenAI embedding size
|
||||
if (records && records.length > 0 && records[0].dimensions) {
|
||||
dimensions = records[0].dimensions;
|
||||
console.log(`Found vector dimension from existing data: ${dimensions}`);
|
||||
} else {
|
||||
console.log(`Using default vector dimension: ${dimensions} (no existing data found)`);
|
||||
}
|
||||
|
||||
// Set the vector dimensions explicitly only if table has data
|
||||
if (records && records.length > 0) {
|
||||
await AppDataSource.query(`
|
||||
ALTER TABLE vector_embeddings
|
||||
ALTER COLUMN embedding TYPE vector(${dimensions});
|
||||
`);
|
||||
|
||||
// Now try to create the index
|
||||
await AppDataSource.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
|
||||
ON vector_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
`);
|
||||
console.log('Created IVFFlat index for vector similarity search.');
|
||||
} else {
|
||||
console.log(
|
||||
'No existing vector data found, skipping index creation - will be handled by vector service.',
|
||||
);
|
||||
}
|
||||
} catch (indexError: any) {
|
||||
console.warn('IVFFlat index creation failed:', indexError.message);
|
||||
console.warn('Trying alternative index type...');
|
||||
|
||||
try {
|
||||
// Try HNSW index instead
|
||||
await AppDataSource.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
|
||||
ON vector_embeddings USING hnsw (embedding vector_cosine_ops);
|
||||
`);
|
||||
console.log('Created HNSW index for vector similarity search.');
|
||||
} catch (hnswError: any) {
|
||||
// Final fallback to simpler index type
|
||||
console.warn('HNSW index creation failed too. Using simple L2 distance index.');
|
||||
|
||||
try {
|
||||
// Create a basic GIN index as last resort
|
||||
await AppDataSource.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
|
||||
ON vector_embeddings USING gin (embedding);
|
||||
`);
|
||||
console.log('Created GIN index for basic vector lookups.');
|
||||
} catch (ginError: any) {
|
||||
console.warn('All index creation attempts failed:', ginError.message);
|
||||
console.warn('Vector search will be slower without an optimized index.');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'Vector embeddings table does not exist yet - will configure after schema sync.',
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn('Could not set up vector column/index:', error.message);
|
||||
console.warn('Will attempt again after schema synchronization.');
|
||||
}
|
||||
|
||||
console.log('Database connection established successfully.');
|
||||
|
||||
// Run one final setup check after schema synchronization is done
|
||||
if (defaultConfig.synchronize) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
console.log('Running final vector configuration check...');
|
||||
|
||||
// Try setup again with the same code from above
|
||||
const tableExists = await AppDataSource.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'vector_embeddings'
|
||||
);
|
||||
`);
|
||||
|
||||
if (tableExists[0].exists) {
|
||||
console.log('Vector embeddings table found, checking configuration...');
|
||||
|
||||
// Get the dimension size first
|
||||
try {
|
||||
// Try to get dimensions from an existing record
|
||||
const records = await AppDataSource.query(`
|
||||
SELECT dimensions FROM vector_embeddings LIMIT 1;
|
||||
`);
|
||||
|
||||
// Only proceed if we have existing data, otherwise let vector service handle it
|
||||
if (records && records.length > 0 && records[0].dimensions) {
|
||||
const dimensions = records[0].dimensions;
|
||||
console.log(`Found vector dimension from database: ${dimensions}`);
|
||||
|
||||
// Ensure column type is vector with explicit dimensions
|
||||
await AppDataSource.query(`
|
||||
ALTER TABLE vector_embeddings
|
||||
ALTER COLUMN embedding TYPE vector(${dimensions});
|
||||
`);
|
||||
console.log('Vector embedding column type updated in final check.');
|
||||
|
||||
// One more attempt at creating the index with dimensions
|
||||
try {
|
||||
// Drop existing index if any
|
||||
await AppDataSource.query(`
|
||||
DROP INDEX IF EXISTS idx_vector_embeddings_embedding;
|
||||
`);
|
||||
|
||||
// Create new index with proper dimensions
|
||||
await AppDataSource.query(`
|
||||
CREATE INDEX idx_vector_embeddings_embedding
|
||||
ON vector_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
`);
|
||||
console.log('Created IVFFlat index in final check.');
|
||||
} catch (indexError: any) {
|
||||
console.warn(
|
||||
'Final index creation attempt did not succeed:',
|
||||
indexError.message,
|
||||
);
|
||||
console.warn('Using basic lookup without vector index.');
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'No existing vector data found, vector dimensions will be configured by vector service.',
|
||||
);
|
||||
}
|
||||
} catch (setupError: any) {
|
||||
console.warn('Vector setup in final check failed:', setupError.message);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn('Post-initialization vector setup failed:', error.message);
|
||||
}
|
||||
}, 3000); // Give synchronize some time to complete
|
||||
}
|
||||
}
|
||||
return AppDataSource;
|
||||
} catch (error) {
|
||||
console.error('Error during database initialization:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Get database connection status
|
||||
export const isDatabaseConnected = (): boolean => {
|
||||
return AppDataSource.isInitialized;
|
||||
};
|
||||
|
||||
// Close database connection
|
||||
export const closeDatabase = async (): Promise<void> => {
|
||||
if (AppDataSource.isInitialized) {
|
||||
await AppDataSource.destroy();
|
||||
console.log('Database connection closed.');
|
||||
}
|
||||
};
|
||||
|
||||
// Export AppDataSource for backward compatibility
|
||||
export { AppDataSource };
|
||||
|
||||
export default getAppDataSource;
|
||||
46
src/db/entities/VectorEmbedding.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'vector_embeddings' })
|
||||
export class VectorEmbedding {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
content_type: string; // 'market_server', 'tool', 'documentation', etc.
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
content_id: string; // Reference ID to the original content
|
||||
|
||||
@Column('text')
|
||||
text_content: string; // The text that was embedded
|
||||
|
||||
@Column('simple-json')
|
||||
metadata: Record<string, any>; // Additional metadata about the embedding
|
||||
|
||||
@Column({
|
||||
type: 'float',
|
||||
array: true,
|
||||
nullable: true,
|
||||
})
|
||||
embedding: number[]; // The vector embedding - will be converted to vector type after table creation
|
||||
|
||||
@Column({ type: 'int' })
|
||||
dimensions: number; // Dimensionality of the embedding vector
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
model: string; // Model used to create the embedding
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default VectorEmbedding;
|
||||
7
src/db/entities/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { VectorEmbedding } from './VectorEmbedding.js';
|
||||
|
||||
// Export all entities
|
||||
export default [VectorEmbedding];
|
||||
|
||||
// Export individual entities for direct use
|
||||
export { VectorEmbedding };
|
||||
33
src/db/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { initializeDatabase, closeDatabase, isDatabaseConnected } from './connection.js';
|
||||
import * as repositories from './repositories/index.js';
|
||||
|
||||
/**
|
||||
* Initialize the database module
|
||||
*/
|
||||
export async function initializeDbModule(): Promise<boolean> {
|
||||
try {
|
||||
// Connect to the database
|
||||
await initializeDatabase();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize database module:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository factory for a database entity type
|
||||
* @param entityType The type of entity to get a repository for
|
||||
*/
|
||||
export function getRepositoryFactory(entityType: 'vectorEmbeddings') {
|
||||
// Return the appropriate repository based on entity type
|
||||
switch (entityType) {
|
||||
case 'vectorEmbeddings':
|
||||
return () => new repositories.VectorEmbeddingRepository();
|
||||
default:
|
||||
throw new Error(`Unknown entity type: ${entityType}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export everything from the database module
|
||||
export { initializeDatabase, closeDatabase, isDatabaseConnected, repositories };
|
||||
69
src/db/repositories/BaseRepository.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Repository, EntityTarget, ObjectLiteral } from 'typeorm';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Base repository class with common CRUD operations
|
||||
*/
|
||||
export class BaseRepository<T extends ObjectLiteral> {
|
||||
protected readonly repository: Repository<T>;
|
||||
|
||||
constructor(entityClass: EntityTarget<T>) {
|
||||
this.repository = getAppDataSource().getRepository(entityClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository access
|
||||
*/
|
||||
getRepository(): Repository<T> {
|
||||
return this.repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all entities
|
||||
*/
|
||||
async findAll(): Promise<T[]> {
|
||||
return this.repository.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find entity by ID
|
||||
* @param id Entity ID
|
||||
*/
|
||||
async findById(id: string | number): Promise<T | null> {
|
||||
return this.repository.findOneBy({ id } as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update an entity
|
||||
* @param entity Entity to save
|
||||
*/
|
||||
async save(entity: Partial<T>): Promise<T> {
|
||||
return this.repository.save(entity as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save multiple entities
|
||||
* @param entities Array of entities to save
|
||||
*/
|
||||
async saveMany(entities: Partial<T>[]): Promise<T[]> {
|
||||
return this.repository.save(entities as any[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an entity by ID
|
||||
* @param id Entity ID
|
||||
*/
|
||||
async delete(id: string | number): Promise<boolean> {
|
||||
const result = await this.repository.delete(id);
|
||||
return result.affected !== null && result.affected !== undefined && result.affected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total entities
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return this.repository.count();
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseRepository;
|
||||
219
src/db/repositories/VectorEmbeddingRepository.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { VectorEmbedding } from '../entities/VectorEmbedding.js';
|
||||
import BaseRepository from './BaseRepository.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
export class VectorEmbeddingRepository extends BaseRepository<VectorEmbedding> {
|
||||
constructor() {
|
||||
super(VectorEmbedding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by content type and ID
|
||||
* @param contentType Content type
|
||||
* @param contentId Content ID
|
||||
*/
|
||||
async findByContentIdentity(
|
||||
contentType: string,
|
||||
contentId: string,
|
||||
): Promise<VectorEmbedding | null> {
|
||||
return this.repository.findOneBy({
|
||||
content_type: contentType,
|
||||
content_id: contentId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update an embedding for content
|
||||
* @param contentType Content type
|
||||
* @param contentId Content ID
|
||||
* @param textContent Text content to embed
|
||||
* @param embedding Vector embedding
|
||||
* @param metadata Additional metadata
|
||||
* @param model Model used to create the embedding
|
||||
*/
|
||||
async saveEmbedding(
|
||||
contentType: string,
|
||||
contentId: string,
|
||||
textContent: string,
|
||||
embedding: number[],
|
||||
metadata: Record<string, any> = {},
|
||||
model = 'default',
|
||||
): Promise<VectorEmbedding> {
|
||||
// Check if embedding exists
|
||||
let vectorEmbedding = await this.findByContentIdentity(contentType, contentId);
|
||||
|
||||
if (!vectorEmbedding) {
|
||||
vectorEmbedding = new VectorEmbedding();
|
||||
vectorEmbedding.content_type = contentType;
|
||||
vectorEmbedding.content_id = contentId;
|
||||
}
|
||||
|
||||
// Update properties
|
||||
vectorEmbedding.text_content = textContent;
|
||||
vectorEmbedding.embedding = embedding;
|
||||
vectorEmbedding.dimensions = embedding.length;
|
||||
vectorEmbedding.metadata = metadata;
|
||||
vectorEmbedding.model = model;
|
||||
|
||||
// For raw SQL operations where our subscriber might not be called
|
||||
// Ensure the embedding is properly formatted for postgres
|
||||
const rawEmbedding = this.formatEmbeddingForPgVector(embedding);
|
||||
if (rawEmbedding) {
|
||||
(vectorEmbedding as any).embedding = rawEmbedding;
|
||||
}
|
||||
|
||||
return this.save(vectorEmbedding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for similar embeddings using cosine similarity
|
||||
* @param embedding Vector embedding to search against
|
||||
* @param limit Maximum number of results (default: 10)
|
||||
* @param threshold Similarity threshold (default: 0.7)
|
||||
* @param contentTypes Optional content types to filter by
|
||||
*/
|
||||
async searchSimilar(
|
||||
embedding: number[],
|
||||
limit = 10,
|
||||
threshold = 0.7,
|
||||
contentTypes?: string[],
|
||||
): Promise<Array<{ embedding: VectorEmbedding; similarity: number }>> {
|
||||
try {
|
||||
// Try using vector similarity operator first
|
||||
try {
|
||||
// Build query with vector operators
|
||||
let query = getAppDataSource()
|
||||
.createQueryBuilder()
|
||||
.select('vector_embedding.*')
|
||||
.addSelect(`1 - (vector_embedding.embedding <=> :embedding) AS similarity`)
|
||||
.from(VectorEmbedding, 'vector_embedding')
|
||||
.where(`1 - (vector_embedding.embedding <=> :embedding) > :threshold`)
|
||||
.orderBy('similarity', 'DESC')
|
||||
.limit(limit)
|
||||
.setParameter(
|
||||
'embedding',
|
||||
Array.isArray(embedding) ? `[${embedding.join(',')}]` : embedding,
|
||||
)
|
||||
.setParameter('threshold', threshold);
|
||||
|
||||
// Add content type filter if provided
|
||||
if (contentTypes && contentTypes.length > 0) {
|
||||
query = query
|
||||
.andWhere('vector_embedding.content_type IN (:...contentTypes)')
|
||||
.setParameter('contentTypes', contentTypes);
|
||||
}
|
||||
|
||||
// Execute query
|
||||
const results = await query.getRawMany();
|
||||
|
||||
// Return results if successful
|
||||
return results.map((row) => ({
|
||||
embedding: this.mapRawToEntity(row),
|
||||
similarity: parseFloat(row.similarity),
|
||||
}));
|
||||
} catch (vectorError) {
|
||||
console.warn(
|
||||
'Vector similarity search failed, falling back to basic filtering:',
|
||||
vectorError,
|
||||
);
|
||||
|
||||
// Fallback to just getting the records by content type
|
||||
let query = this.repository.createQueryBuilder('vector_embedding');
|
||||
|
||||
// Add content type filter if provided
|
||||
if (contentTypes && contentTypes.length > 0) {
|
||||
query = query
|
||||
.where('vector_embedding.content_type IN (:...contentTypes)')
|
||||
.setParameter('contentTypes', contentTypes);
|
||||
}
|
||||
|
||||
// Limit results
|
||||
query = query.take(limit);
|
||||
|
||||
// Execute query
|
||||
const results = await query.getMany();
|
||||
|
||||
// Return results with a placeholder similarity
|
||||
return results.map((entity) => ({
|
||||
embedding: entity,
|
||||
similarity: 0.5, // Placeholder similarity
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during vector search:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by text using vector similarity
|
||||
* @param text Text to search for
|
||||
* @param getEmbeddingFunc Function to convert text to embedding
|
||||
* @param limit Maximum number of results
|
||||
* @param threshold Similarity threshold
|
||||
* @param contentTypes Optional content types to filter by
|
||||
*/
|
||||
async searchByText(
|
||||
text: string,
|
||||
getEmbeddingFunc: (text: string) => Promise<number[]>,
|
||||
limit = 10,
|
||||
threshold = 0.7,
|
||||
contentTypes?: string[],
|
||||
): Promise<Array<{ embedding: VectorEmbedding; similarity: number }>> {
|
||||
try {
|
||||
// Get embedding for the search text
|
||||
const embedding = await getEmbeddingFunc(text);
|
||||
|
||||
// Search by embedding
|
||||
return this.searchSimilar(embedding, limit, threshold, contentTypes);
|
||||
} catch (error) {
|
||||
console.error('Error searching by text:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map raw database result to entity
|
||||
* @param raw Raw database result
|
||||
*/
|
||||
private mapRawToEntity(raw: any): VectorEmbedding {
|
||||
const entity = new VectorEmbedding();
|
||||
entity.id = raw.id;
|
||||
entity.content_type = raw.content_type;
|
||||
entity.content_id = raw.content_id;
|
||||
entity.text_content = raw.text_content;
|
||||
entity.metadata = raw.metadata;
|
||||
entity.embedding = raw.embedding;
|
||||
entity.dimensions = raw.dimensions;
|
||||
entity.model = raw.model;
|
||||
entity.createdAt = raw.created_at;
|
||||
entity.updatedAt = raw.updated_at;
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format embedding array for pgvector
|
||||
* @param embedding Array of embedding values
|
||||
* @returns Properly formatted vector string for pgvector
|
||||
*/
|
||||
private formatEmbeddingForPgVector(embedding: number[] | string): string | null {
|
||||
if (!embedding) return null;
|
||||
|
||||
// If it's already a string and starts with '[', assume it's formatted
|
||||
if (typeof embedding === 'string') {
|
||||
if (embedding.startsWith('[') && embedding.endsWith(']')) {
|
||||
return embedding;
|
||||
}
|
||||
return `[${embedding}]`;
|
||||
}
|
||||
|
||||
// Format array as proper pgvector string
|
||||
if (Array.isArray(embedding)) {
|
||||
return `[${embedding.join(',')}]`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default VectorEmbeddingRepository;
|
||||
4
src/db/repositories/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VectorEmbeddingRepository from './VectorEmbeddingRepository.js';
|
||||
|
||||
// Export all repositories
|
||||
export { VectorEmbeddingRepository };
|
||||