mirror of
https://github.com/langbot-app/LangBot.git
synced 2025-11-25 19:37:36 +08:00
Compare commits
1 Commits
v.4.4.2b1
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4995c8cd9 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,6 +44,5 @@ test.py
|
||||
.venv/
|
||||
uv.lock
|
||||
/test
|
||||
plugins.bak
|
||||
coverage.xml
|
||||
.coverage
|
||||
.coverage
|
||||
81
AGENTS.md
81
AGENTS.md
@@ -1,81 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file is for guiding code agents (like Claude Code, GitHub Copilot, OpenAI Codex, etc.) to work in LangBot project.
|
||||
|
||||
## Project Overview
|
||||
|
||||
LangBot is a open-source LLM native instant messaging bot development platform, aiming to provide an out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, supporting global instant messaging platforms, and providing rich API interfaces, supporting custom development.
|
||||
|
||||
LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts:
|
||||
|
||||
- `./pkg`: The core python package of the project backend.
|
||||
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
|
||||
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
|
||||
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
|
||||
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
|
||||
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
|
||||
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
|
||||
- `./templates`: Templates of config files, components, etc.
|
||||
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
|
||||
- `./docker`: docker-compose deployment files.
|
||||
|
||||
## Backend Development
|
||||
|
||||
We use `uv` to manage dependencies.
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
uv sync --dev
|
||||
```
|
||||
|
||||
Start the backend and run the project in development mode.
|
||||
|
||||
```bash
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Then you can access the project at `http://127.0.0.1:5300`.
|
||||
|
||||
## Frontend Development
|
||||
|
||||
We use `pnpm` to manage dependencies.
|
||||
|
||||
```bash
|
||||
cd web
|
||||
cp .env.example .env
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Then you can access the project at `http://127.0.0.1:3000`.
|
||||
|
||||
## Plugin System Architecture
|
||||
|
||||
LangBot is composed of various internal components such as Large Language Model tools, commands, messaging platform adapters, LLM requesters, and more. To meet extensibility and flexibility requirements, we have implemented a production-grade plugin system.
|
||||
|
||||
Each plugin runs in an independent process, managed uniformly by the Plugin Runtime. It has two operating modes: `stdio` and `websocket`. When LangBot is started directly by users (not running in a container), it uses `stdio` mode, which is common for personal users or lightweight environments. When LangBot runs in a container, it uses `websocket` mode, designed specifically for production environments.
|
||||
|
||||
Plugin Runtime automatically starts each installed plugin and interacts through stdio. In plugin development scenarios, developers can use the lbp command-line tool to start plugins and connect to the running Runtime via WebSocket for debugging.
|
||||
|
||||
> Plugin SDK, CLI, Runtime, and entities definitions shared between LangBot and plugins are contained in the [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk) repository.
|
||||
|
||||
## Some Development Tips and Standards
|
||||
|
||||
- LangBot is a global project, any comments in code should be in English, and user experience should be considered in all aspects.
|
||||
- Thus you should consider the i18n support in all aspects.
|
||||
- LangBot is widely adopted in both toC and toB scenarios, so you should consider the compatibility and security in all aspects.
|
||||
|
||||
## Some Principles
|
||||
|
||||
- Keep it simple, stupid.
|
||||
- Entities should not be multiplied unnecessarily
|
||||
- 八荣八耻
|
||||
|
||||
以瞎猜接口为耻,以认真查询为荣。
|
||||
以模糊执行为耻,以寻求确认为荣。
|
||||
以臆想业务为耻,以人类确认为荣。
|
||||
以创造接口为耻,以复用现有为荣。
|
||||
以跳过验证为耻,以主动测试为荣。
|
||||
以破坏架构为耻,以遵循规范为荣。
|
||||
以假装理解为耻,以诚实无知为荣。
|
||||
以盲目修改为耻,以谨慎重构为荣。
|
||||
@@ -147,9 +147,3 @@ docker compose up -d
|
||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||
</a>
|
||||
|
||||
<!--
|
||||
## For Code Agents
|
||||
|
||||
If you're a code agent based on LLM, you should read [AGENTS.md](AGENTS.md) to understand the project structure and development process before you start any development.
|
||||
-->
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
# API Key Authentication
|
||||
|
||||
LangBot now supports API key authentication for external systems to access its HTTP service API.
|
||||
|
||||
## Managing API Keys
|
||||
|
||||
API keys can be managed through the web interface:
|
||||
|
||||
1. Log in to the LangBot web interface
|
||||
2. Click the "API Keys" button at the bottom of the sidebar
|
||||
3. Create, view, copy, or delete API keys as needed
|
||||
|
||||
## Using API Keys
|
||||
|
||||
### Authentication Headers
|
||||
|
||||
Include your API key in the request header using one of these methods:
|
||||
|
||||
**Method 1: X-API-Key header (Recommended)**
|
||||
```
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
```
|
||||
|
||||
**Method 2: Authorization Bearer token**
|
||||
```
|
||||
Authorization: Bearer lbk_your_api_key_here
|
||||
```
|
||||
|
||||
## Available APIs
|
||||
|
||||
All existing LangBot APIs now support **both user token and API key authentication**. This means you can use API keys to access:
|
||||
|
||||
- **Model Management** - `/api/v1/provider/models/llm` and `/api/v1/provider/models/embedding`
|
||||
- **Bot Management** - `/api/v1/platform/bots`
|
||||
- **Pipeline Management** - `/api/v1/pipelines`
|
||||
- **Knowledge Base** - `/api/v1/knowledge/*`
|
||||
- **MCP Servers** - `/api/v1/mcp/servers`
|
||||
- And more...
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
Each endpoint accepts **either**:
|
||||
1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users
|
||||
2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - for external services
|
||||
|
||||
## Example: Model Management
|
||||
|
||||
### List All LLM Models
|
||||
|
||||
```http
|
||||
GET /api/v1/provider/models/llm
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"models": [
|
||||
{
|
||||
"uuid": "model-uuid",
|
||||
"name": "GPT-4",
|
||||
"description": "OpenAI GPT-4 model",
|
||||
"requester": "openai-chat-completions",
|
||||
"requester_config": {...},
|
||||
"abilities": ["chat", "vision"],
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
"updated_at": "2024-01-01T00:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create a New LLM Model
|
||||
|
||||
```http
|
||||
POST /api/v1/provider/models/llm
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "My Custom Model",
|
||||
"description": "Description of the model",
|
||||
"requester": "openai-chat-completions",
|
||||
"requester_config": {
|
||||
"model": "gpt-4",
|
||||
"args": {}
|
||||
},
|
||||
"api_keys": [
|
||||
{
|
||||
"name": "default",
|
||||
"keys": ["sk-..."]
|
||||
}
|
||||
],
|
||||
"abilities": ["chat"],
|
||||
"extra_args": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Update an LLM Model
|
||||
|
||||
```http
|
||||
PUT /api/v1/provider/models/llm/{model_uuid}
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Updated Model Name",
|
||||
"description": "Updated description",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Delete an LLM Model
|
||||
|
||||
```http
|
||||
DELETE /api/v1/provider/models/llm/{model_uuid}
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
```
|
||||
|
||||
## Example: Bot Management
|
||||
|
||||
### List All Bots
|
||||
|
||||
```http
|
||||
GET /api/v1/platform/bots
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
```
|
||||
|
||||
### Create a New Bot
|
||||
|
||||
```http
|
||||
POST /api/v1/platform/bots
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "My Bot",
|
||||
"adapter": "telegram",
|
||||
"config": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Pipeline Management
|
||||
|
||||
### List All Pipelines
|
||||
|
||||
```http
|
||||
GET /api/v1/pipelines
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
```
|
||||
|
||||
### Create a New Pipeline
|
||||
|
||||
```http
|
||||
POST /api/v1/pipelines
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "My Pipeline",
|
||||
"config": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -1,
|
||||
"msg": "No valid authentication provided (user token or API key required)"
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -1,
|
||||
"msg": "Invalid API key"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -1,
|
||||
"msg": "Resource not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 500 Internal Server Error
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -2,
|
||||
"msg": "Error message details"
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Keep API keys secure**: Store them securely and never commit them to version control
|
||||
2. **Use HTTPS**: Always use HTTPS in production to encrypt API key transmission
|
||||
3. **Rotate keys regularly**: Create new API keys periodically and delete old ones
|
||||
4. **Use descriptive names**: Give your API keys meaningful names to track their usage
|
||||
5. **Delete unused keys**: Remove API keys that are no longer needed
|
||||
6. **Use X-API-Key header**: Prefer using the `X-API-Key` header for clarity
|
||||
|
||||
## Example: Python Client
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = "lbk_your_api_key_here"
|
||||
BASE_URL = "http://your-langbot-server:5300"
|
||||
|
||||
headers = {
|
||||
"X-API-Key": API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# List all models
|
||||
response = requests.get(f"{BASE_URL}/api/v1/provider/models/llm", headers=headers)
|
||||
models = response.json()["data"]["models"]
|
||||
|
||||
print(f"Found {len(models)} models")
|
||||
for model in models:
|
||||
print(f"- {model['name']}: {model['description']}")
|
||||
|
||||
# Create a new bot
|
||||
bot_data = {
|
||||
"name": "My Telegram Bot",
|
||||
"adapter": "telegram",
|
||||
"config": {
|
||||
"token": "your-telegram-token"
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/v1/platform/bots",
|
||||
headers=headers,
|
||||
json=bot_data
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
bot_uuid = response.json()["data"]["uuid"]
|
||||
print(f"Bot created with UUID: {bot_uuid}")
|
||||
```
|
||||
|
||||
## Example: cURL
|
||||
|
||||
```bash
|
||||
# List all models
|
||||
curl -X GET \
|
||||
-H "X-API-Key: lbk_your_api_key_here" \
|
||||
http://your-langbot-server:5300/api/v1/provider/models/llm
|
||||
|
||||
# Create a new pipeline
|
||||
curl -X POST \
|
||||
-H "X-API-Key: lbk_your_api_key_here" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "My Pipeline",
|
||||
"config": {...}
|
||||
}' \
|
||||
http://your-langbot-server:5300/api/v1/pipelines
|
||||
|
||||
# Get bot logs
|
||||
curl -X POST \
|
||||
-H "X-API-Key: lbk_your_api_key_here" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"from_index": -1,
|
||||
"max_count": 10
|
||||
}' \
|
||||
http://your-langbot-server:5300/api/v1/platform/bots/{bot_uuid}/logs
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The same endpoints work for both the web UI (with user tokens) and external services (with API keys)
|
||||
- No need to learn different API paths - use the existing API documentation with API key authentication
|
||||
- All endpoints that previously required user authentication now also accept API keys
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -178,7 +178,7 @@ class AsyncCozeAPIClient:
|
||||
elif chunk.startswith("data:"):
|
||||
chunk_data = chunk.replace("data:", "", 1).strip()
|
||||
else:
|
||||
yield {"event": chunk_type, "data": json.loads(chunk_data) if chunk_data else {}} # 处理本地部署时,接口返回的data为空值
|
||||
yield {"event": chunk_type, "data": json.loads(chunk_data)}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)")
|
||||
|
||||
@@ -1,452 +1,189 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
from urllib.parse import unquote
|
||||
import hashlib
|
||||
import traceback
|
||||
|
||||
import httpx
|
||||
from Crypto.Cipher import AES
|
||||
from quart import Quart, request, Response, jsonify
|
||||
|
||||
from libs.wecom_ai_bot_api import wecombotevent
|
||||
from libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
from quart import Quart, request, Response, jsonify
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import asyncio
|
||||
from libs.wecom_ai_bot_api import wecombotevent
|
||||
from typing import Callable
|
||||
import base64
|
||||
from Crypto.Cipher import AES
|
||||
from pkg.platform.logger import EventLogger
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamChunk:
|
||||
"""描述单次推送给企业微信的流式片段。"""
|
||||
|
||||
# 需要返回给企业微信的文本内容
|
||||
content: str
|
||||
|
||||
# 标记是否为最终片段,对应企业微信协议里的 finish 字段
|
||||
is_final: bool = False
|
||||
|
||||
# 预留额外元信息,未来支持多模态扩展时可使用
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamSession:
|
||||
"""维护一次企业微信流式会话的上下文。"""
|
||||
|
||||
# 企业微信要求的 stream_id,用于标识后续刷新请求
|
||||
stream_id: str
|
||||
|
||||
# 原始消息的 msgid,便于与流水线消息对应
|
||||
msg_id: str
|
||||
|
||||
# 群聊会话标识(单聊时为空)
|
||||
chat_id: Optional[str]
|
||||
|
||||
# 触发消息的发送者
|
||||
user_id: Optional[str]
|
||||
|
||||
# 会话创建时间
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
# 最近一次被访问的时间,cleanup 依据该值判断过期
|
||||
last_access: float = field(default_factory=time.time)
|
||||
|
||||
# 将流水线增量结果缓存到队列,刷新请求逐条消费
|
||||
queue: asyncio.Queue = field(default_factory=asyncio.Queue)
|
||||
|
||||
# 是否已经完成(收到最终片段)
|
||||
finished: bool = False
|
||||
|
||||
# 缓存最近一次片段,处理重试或超时兜底
|
||||
last_chunk: Optional[StreamChunk] = None
|
||||
|
||||
|
||||
class StreamSessionManager:
|
||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||
|
||||
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
||||
self.logger = logger
|
||||
|
||||
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
||||
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
||||
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
||||
|
||||
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
||||
if not msg_id:
|
||||
return None
|
||||
return self._msg_index.get(msg_id)
|
||||
|
||||
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
||||
return self._sessions.get(stream_id)
|
||||
|
||||
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
||||
"""根据企业微信回调创建或获取会话。
|
||||
|
||||
Args:
|
||||
msg_json: 企业微信解密后的回调 JSON。
|
||||
|
||||
Returns:
|
||||
Tuple[StreamSession, bool]: `StreamSession` 为会话实例,`bool` 指示是否为新建会话。
|
||||
|
||||
Example:
|
||||
在首次回调中调用,得到 `is_new=True` 后再触发流水线。
|
||||
"""
|
||||
msg_id = msg_json.get('msgid', '')
|
||||
if msg_id and msg_id in self._msg_index:
|
||||
stream_id = self._msg_index[msg_id]
|
||||
session = self._sessions.get(stream_id)
|
||||
if session:
|
||||
session.last_access = time.time()
|
||||
return session, False
|
||||
|
||||
stream_id = str(uuid.uuid4())
|
||||
session = StreamSession(
|
||||
stream_id=stream_id,
|
||||
msg_id=msg_id,
|
||||
chat_id=msg_json.get('chatid'),
|
||||
user_id=msg_json.get('from', {}).get('userid'),
|
||||
)
|
||||
|
||||
if msg_id:
|
||||
self._msg_index[msg_id] = stream_id
|
||||
self._sessions[stream_id] = session
|
||||
return session, True
|
||||
|
||||
async def publish(self, stream_id: str, chunk: StreamChunk) -> bool:
|
||||
"""向 stream 队列写入新的增量片段。
|
||||
|
||||
Args:
|
||||
stream_id: 企业微信分配的流式会话 ID。
|
||||
chunk: 待发送的增量片段。
|
||||
|
||||
Returns:
|
||||
bool: 当流式队列存在并成功入队时返回 True。
|
||||
|
||||
Example:
|
||||
在收到模型增量后调用 `await manager.publish('sid', StreamChunk('hello'))`。
|
||||
"""
|
||||
session = self._sessions.get(stream_id)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
session.last_access = time.time()
|
||||
session.last_chunk = chunk
|
||||
|
||||
try:
|
||||
session.queue.put_nowait(chunk)
|
||||
except asyncio.QueueFull:
|
||||
# 默认无界队列,此处兜底防御
|
||||
await session.queue.put(chunk)
|
||||
|
||||
if chunk.is_final:
|
||||
session.finished = True
|
||||
|
||||
return True
|
||||
|
||||
async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[StreamChunk]:
|
||||
"""从队列中取出一个片段,若超时返回 None。
|
||||
|
||||
Args:
|
||||
stream_id: 企业微信流式会话 ID。
|
||||
timeout: 取片段的最长等待时间(秒)。
|
||||
|
||||
Returns:
|
||||
Optional[StreamChunk]: 成功时返回片段,超时或会话不存在时返回 None。
|
||||
|
||||
Example:
|
||||
企业微信刷新到达时调用,若队列有数据则立即返回 `StreamChunk`。
|
||||
"""
|
||||
session = self._sessions.get(stream_id)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
session.last_access = time.time()
|
||||
|
||||
try:
|
||||
chunk = await asyncio.wait_for(session.queue.get(), timeout)
|
||||
session.last_access = time.time()
|
||||
if chunk.is_final:
|
||||
session.finished = True
|
||||
return chunk
|
||||
except asyncio.TimeoutError:
|
||||
if session.finished and session.last_chunk:
|
||||
return session.last_chunk
|
||||
return None
|
||||
|
||||
def mark_finished(self, stream_id: str) -> None:
|
||||
session = self._sessions.get(stream_id)
|
||||
if session:
|
||||
session.finished = True
|
||||
session.last_access = time.time()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""定期清理过期会话,防止队列与映射无上限累积。"""
|
||||
now = time.time()
|
||||
expired: list[str] = []
|
||||
for stream_id, session in self._sessions.items():
|
||||
if now - session.last_access > self.ttl:
|
||||
expired.append(stream_id)
|
||||
|
||||
for stream_id in expired:
|
||||
session = self._sessions.pop(stream_id, None)
|
||||
if not session:
|
||||
continue
|
||||
msg_id = session.msg_id
|
||||
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
||||
self._msg_index.pop(msg_id, None)
|
||||
|
||||
|
||||
class WecomBotClient:
|
||||
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger):
|
||||
"""企业微信智能机器人客户端。
|
||||
|
||||
Args:
|
||||
Token: 企业微信回调验证使用的 token。
|
||||
EnCodingAESKey: 企业微信消息加解密密钥。
|
||||
Corpid: 企业 ID。
|
||||
logger: 日志记录器。
|
||||
|
||||
Example:
|
||||
>>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger)
|
||||
"""
|
||||
|
||||
self.Token = Token
|
||||
self.EnCodingAESKey = EnCodingAESKey
|
||||
self.Corpid = Corpid
|
||||
def __init__(self,Token:str,EnCodingAESKey:str,Corpid:str,logger:EventLogger):
|
||||
self.Token=Token
|
||||
self.EnCodingAESKey=EnCodingAESKey
|
||||
self.Corpid=Corpid
|
||||
self.ReceiveId = ''
|
||||
self.app = Quart(__name__)
|
||||
self.app.add_url_rule(
|
||||
'/callback/command',
|
||||
'handle_callback',
|
||||
self.handle_callback_request,
|
||||
methods=['POST', 'GET']
|
||||
methods=['POST','GET']
|
||||
)
|
||||
self._message_handlers = {
|
||||
'example': [],
|
||||
}
|
||||
self.user_stream_map = {}
|
||||
self.logger = logger
|
||||
self.generated_content: dict[str, str] = {}
|
||||
self.msg_id_map: dict[str, int] = {}
|
||||
self.stream_sessions = StreamSessionManager(logger=logger)
|
||||
self.stream_poll_timeout = 0.5
|
||||
|
||||
@staticmethod
|
||||
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
|
||||
"""按照企业微信协议拼装返回报文。
|
||||
|
||||
Args:
|
||||
stream_id: 企业微信会话 ID。
|
||||
content: 推送的文本内容。
|
||||
finish: 是否为最终片段。
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: 可直接加密返回的 payload。
|
||||
|
||||
Example:
|
||||
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
||||
"""
|
||||
return {
|
||||
'msgtype': 'stream',
|
||||
'stream': {
|
||||
'id': stream_id,
|
||||
'finish': finish,
|
||||
'content': content,
|
||||
},
|
||||
}
|
||||
|
||||
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||
"""对响应进行加密封装并返回给企业微信。
|
||||
|
||||
Args:
|
||||
payload: 待加密的响应内容。
|
||||
nonce: 企业微信回调参数中的 nonce。
|
||||
|
||||
Returns:
|
||||
Tuple[Response, int]: Quart Response 对象及状态码。
|
||||
|
||||
Example:
|
||||
在首包或刷新场景中调用以生成加密响应。
|
||||
"""
|
||||
reply_plain_str = json.dumps(payload, ensure_ascii=False)
|
||||
reply_timestamp = str(int(time.time()))
|
||||
ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp)
|
||||
if ret != 0:
|
||||
await self.logger.error(f'加密失败: {ret}')
|
||||
return jsonify({'error': 'encrypt_failed'}), 500
|
||||
|
||||
root = ET.fromstring(encrypt_text)
|
||||
encrypt = root.find('Encrypt').text
|
||||
resp = {
|
||||
'encrypt': encrypt,
|
||||
}
|
||||
return jsonify(resp), 200
|
||||
|
||||
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent) -> None:
|
||||
"""异步触发流水线处理,避免阻塞首包响应。
|
||||
|
||||
Args:
|
||||
event: 由企业微信消息转换的内部事件对象。
|
||||
"""
|
||||
try:
|
||||
await self._handle_message(event)
|
||||
except Exception:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
|
||||
async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||
"""处理企业微信首次推送的消息,返回 stream_id 并开启流水线。
|
||||
|
||||
Args:
|
||||
msg_json: 解密后的企业微信消息 JSON。
|
||||
nonce: 企业微信回调参数 nonce。
|
||||
|
||||
Returns:
|
||||
Tuple[Response, int]: Quart Response 及状态码。
|
||||
|
||||
Example:
|
||||
首次回调时调用,立即返回带 `stream_id` 的响应。
|
||||
"""
|
||||
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
||||
|
||||
message_data = await self.get_message(msg_json)
|
||||
if message_data:
|
||||
message_data['stream_id'] = session.stream_id
|
||||
try:
|
||||
event = wecombotevent.WecomBotEvent(message_data)
|
||||
except Exception:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
else:
|
||||
if is_new:
|
||||
asyncio.create_task(self._dispatch_event(event))
|
||||
|
||||
payload = self._build_stream_payload(session.stream_id, '', False)
|
||||
return await self._encrypt_and_reply(payload, nonce)
|
||||
|
||||
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||
"""处理企业微信的流式刷新请求,按需返回增量片段。
|
||||
|
||||
Args:
|
||||
msg_json: 解密后的企业微信刷新请求。
|
||||
nonce: 企业微信回调参数 nonce。
|
||||
|
||||
Returns:
|
||||
Tuple[Response, int]: Quart Response 及状态码。
|
||||
|
||||
Example:
|
||||
在刷新请求中调用,按需返回增量片段。
|
||||
"""
|
||||
stream_info = msg_json.get('stream', {})
|
||||
stream_id = stream_info.get('id', '')
|
||||
if not stream_id:
|
||||
await self.logger.error('刷新请求缺少 stream.id')
|
||||
return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce)
|
||||
|
||||
session = self.stream_sessions.get_session(stream_id)
|
||||
chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout)
|
||||
|
||||
if not chunk:
|
||||
cached_content = None
|
||||
if session and session.msg_id:
|
||||
cached_content = self.generated_content.pop(session.msg_id, None)
|
||||
if cached_content is not None:
|
||||
chunk = StreamChunk(content=cached_content, is_final=True)
|
||||
else:
|
||||
payload = self._build_stream_payload(stream_id, '', False)
|
||||
return await self._encrypt_and_reply(payload, nonce)
|
||||
|
||||
payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final)
|
||||
if chunk.is_final:
|
||||
self.stream_sessions.mark_finished(stream_id)
|
||||
return await self._encrypt_and_reply(payload, nonce)
|
||||
self.generated_content = {}
|
||||
self.msg_id_map = {}
|
||||
|
||||
async def sha1_signature(token: str, timestamp: str, nonce: str, encrypt: str) -> str:
|
||||
raw = "".join(sorted([token, timestamp, nonce, encrypt]))
|
||||
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""企业微信回调入口。
|
||||
|
||||
Returns:
|
||||
Quart Response: 根据请求类型返回验证、首包或刷新结果。
|
||||
|
||||
Example:
|
||||
作为 Quart 路由处理函数直接注册并使用。
|
||||
"""
|
||||
try:
|
||||
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
|
||||
await self.logger.info(f'{request.method} {request.url} {str(request.args)}')
|
||||
self.wxcpt=WXBizMsgCrypt(self.Token,self.EnCodingAESKey,'')
|
||||
|
||||
if request.method == 'GET':
|
||||
return await self._handle_get_callback()
|
||||
if request.method == "GET":
|
||||
|
||||
if request.method == 'POST':
|
||||
return await self._handle_post_callback()
|
||||
msg_signature = unquote(request.args.get("msg_signature", ""))
|
||||
timestamp = unquote(request.args.get("timestamp", ""))
|
||||
nonce = unquote(request.args.get("nonce", ""))
|
||||
echostr = unquote(request.args.get("echostr", ""))
|
||||
|
||||
return Response('', status=405)
|
||||
if not all([msg_signature, timestamp, nonce, echostr]):
|
||||
await self.logger.error("请求参数缺失")
|
||||
return Response("缺少参数", status=400)
|
||||
|
||||
except Exception:
|
||||
ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||
if ret != 0:
|
||||
|
||||
await self.logger.error("验证URL失败")
|
||||
return Response("验证失败", status=403)
|
||||
|
||||
return Response(decrypted_str, mimetype="text/plain")
|
||||
|
||||
elif request.method == "POST":
|
||||
msg_signature = unquote(request.args.get("msg_signature", ""))
|
||||
timestamp = unquote(request.args.get("timestamp", ""))
|
||||
nonce = unquote(request.args.get("nonce", ""))
|
||||
|
||||
try:
|
||||
timeout = 3
|
||||
interval = 0.1
|
||||
start_time = time.monotonic()
|
||||
encrypted_json = await request.get_json()
|
||||
encrypted_msg = encrypted_json.get("encrypt", "")
|
||||
if not encrypted_msg:
|
||||
await self.logger.error("请求体中缺少 'encrypt' 字段")
|
||||
|
||||
xml_post_data = f"<xml><Encrypt><![CDATA[{encrypted_msg}]]></Encrypt></xml>"
|
||||
ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce)
|
||||
if ret != 0:
|
||||
await self.logger.error("解密失败")
|
||||
|
||||
|
||||
msg_json = json.loads(decrypted_xml)
|
||||
|
||||
from_user_id = msg_json.get("from", {}).get("userid")
|
||||
chatid = msg_json.get("chatid", "")
|
||||
|
||||
message_data = await self.get_message(msg_json)
|
||||
|
||||
|
||||
|
||||
if message_data:
|
||||
try:
|
||||
event = wecombotevent.WecomBotEvent(message_data)
|
||||
if event:
|
||||
await self._handle_message(event)
|
||||
except Exception as e:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
print(traceback.format_exc())
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
if msg_json.get('chattype','') == 'single':
|
||||
if from_user_id in self.user_stream_map:
|
||||
stream_id = self.user_stream_map[from_user_id]
|
||||
else:
|
||||
stream_id =str(uuid.uuid4())
|
||||
self.user_stream_map[from_user_id] = stream_id
|
||||
|
||||
|
||||
else:
|
||||
|
||||
if chatid in self.user_stream_map:
|
||||
stream_id = self.user_stream_map[chatid]
|
||||
else:
|
||||
stream_id = str(uuid.uuid4())
|
||||
self.user_stream_map[chatid] = stream_id
|
||||
except Exception as e:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
print(traceback.format_exc())
|
||||
while True:
|
||||
content = self.generated_content.pop(msg_json['msgid'],None)
|
||||
if content:
|
||||
reply_plain = {
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": True,
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
reply_plain_str = json.dumps(reply_plain, ensure_ascii=False)
|
||||
|
||||
reply_timestamp = str(int(time.time()))
|
||||
ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp)
|
||||
if ret != 0:
|
||||
|
||||
await self.logger.error("加密失败"+str(ret))
|
||||
|
||||
|
||||
root = ET.fromstring(encrypt_text)
|
||||
encrypt = root.find("Encrypt").text
|
||||
resp = {
|
||||
"encrypt": encrypt,
|
||||
}
|
||||
return jsonify(resp), 200
|
||||
|
||||
if time.time() - start_time > timeout:
|
||||
break
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
if self.msg_id_map.get(message_data['msgid'], 1) == 3:
|
||||
await self.logger.error('请求失效:暂不支持智能机器人超过7秒的请求,如有需求,请联系 LangBot 团队。')
|
||||
return ''
|
||||
|
||||
except Exception as e:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
print(traceback.format_exc())
|
||||
|
||||
except Exception as e:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
return Response('Internal Server Error', status=500)
|
||||
print(traceback.format_exc())
|
||||
|
||||
async def _handle_get_callback(self) -> tuple[Response, int] | Response:
|
||||
"""处理企业微信的 GET 验证请求。"""
|
||||
|
||||
msg_signature = unquote(request.args.get('msg_signature', ''))
|
||||
timestamp = unquote(request.args.get('timestamp', ''))
|
||||
nonce = unquote(request.args.get('nonce', ''))
|
||||
echostr = unquote(request.args.get('echostr', ''))
|
||||
|
||||
if not all([msg_signature, timestamp, nonce, echostr]):
|
||||
await self.logger.error('请求参数缺失')
|
||||
return Response('缺少参数', status=400)
|
||||
|
||||
ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||
if ret != 0:
|
||||
await self.logger.error('验证URL失败')
|
||||
return Response('验证失败', status=403)
|
||||
|
||||
return Response(decrypted_str, mimetype='text/plain')
|
||||
|
||||
async def _handle_post_callback(self) -> tuple[Response, int] | Response:
|
||||
"""处理企业微信的 POST 回调请求。"""
|
||||
|
||||
self.stream_sessions.cleanup()
|
||||
|
||||
msg_signature = unquote(request.args.get('msg_signature', ''))
|
||||
timestamp = unquote(request.args.get('timestamp', ''))
|
||||
nonce = unquote(request.args.get('nonce', ''))
|
||||
|
||||
encrypted_json = await request.get_json()
|
||||
encrypted_msg = (encrypted_json or {}).get('encrypt', '')
|
||||
if not encrypted_msg:
|
||||
await self.logger.error("请求体中缺少 'encrypt' 字段")
|
||||
return Response('Bad Request', status=400)
|
||||
|
||||
xml_post_data = f"<xml><Encrypt><![CDATA[{encrypted_msg}]]></Encrypt></xml>"
|
||||
ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce)
|
||||
if ret != 0:
|
||||
await self.logger.error('解密失败')
|
||||
return Response('解密失败', status=400)
|
||||
|
||||
msg_json = json.loads(decrypted_xml)
|
||||
|
||||
if msg_json.get('msgtype') == 'stream':
|
||||
return await self._handle_post_followup_response(msg_json, nonce)
|
||||
|
||||
return await self._handle_post_initial_response(msg_json, nonce)
|
||||
|
||||
async def get_message(self, msg_json):
|
||||
|
||||
async def get_message(self,msg_json):
|
||||
message_data = {}
|
||||
|
||||
if msg_json.get('chattype', '') == 'single':
|
||||
if msg_json.get('chattype','') == 'single':
|
||||
message_data['type'] = 'single'
|
||||
elif msg_json.get('chattype', '') == 'group':
|
||||
elif msg_json.get('chattype','') == 'group':
|
||||
message_data['type'] = 'group'
|
||||
|
||||
if msg_json.get('msgtype') == 'text':
|
||||
message_data['content'] = msg_json.get('text', {}).get('content')
|
||||
message_data['content'] = msg_json.get('text',{}).get('content')
|
||||
elif msg_json.get('msgtype') == 'image':
|
||||
picurl = msg_json.get('image', {}).get('url', '')
|
||||
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
||||
message_data['picurl'] = base64
|
||||
picurl = msg_json.get('image', {}).get('url','')
|
||||
base64 = await self.download_url_to_base64(picurl,self.EnCodingAESKey)
|
||||
message_data['picurl'] = base64
|
||||
elif msg_json.get('msgtype') == 'mixed':
|
||||
items = msg_json.get('mixed', {}).get('msg_item', [])
|
||||
texts = []
|
||||
@@ -460,27 +197,17 @@ class WecomBotClient:
|
||||
if texts:
|
||||
message_data['content'] = "".join(texts) # 拼接所有 text
|
||||
if picurl:
|
||||
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
||||
message_data['picurl'] = base64 # 只保留第一个 image
|
||||
|
||||
# Extract user information
|
||||
from_info = msg_json.get('from', {})
|
||||
message_data['userid'] = from_info.get('userid', '')
|
||||
message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
|
||||
|
||||
# Extract chat/group information
|
||||
if msg_json.get('chattype', '') == 'group':
|
||||
message_data['chatid'] = msg_json.get('chatid', '')
|
||||
# Try to get group name if available
|
||||
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
|
||||
base64 = await self.download_url_to_base64(picurl,self.EnCodingAESKey)
|
||||
message_data['picurl'] = base64 # 只保留第一个 image
|
||||
|
||||
message_data['userid'] = msg_json.get('from', {}).get('userid', '')
|
||||
message_data['msgid'] = msg_json.get('msgid', '')
|
||||
|
||||
if msg_json.get('aibotid'):
|
||||
message_data['aibotid'] = msg_json.get('aibotid', '')
|
||||
|
||||
return message_data
|
||||
|
||||
|
||||
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
||||
"""
|
||||
处理消息事件。
|
||||
@@ -496,46 +223,10 @@ class WecomBotClient:
|
||||
for handler in self._message_handlers[msg_type]:
|
||||
await handler(event)
|
||||
except Exception:
|
||||
print(traceback.format_exc())
|
||||
|
||||
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
||||
"""将流水线片段推送到 stream 会话。
|
||||
|
||||
Args:
|
||||
msg_id: 原始企业微信消息 ID。
|
||||
content: 模型产生的片段内容。
|
||||
is_final: 是否为最终片段。
|
||||
|
||||
Returns:
|
||||
bool: 当成功写入流式队列时返回 True。
|
||||
|
||||
Example:
|
||||
在流水线 `reply_message_chunk` 中调用,将增量推送至企业微信。
|
||||
"""
|
||||
# 根据 msg_id 找到对应 stream 会话,如果不存在说明当前消息非流式
|
||||
stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id)
|
||||
if not stream_id:
|
||||
return False
|
||||
|
||||
chunk = StreamChunk(content=content, is_final=is_final)
|
||||
await self.stream_sessions.publish(stream_id, chunk)
|
||||
if is_final:
|
||||
self.stream_sessions.mark_finished(stream_id)
|
||||
return True
|
||||
print(traceback.format_exc())
|
||||
|
||||
async def set_message(self, msg_id: str, content: str):
|
||||
"""兼容旧逻辑:若无法流式返回则缓存最终结果。
|
||||
|
||||
Args:
|
||||
msg_id: 企业微信消息 ID。
|
||||
content: 最终回复的文本内容。
|
||||
|
||||
Example:
|
||||
在非流式场景下缓存最终结果以备刷新时返回。
|
||||
"""
|
||||
handled = await self.push_stream_chunk(msg_id, content, is_final=True)
|
||||
if not handled:
|
||||
self.generated_content[msg_id] = content
|
||||
self.generated_content[msg_id] = content
|
||||
|
||||
def on_message(self, msg_type: str):
|
||||
def decorator(func: Callable[[wecombotevent.WecomBotEvent], None]):
|
||||
@@ -546,6 +237,7 @@ class WecomBotClient:
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(download_url)
|
||||
@@ -555,22 +247,26 @@ class WecomBotClient:
|
||||
|
||||
encrypted_bytes = response.content
|
||||
|
||||
|
||||
aes_key = base64.b64decode(encoding_aes_key + "=") # base64 补齐
|
||||
iv = aes_key[:16]
|
||||
|
||||
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted_bytes)
|
||||
|
||||
|
||||
pad_len = decrypted[-1]
|
||||
decrypted = decrypted[:-pad_len]
|
||||
|
||||
if decrypted.startswith(b"\xff\xd8"): # JPEG
|
||||
|
||||
if decrypted.startswith(b"\xff\xd8"): # JPEG
|
||||
mime_type = "image/jpeg"
|
||||
elif decrypted.startswith(b"\x89PNG"): # PNG
|
||||
mime_type = "image/png"
|
||||
elif decrypted.startswith((b"GIF87a", b"GIF89a")): # GIF
|
||||
mime_type = "image/gif"
|
||||
elif decrypted.startswith(b"BM"): # BMP
|
||||
elif decrypted.startswith(b"BM"): # BMP
|
||||
mime_type = "image/bmp"
|
||||
elif decrypted.startswith(b"II*\x00") or decrypted.startswith(b"MM\x00*"): # TIFF
|
||||
mime_type = "image/tiff"
|
||||
@@ -580,9 +276,15 @@ class WecomBotClient:
|
||||
# 转 base64
|
||||
base64_str = base64.b64encode(decrypted).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{base64_str}"
|
||||
|
||||
|
||||
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||
"""
|
||||
启动 Quart 应用。
|
||||
"""
|
||||
await self.app.run_task(host=host, port=port, *args, **kwargs)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -22,21 +22,7 @@ class WecomBotEvent(dict):
|
||||
"""
|
||||
用户id
|
||||
"""
|
||||
return self.get('from', {}).get('userid', '') or self.get('userid', '')
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
"""
|
||||
用户名称
|
||||
"""
|
||||
return self.get('username', '') or self.get('from', {}).get('alias', '') or self.get('from', {}).get('name', '') or self.userid
|
||||
|
||||
@property
|
||||
def chatname(self) -> str:
|
||||
"""
|
||||
群组名称
|
||||
"""
|
||||
return self.get('chatname', '') or str(self.chatid)
|
||||
return self.get('from', {}).get('userid', '')
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
|
||||
@@ -9,9 +9,6 @@ from quart.typing import RouteCallable
|
||||
|
||||
from ....core import app
|
||||
|
||||
# Maximum file upload size limit (10MB)
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
preregistered_groups: list[type[RouterGroup]] = []
|
||||
"""Pre-registered list of RouterGroup"""
|
||||
@@ -34,8 +31,6 @@ class AuthType(enum.Enum):
|
||||
|
||||
NONE = 'none'
|
||||
USER_TOKEN = 'user-token'
|
||||
API_KEY = 'api-key'
|
||||
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
|
||||
|
||||
|
||||
class RouterGroup(abc.ABC):
|
||||
@@ -89,63 +84,6 @@ class RouterGroup(abc.ABC):
|
||||
except Exception as e:
|
||||
return self.http_status(401, -1, str(e))
|
||||
|
||||
elif auth_type == AuthType.API_KEY:
|
||||
# get API key from Authorization header or X-API-Key header
|
||||
api_key = quart.request.headers.get('X-API-Key', '')
|
||||
if not api_key:
|
||||
auth_header = quart.request.headers.get('Authorization', '')
|
||||
if auth_header.startswith('Bearer '):
|
||||
api_key = auth_header.replace('Bearer ', '')
|
||||
|
||||
if not api_key:
|
||||
return self.http_status(401, -1, 'No valid API key provided')
|
||||
|
||||
try:
|
||||
is_valid = await self.ap.apikey_service.verify_api_key(api_key)
|
||||
if not is_valid:
|
||||
return self.http_status(401, -1, 'Invalid API key')
|
||||
except Exception as e:
|
||||
return self.http_status(401, -1, str(e))
|
||||
|
||||
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
|
||||
# Try API key first (check X-API-Key header)
|
||||
api_key = quart.request.headers.get('X-API-Key', '')
|
||||
|
||||
if api_key:
|
||||
# API key authentication
|
||||
try:
|
||||
is_valid = await self.ap.apikey_service.verify_api_key(api_key)
|
||||
if not is_valid:
|
||||
return self.http_status(401, -1, 'Invalid API key')
|
||||
except Exception as e:
|
||||
return self.http_status(401, -1, str(e))
|
||||
else:
|
||||
# Try user token authentication (Authorization header)
|
||||
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
|
||||
if not token:
|
||||
return self.http_status(401, -1, 'No valid authentication provided (user token or API key required)')
|
||||
|
||||
try:
|
||||
user_email = await self.ap.user_service.verify_jwt_token(token)
|
||||
|
||||
# check if this account exists
|
||||
user = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if not user:
|
||||
return self.http_status(401, -1, 'User not found')
|
||||
|
||||
# check if f accepts user_email parameter
|
||||
if 'user_email' in f.__code__.co_varnames:
|
||||
kwargs['user_email'] = user_email
|
||||
except Exception:
|
||||
# If user token fails, maybe it's an API key in Authorization header
|
||||
try:
|
||||
is_valid = await self.ap.apikey_service.verify_api_key(token)
|
||||
if not is_valid:
|
||||
return self.http_status(401, -1, 'Invalid authentication credentials')
|
||||
except Exception as e:
|
||||
return self.http_status(401, -1, str(e))
|
||||
|
||||
try:
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('apikeys', '/api/v1/apikeys')
|
||||
class ApiKeysRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
keys = await self.ap.apikey_service.get_api_keys()
|
||||
return self.success(data={'keys': keys})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name', '')
|
||||
description = json_data.get('description', '')
|
||||
|
||||
if not name:
|
||||
return self.http_status(400, -1, 'Name is required')
|
||||
|
||||
key = await self.ap.apikey_service.create_api_key(name, description)
|
||||
return self.success(data={'key': key})
|
||||
|
||||
@self.route('/<int:key_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(key_id: int) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
key = await self.ap.apikey_service.get_api_key(key_id)
|
||||
if key is None:
|
||||
return self.http_status(404, -1, 'API key not found')
|
||||
return self.success(data={'key': key})
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name')
|
||||
description = json_data.get('description')
|
||||
|
||||
await self.ap.apikey_service.update_api_key(key_id, name, description)
|
||||
return self.success()
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.apikey_service.delete_api_key(key_id)
|
||||
return self.success()
|
||||
@@ -31,41 +31,19 @@ class FilesRouterGroup(group.RouterGroup):
|
||||
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> quart.Response:
|
||||
request = quart.request
|
||||
|
||||
# Check file size limit before reading the file
|
||||
content_length = request.content_length
|
||||
if content_length and content_length > group.MAX_FILE_SIZE:
|
||||
return self.fail(400, 'File size exceeds 10MB limit. Please split large files into smaller parts.')
|
||||
|
||||
# get file bytes from 'file'
|
||||
files = await request.files
|
||||
if 'file' not in files:
|
||||
return self.fail(400, 'No file provided in request')
|
||||
|
||||
file = files['file']
|
||||
file = (await request.files)['file']
|
||||
assert isinstance(file, quart.datastructures.FileStorage)
|
||||
|
||||
file_bytes = await asyncio.to_thread(file.stream.read)
|
||||
|
||||
# Double-check actual file size after reading
|
||||
if len(file_bytes) > group.MAX_FILE_SIZE:
|
||||
return self.fail(400, 'File size exceeds 10MB limit. Please split large files into smaller parts.')
|
||||
|
||||
# Split filename and extension properly
|
||||
if '.' in file.filename:
|
||||
file_name, extension = file.filename.rsplit('.', 1)
|
||||
else:
|
||||
file_name = file.filename
|
||||
extension = ''
|
||||
extension = file.filename.split('.')[-1]
|
||||
file_name = file.filename.split('.')[0]
|
||||
|
||||
# check if file name contains '/' or '\'
|
||||
if '/' in file_name or '\\' in file_name:
|
||||
return self.fail(400, 'File name contains invalid characters')
|
||||
|
||||
file_key = file_name + '_' + str(uuid.uuid4())[:8]
|
||||
if extension:
|
||||
file_key += '.' + extension
|
||||
|
||||
file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension
|
||||
# save file to storage
|
||||
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||
return self.success(
|
||||
|
||||
@@ -8,7 +8,7 @@ from ... import group
|
||||
@group.group_class('pipelines', '/api/v1/pipelines')
|
||||
class PipelinesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
sort_by = quart.request.args.get('sort_by', 'created_at')
|
||||
@@ -23,11 +23,11 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={'uuid': pipeline_uuid})
|
||||
|
||||
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/_/metadata', methods=['GET'])
|
||||
async def _() -> str:
|
||||
return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()})
|
||||
|
||||
@self.route('/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(pipeline_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
||||
@@ -46,34 +46,3 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
await self.ap.pipeline_service.delete_pipeline(pipeline_uuid)
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(pipeline_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
# Get current extensions and available plugins
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
||||
if pipeline is None:
|
||||
return self.http_status(404, -1, 'pipeline not found')
|
||||
|
||||
plugins = await self.ap.plugin_connector.list_plugins()
|
||||
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'bound_plugins': pipeline.get('extensions_preferences', {}).get('plugins', []),
|
||||
'available_plugins': plugins,
|
||||
'bound_mcp_servers': pipeline.get('extensions_preferences', {}).get('mcp_servers', []),
|
||||
'available_mcp_servers': mcp_servers,
|
||||
}
|
||||
)
|
||||
elif quart.request.method == 'PUT':
|
||||
# Update bound plugins and MCP servers for this pipeline
|
||||
json_data = await quart.request.json
|
||||
bound_plugins = json_data.get('bound_plugins', [])
|
||||
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
||||
|
||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||
pipeline_uuid, bound_plugins, bound_mcp_servers
|
||||
)
|
||||
|
||||
return self.success()
|
||||
|
||||
229
pkg/api/http/controller/groups/pipelines/websocket.py
Normal file
229
pkg/api/http/controller/groups/pipelines/websocket.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""流水线调试 WebSocket 路由
|
||||
|
||||
提供基于 WebSocket 的实时双向通信,用于流水线调试。
|
||||
支持 person 和 group 两种会话类型的隔离。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
from ....service.websocket_pool import WebSocketConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_client_event(connection: WebSocketConnection, message: dict, ap):
|
||||
"""处理客户端发送的事件
|
||||
|
||||
Args:
|
||||
connection: WebSocket 连接对象
|
||||
message: 客户端消息 {'type': 'xxx', 'data': {...}}
|
||||
ap: Application 实例
|
||||
"""
|
||||
event_type = message.get('type')
|
||||
data = message.get('data', {})
|
||||
|
||||
pipeline_uuid = connection.pipeline_uuid
|
||||
session_type = connection.session_type
|
||||
|
||||
try:
|
||||
webchat_adapter = ap.platform_mgr.webchat_proxy_bot.adapter
|
||||
|
||||
if event_type == 'send_message':
|
||||
# 发送消息到指定会话
|
||||
message_chain_obj = data.get('message_chain', [])
|
||||
client_message_id = data.get('client_message_id')
|
||||
|
||||
if not message_chain_obj:
|
||||
await connection.send('error', {'error': 'message_chain is required', 'error_code': 'INVALID_REQUEST'})
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Received send_message: pipeline={pipeline_uuid}, "
|
||||
f"session={session_type}, "
|
||||
f"client_msg_id={client_message_id}"
|
||||
)
|
||||
|
||||
# 调用 webchat_adapter.send_webchat_message
|
||||
# 消息将通过 reply_message_chunk 自动推送到 WebSocket
|
||||
result = None
|
||||
async for msg in webchat_adapter.send_webchat_message(
|
||||
pipeline_uuid=pipeline_uuid, session_type=session_type, message_chain_obj=message_chain_obj, is_stream=True
|
||||
):
|
||||
result = msg
|
||||
|
||||
# 发送确认
|
||||
if result:
|
||||
await connection.send(
|
||||
'message_sent',
|
||||
{
|
||||
'client_message_id': client_message_id,
|
||||
'server_message_id': result.get('id'),
|
||||
'timestamp': result.get('timestamp'),
|
||||
},
|
||||
)
|
||||
|
||||
elif event_type == 'load_history':
|
||||
# 加载指定会话的历史消息
|
||||
before_message_id = data.get('before_message_id')
|
||||
limit = data.get('limit', 50)
|
||||
|
||||
logger.info(f"Loading history: pipeline={pipeline_uuid}, session={session_type}, limit={limit}")
|
||||
|
||||
# 从对应会话获取历史消息
|
||||
messages = webchat_adapter.get_webchat_messages(pipeline_uuid, session_type)
|
||||
|
||||
# 简单分页:返回最后 limit 条
|
||||
if before_message_id:
|
||||
# TODO: 实现基于 message_id 的分页
|
||||
history_messages = messages[-limit:]
|
||||
else:
|
||||
history_messages = messages[-limit:] if len(messages) > limit else messages
|
||||
|
||||
await connection.send(
|
||||
'history', {'messages': history_messages, 'has_more': len(messages) > len(history_messages)}
|
||||
)
|
||||
|
||||
elif event_type == 'interrupt':
|
||||
# 中断消息
|
||||
message_id = data.get('message_id')
|
||||
logger.info(f"Interrupt requested: message_id={message_id}")
|
||||
|
||||
# TODO: 实现中断逻辑
|
||||
await connection.send('interrupted', {'message_id': message_id, 'partial_content': ''})
|
||||
|
||||
elif event_type == 'ping':
|
||||
# 心跳
|
||||
connection.last_ping = datetime.now()
|
||||
await connection.send('pong', {'timestamp': data.get('timestamp')})
|
||||
|
||||
else:
|
||||
logger.warning(f"Unknown event type: {event_type}")
|
||||
await connection.send('error', {'error': f'Unknown event type: {event_type}', 'error_code': 'UNKNOWN_EVENT'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling event {event_type}: {e}", exc_info=True)
|
||||
await connection.send(
|
||||
'error',
|
||||
{'error': f'Internal server error: {str(e)}', 'error_code': 'INTERNAL_ERROR', 'details': {'event_type': event_type}},
|
||||
)
|
||||
|
||||
|
||||
@group.group_class('pipeline-websocket', '/api/v1/pipelines/<pipeline_uuid>/chat')
|
||||
class PipelineWebSocketRouterGroup(group.RouterGroup):
|
||||
"""流水线调试 WebSocket 路由组"""
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/ws')
|
||||
async def websocket_handler(pipeline_uuid: str):
|
||||
"""WebSocket 连接处理 - 会话隔离
|
||||
|
||||
连接流程:
|
||||
1. 客户端建立 WebSocket 连接
|
||||
2. 客户端发送 connect 事件(携带 session_type 和 token)
|
||||
3. 服务端验证并创建连接对象
|
||||
4. 进入消息循环,处理客户端事件
|
||||
5. 断开时清理连接
|
||||
|
||||
Args:
|
||||
pipeline_uuid: 流水线 UUID
|
||||
"""
|
||||
websocket = quart.websocket._get_current_object()
|
||||
connection_id = str(uuid.uuid4())
|
||||
session_key = None
|
||||
connection = None
|
||||
|
||||
try:
|
||||
# 1. 等待客户端发送 connect 事件
|
||||
first_message = await websocket.receive_json()
|
||||
|
||||
if first_message.get('type') != 'connect':
|
||||
await websocket.send_json(
|
||||
{'type': 'error', 'data': {'error': 'First message must be connect event', 'error_code': 'INVALID_HANDSHAKE'}}
|
||||
)
|
||||
await websocket.close(1008)
|
||||
return
|
||||
|
||||
connect_data = first_message.get('data', {})
|
||||
session_type = connect_data.get('session_type')
|
||||
token = connect_data.get('token')
|
||||
|
||||
# 验证参数
|
||||
if session_type not in ['person', 'group']:
|
||||
await websocket.send_json(
|
||||
{'type': 'error', 'data': {'error': 'session_type must be person or group', 'error_code': 'INVALID_SESSION_TYPE'}}
|
||||
)
|
||||
await websocket.close(1008)
|
||||
return
|
||||
|
||||
# 验证 token
|
||||
if not token:
|
||||
await websocket.send_json(
|
||||
{'type': 'error', 'data': {'error': 'token is required', 'error_code': 'MISSING_TOKEN'}}
|
||||
)
|
||||
await websocket.close(1008)
|
||||
return
|
||||
|
||||
# 验证用户身份
|
||||
try:
|
||||
user = await self.ap.user_service.verify_token(token)
|
||||
if not user:
|
||||
await websocket.send_json({'type': 'error', 'data': {'error': 'Unauthorized', 'error_code': 'UNAUTHORIZED'}})
|
||||
await websocket.close(1008)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification failed: {e}")
|
||||
await websocket.send_json(
|
||||
{'type': 'error', 'data': {'error': 'Token verification failed', 'error_code': 'AUTH_ERROR'}}
|
||||
)
|
||||
await websocket.close(1008)
|
||||
return
|
||||
|
||||
# 2. 创建连接对象并加入连接池
|
||||
connection = WebSocketConnection(
|
||||
connection_id=connection_id,
|
||||
websocket=websocket,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
created_at=datetime.now(),
|
||||
last_ping=datetime.now(),
|
||||
)
|
||||
|
||||
session_key = connection.session_key
|
||||
ws_pool = self.ap.ws_pool
|
||||
ws_pool.add_connection(connection)
|
||||
|
||||
# 3. 发送连接成功事件
|
||||
await connection.send(
|
||||
'connected', {'connection_id': connection_id, 'session_type': session_type, 'pipeline_uuid': pipeline_uuid}
|
||||
)
|
||||
|
||||
logger.info(f"WebSocket connected: {connection_id} [pipeline={pipeline_uuid}, session={session_type}]")
|
||||
|
||||
# 4. 进入消息处理循环
|
||||
while True:
|
||||
try:
|
||||
message = await websocket.receive_json()
|
||||
await handle_client_event(connection, message, self.ap)
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"WebSocket connection cancelled: {connection_id}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error receiving message from {connection_id}: {e}")
|
||||
break
|
||||
|
||||
except quart.exceptions.WebsocketDisconnected:
|
||||
logger.info(f"WebSocket disconnected: {connection_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket error for {connection_id}: {e}", exc_info=True)
|
||||
finally:
|
||||
# 清理连接
|
||||
if connection and session_key:
|
||||
ws_pool = self.ap.ws_pool
|
||||
await ws_pool.remove_connection(connection_id, session_key)
|
||||
logger.info(f"WebSocket connection cleaned up: {connection_id}")
|
||||
@@ -6,7 +6,7 @@ from ... import group
|
||||
@group.group_class('bots', '/api/v1/platform/bots')
|
||||
class BotsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'bots': await self.ap.bot_service.get_bots()})
|
||||
@@ -15,7 +15,7 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
bot_uuid = await self.ap.bot_service.create_bot(json_data)
|
||||
return self.success(data={'uuid': bot_uuid})
|
||||
|
||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(bot_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
bot = await self.ap.bot_service.get_bot(bot_uuid)
|
||||
@@ -30,7 +30,7 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
await self.ap.bot_service.delete_bot(bot_uuid)
|
||||
return self.success()
|
||||
|
||||
@self.route('/<bot_uuid>/logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/<bot_uuid>/logs', methods=['POST'])
|
||||
async def _(bot_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
from_index = json_data.get('from_index', -1)
|
||||
|
||||
@@ -2,10 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import quart
|
||||
import re
|
||||
import httpx
|
||||
import uuid
|
||||
import os
|
||||
|
||||
from .....core import taskmgr
|
||||
from .. import group
|
||||
@@ -49,12 +45,9 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
return self.http_status(404, -1, 'plugin not found')
|
||||
return self.success(data={'plugin': plugin})
|
||||
elif quart.request.method == 'DELETE':
|
||||
delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.delete_plugin(
|
||||
author, plugin_name, delete_data=delete_data, task_context=ctx
|
||||
),
|
||||
self.ap.plugin_connector.delete_plugin(author, plugin_name, task_context=ctx),
|
||||
kind='plugin-operation',
|
||||
name=f'plugin-remove-{plugin_name}',
|
||||
label=f'Removing plugin {plugin_name}',
|
||||
@@ -96,145 +89,23 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
return quart.Response(icon_data, mimetype=mime_type)
|
||||
|
||||
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""Get releases from a GitHub repository URL"""
|
||||
data = await quart.request.json
|
||||
repo_url = data.get('repo_url', '')
|
||||
|
||||
# Parse GitHub repository URL to extract owner and repo
|
||||
# Supports: https://github.com/owner/repo or github.com/owner/repo
|
||||
pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$'
|
||||
match = re.search(pattern, repo_url)
|
||||
|
||||
if not match:
|
||||
return self.http_status(400, -1, 'Invalid GitHub repository URL')
|
||||
|
||||
owner, repo = match.groups()
|
||||
|
||||
try:
|
||||
# Fetch releases from GitHub API
|
||||
url = f'https://api.github.com/repos/{owner}/{repo}/releases'
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
follow_redirects=True,
|
||||
timeout=10,
|
||||
) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
releases = response.json()
|
||||
|
||||
# Format releases data for frontend
|
||||
formatted_releases = []
|
||||
for release in releases:
|
||||
formatted_releases.append(
|
||||
{
|
||||
'id': release['id'],
|
||||
'tag_name': release['tag_name'],
|
||||
'name': release['name'],
|
||||
'published_at': release['published_at'],
|
||||
'prerelease': release['prerelease'],
|
||||
'draft': release['draft'],
|
||||
}
|
||||
)
|
||||
|
||||
return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo})
|
||||
except httpx.RequestError as e:
|
||||
return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
|
||||
|
||||
@self.route(
|
||||
'/github/release-assets',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _() -> str:
|
||||
"""Get assets from a specific GitHub release"""
|
||||
data = await quart.request.json
|
||||
owner = data.get('owner', '')
|
||||
repo = data.get('repo', '')
|
||||
release_id = data.get('release_id', '')
|
||||
|
||||
if not all([owner, repo, release_id]):
|
||||
return self.http_status(400, -1, 'Missing required parameters')
|
||||
|
||||
try:
|
||||
# Fetch release assets from GitHub API
|
||||
url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}'
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
follow_redirects=True,
|
||||
timeout=10,
|
||||
) as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
)
|
||||
response.raise_for_status()
|
||||
release = response.json()
|
||||
|
||||
# Format assets data for frontend
|
||||
formatted_assets = []
|
||||
for asset in release.get('assets', []):
|
||||
formatted_assets.append(
|
||||
{
|
||||
'id': asset['id'],
|
||||
'name': asset['name'],
|
||||
'size': asset['size'],
|
||||
'download_url': asset['browser_download_url'],
|
||||
'content_type': asset['content_type'],
|
||||
}
|
||||
)
|
||||
|
||||
# add zipball as a downloadable asset
|
||||
# formatted_assets.append(
|
||||
# {
|
||||
# "id": 0,
|
||||
# "name": "Source code (zip)",
|
||||
# "size": -1,
|
||||
# "download_url": release["zipball_url"],
|
||||
# "content_type": "application/zip",
|
||||
# }
|
||||
# )
|
||||
|
||||
return self.success(data={'assets': formatted_assets})
|
||||
except httpx.RequestError as e:
|
||||
return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}')
|
||||
|
||||
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""Install plugin from GitHub release asset"""
|
||||
data = await quart.request.json
|
||||
asset_url = data.get('asset_url', '')
|
||||
owner = data.get('owner', '')
|
||||
repo = data.get('repo', '')
|
||||
release_tag = data.get('release_tag', '')
|
||||
|
||||
if not asset_url:
|
||||
return self.http_status(400, -1, 'Missing asset_url parameter')
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
install_info = {
|
||||
'asset_url': asset_url,
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'release_tag': release_tag,
|
||||
'github_url': f'https://github.com/{owner}/{repo}',
|
||||
}
|
||||
|
||||
short_source_str = data['source'][-8:]
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx),
|
||||
self.ap.plugin_mgr.install_plugin(data['source'], task_context=ctx),
|
||||
kind='plugin-operation',
|
||||
name='plugin-install-github',
|
||||
label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}',
|
||||
label=f'Installing plugin from github ...{short_source_str}',
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route(
|
||||
'/install/marketplace',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
@self.route('/install/marketplace', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
data = await quart.request.json
|
||||
|
||||
@@ -271,39 +142,3 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""Upload a file for plugin configuration"""
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
|
||||
# Check file size (10MB limit)
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
file_bytes = file.read()
|
||||
if len(file_bytes) > MAX_FILE_SIZE:
|
||||
return self.http_status(400, -1, 'file size exceeds 10MB limit')
|
||||
|
||||
# Generate unique file key with original extension
|
||||
original_filename = file.filename
|
||||
_, ext = os.path.splitext(original_filename)
|
||||
file_key = f'plugin_config_{uuid.uuid4().hex}{ext}'
|
||||
|
||||
# Save file using storage manager
|
||||
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||
|
||||
return self.success(data={'file_key': file_key})
|
||||
|
||||
@self.route('/config-files/<file_key>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(file_key: str) -> str:
|
||||
"""Delete a plugin configuration file"""
|
||||
# Only allow deletion of files with plugin_config_ prefix for security
|
||||
if not file_key.startswith('plugin_config_'):
|
||||
return self.http_status(400, -1, 'invalid file key')
|
||||
|
||||
try:
|
||||
await self.ap.storage_mgr.storage_provider.delete(file_key)
|
||||
return self.success(data={'deleted': True})
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'failed to delete file: {str(e)}')
|
||||
|
||||
@@ -6,7 +6,7 @@ from ... import group
|
||||
@group.group_class('models/llm', '/api/v1/provider/models/llm')
|
||||
class LLMModelsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})
|
||||
@@ -17,7 +17,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(model_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
model = await self.ap.llm_model_service.get_llm_model(model_uuid)
|
||||
@@ -37,7 +37,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/<model_uuid>/test', methods=['POST'])
|
||||
async def _(model_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
|
||||
@@ -49,7 +49,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
@group.group_class('models/embedding', '/api/v1/provider/models/embedding')
|
||||
class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})
|
||||
@@ -60,7 +60,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(model_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
model = await self.ap.embedding_models_service.get_embedding_model(model_uuid)
|
||||
@@ -80,7 +80,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/<model_uuid>/test', methods=['POST'])
|
||||
async def _(model_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
import traceback
|
||||
|
||||
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('mcp', '/api/v1/mcp')
|
||||
class MCPRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""获取MCP服务器列表"""
|
||||
if quart.request.method == 'GET':
|
||||
servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||
|
||||
return self.success(data={'servers': servers})
|
||||
|
||||
elif quart.request.method == 'POST':
|
||||
data = await quart.request.json
|
||||
|
||||
try:
|
||||
uuid = await self.ap.mcp_service.create_mcp_server(data)
|
||||
return self.success(data={'uuid': uuid})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
|
||||
|
||||
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
"""获取、更新或删除MCP服务器配置"""
|
||||
|
||||
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
|
||||
if server_data is None:
|
||||
return self.http_status(404, -1, 'Server not found')
|
||||
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'server': server_data})
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
try:
|
||||
await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data)
|
||||
return self.success()
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}')
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
try:
|
||||
await self.ap.mcp_service.delete_mcp_server(server_data['uuid'])
|
||||
return self.success()
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
|
||||
|
||||
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
"""测试MCP服务器连接"""
|
||||
server_data = await quart.request.json
|
||||
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
|
||||
return self.success(data={'task_id': task_id})
|
||||
@@ -5,7 +5,6 @@ import os
|
||||
|
||||
import quart
|
||||
import quart_cors
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
|
||||
from ....core import app, entities as core_entities
|
||||
from ....utils import importutil
|
||||
@@ -16,14 +15,12 @@ from .groups import provider as groups_provider
|
||||
from .groups import platform as groups_platform
|
||||
from .groups import pipelines as groups_pipelines
|
||||
from .groups import knowledge as groups_knowledge
|
||||
from .groups import resources as groups_resources
|
||||
|
||||
importutil.import_modules_in_pkg(groups)
|
||||
importutil.import_modules_in_pkg(groups_provider)
|
||||
importutil.import_modules_in_pkg(groups_platform)
|
||||
importutil.import_modules_in_pkg(groups_pipelines)
|
||||
importutil.import_modules_in_pkg(groups_knowledge)
|
||||
importutil.import_modules_in_pkg(groups_resources)
|
||||
|
||||
|
||||
class HTTPController:
|
||||
@@ -36,20 +33,7 @@ class HTTPController:
|
||||
self.quart_app = quart.Quart(__name__)
|
||||
quart_cors.cors(self.quart_app, allow_origin='*')
|
||||
|
||||
# Set maximum content length to prevent large file uploads
|
||||
self.quart_app.config['MAX_CONTENT_LENGTH'] = group.MAX_FILE_SIZE
|
||||
|
||||
async def initialize(self) -> None:
|
||||
# Register custom error handler for file size limit
|
||||
@self.quart_app.errorhandler(RequestEntityTooLarge)
|
||||
async def handle_request_entity_too_large(e):
|
||||
return quart.jsonify(
|
||||
{
|
||||
'code': 400,
|
||||
'msg': 'File size exceeds 10MB limit. Please split large files into smaller parts.',
|
||||
}
|
||||
), 400
|
||||
|
||||
await self.register_routes()
|
||||
|
||||
async def run(self) -> None:
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import apikey
|
||||
|
||||
|
||||
class ApiKeyService:
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_api_keys(self) -> list[dict]:
|
||||
"""Get all API keys"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey))
|
||||
|
||||
keys = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys]
|
||||
|
||||
async def create_api_key(self, name: str, description: str = '') -> dict:
|
||||
"""Create a new API key"""
|
||||
# Generate a secure random API key
|
||||
key = f'lbk_{secrets.token_urlsafe(32)}'
|
||||
|
||||
key_data = {'name': name, 'key': key, 'description': description}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data))
|
||||
|
||||
# Retrieve the created key
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
||||
)
|
||||
created_key = result.first()
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key)
|
||||
|
||||
async def get_api_key(self, key_id: int) -> dict | None:
|
||||
"""Get a specific API key by ID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id)
|
||||
)
|
||||
|
||||
key = result.first()
|
||||
|
||||
if key is None:
|
||||
return None
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key)
|
||||
|
||||
async def verify_api_key(self, key: str) -> bool:
|
||||
"""Verify if an API key is valid"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
||||
)
|
||||
|
||||
key_obj = result.first()
|
||||
return key_obj is not None
|
||||
|
||||
async def delete_api_key(self, key_id: int) -> None:
|
||||
"""Delete an API key"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id)
|
||||
)
|
||||
|
||||
async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None:
|
||||
"""Update an API key's metadata (name, description)"""
|
||||
update_data = {}
|
||||
if name is not None:
|
||||
update_data['name'] = name
|
||||
if description is not None:
|
||||
update_data['description'] = description
|
||||
|
||||
if update_data:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data)
|
||||
)
|
||||
@@ -1,158 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
import uuid
|
||||
import asyncio
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
from ....core import taskmgr
|
||||
from ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus
|
||||
|
||||
|
||||
class MCPService:
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_runtime_info(self, server_name: str) -> dict | None:
|
||||
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
|
||||
if session:
|
||||
return session.get_runtime_info_dict()
|
||||
return None
|
||||
|
||||
async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
|
||||
|
||||
servers = result.all()
|
||||
serialized_servers = [
|
||||
self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers
|
||||
]
|
||||
if contain_runtime_info:
|
||||
for server in serialized_servers:
|
||||
runtime_info = await self.get_runtime_info(server['name'])
|
||||
|
||||
server['runtime_info'] = runtime_info if runtime_info else None
|
||||
|
||||
return serialized_servers
|
||||
|
||||
async def create_mcp_server(self, server_data: dict) -> str:
|
||||
server_data['uuid'] = str(uuid.uuid4())
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid'])
|
||||
)
|
||||
server_entity = result.first()
|
||||
if server_entity:
|
||||
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
|
||||
if self.ap.tool_mgr.mcp_tool_loader:
|
||||
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
|
||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
|
||||
|
||||
return server_data['uuid']
|
||||
|
||||
async def get_mcp_server_by_name(self, server_name: str) -> dict | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
|
||||
)
|
||||
server = result.first()
|
||||
if server is None:
|
||||
return None
|
||||
|
||||
runtime_info = await self.get_runtime_info(server.name)
|
||||
server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
|
||||
server_data['runtime_info'] = runtime_info if runtime_info else None
|
||||
return server_data
|
||||
|
||||
async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
)
|
||||
old_server = result.first()
|
||||
old_server_name = old_server.name if old_server else None
|
||||
old_enable = old_server.enable if old_server else False
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_mcp.MCPServer)
|
||||
.where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
.values(server_data)
|
||||
)
|
||||
|
||||
if self.ap.tool_mgr.mcp_tool_loader:
|
||||
new_enable = server_data.get('enable', False)
|
||||
|
||||
need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions
|
||||
need_start = new_enable
|
||||
|
||||
|
||||
if old_enable and not new_enable:
|
||||
if need_remove:
|
||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
|
||||
|
||||
elif not old_enable and new_enable:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
)
|
||||
updated_server = result.first()
|
||||
if updated_server:
|
||||
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
|
||||
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
|
||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
|
||||
|
||||
elif old_enable and new_enable:
|
||||
if need_remove:
|
||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
)
|
||||
updated_server = result.first()
|
||||
if updated_server:
|
||||
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
|
||||
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
|
||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
|
||||
|
||||
|
||||
async def delete_mcp_server(self, server_uuid: str) -> None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
)
|
||||
server = result.first()
|
||||
server_name = server.name if server else None
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
)
|
||||
|
||||
if server_name and self.ap.tool_mgr.mcp_tool_loader:
|
||||
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
|
||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
||||
|
||||
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
||||
"""测试 MCP 服务器连接并返回任务 ID"""
|
||||
|
||||
runtime_mcp_session: RuntimeMCPSession | None = None
|
||||
|
||||
if server_name != '_':
|
||||
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
|
||||
if runtime_mcp_session is None:
|
||||
raise ValueError(f'Server not found: {server_name}')
|
||||
|
||||
if runtime_mcp_session.status == MCPSessionStatus.ERROR:
|
||||
coroutine = runtime_mcp_session.start()
|
||||
else:
|
||||
coroutine = runtime_mcp_session.refresh()
|
||||
else:
|
||||
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
|
||||
coroutine = runtime_mcp_session.start()
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
coroutine,
|
||||
kind='mcp-operation',
|
||||
name=f'mcp-test-{server_name}',
|
||||
label=f'Testing MCP server {server_name}',
|
||||
context=ctx,
|
||||
)
|
||||
return wrapper.id
|
||||
@@ -1,14 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....provider.modelmgr import requester as model_requester
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
|
||||
|
||||
class LLMModelsService:
|
||||
@@ -105,17 +104,12 @@ class LLMModelsService:
|
||||
else:
|
||||
runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data)
|
||||
|
||||
# 有些模型厂商默认开启了思考功能,测试容易延迟
|
||||
extra_args = model_data.get('extra_args', {})
|
||||
if not extra_args or 'thinking' not in extra_args:
|
||||
extra_args['thinking'] = {'type': 'disabled'}
|
||||
|
||||
await runtime_llm_model.requester.invoke_llm(
|
||||
query=None,
|
||||
model=runtime_llm_model,
|
||||
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
|
||||
messages=[provider_message.Message(role='user', content='Hello, world!')],
|
||||
funcs=[],
|
||||
extra_args=extra_args,
|
||||
extra_args=model_data.get('extra_args', {}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -136,33 +136,3 @@ class PipelineService:
|
||||
)
|
||||
)
|
||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
||||
|
||||
async def update_pipeline_extensions(self, pipeline_uuid: str, bound_plugins: list[dict], bound_mcp_servers: list[str] = None) -> None:
|
||||
"""Update the bound plugins and MCP servers for a pipeline"""
|
||||
# Get current pipeline
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
)
|
||||
)
|
||||
|
||||
pipeline = result.first()
|
||||
if pipeline is None:
|
||||
raise ValueError(f'Pipeline {pipeline_uuid} not found')
|
||||
|
||||
# Update extensions_preferences
|
||||
extensions_preferences = pipeline.extensions_preferences or {}
|
||||
extensions_preferences['plugins'] = bound_plugins
|
||||
if bound_mcp_servers is not None:
|
||||
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
|
||||
.values(extensions_preferences=extensions_preferences)
|
||||
)
|
||||
|
||||
# Reload pipeline to apply changes
|
||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
||||
pipeline = await self.get_pipeline(pipeline_uuid)
|
||||
await self.ap.pipeline_mgr.load_pipeline(pipeline)
|
||||
|
||||
211
pkg/api/http/service/websocket_pool.py
Normal file
211
pkg/api/http/service/websocket_pool.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""WebSocket 连接池管理
|
||||
|
||||
用于管理流水线调试的 WebSocket 连接,支持会话隔离和消息广播。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import quart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebSocketConnection:
|
||||
"""单个 WebSocket 连接"""
|
||||
|
||||
connection_id: str
|
||||
websocket: quart.websocket.WebSocket
|
||||
pipeline_uuid: str
|
||||
session_type: str # 'person' 或 'group'
|
||||
created_at: datetime
|
||||
last_ping: datetime
|
||||
|
||||
@property
|
||||
def session_key(self) -> str:
|
||||
"""会话唯一标识: pipeline_uuid:session_type"""
|
||||
return f"{self.pipeline_uuid}:{self.session_type}"
|
||||
|
||||
async def send(self, event_type: str, data: dict):
|
||||
"""发送事件到客户端
|
||||
|
||||
Args:
|
||||
event_type: 事件类型
|
||||
data: 事件数据
|
||||
"""
|
||||
try:
|
||||
await self.websocket.send_json({"type": event_type, "data": data})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send message to {self.connection_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class WebSocketConnectionPool:
|
||||
"""WebSocket 连接池 - 按会话隔离
|
||||
|
||||
连接池结构:
|
||||
connections[session_key][connection_id] = WebSocketConnection
|
||||
其中 session_key = f"{pipeline_uuid}:{session_type}"
|
||||
|
||||
这样可以确保:
|
||||
- person 和 group 会话完全隔离
|
||||
- 不同 pipeline 的会话隔离
|
||||
- 同一会话的多个连接可以同步接收消息(多标签页)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.connections: dict[str, dict[str, WebSocketConnection]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def add_connection(self, conn: WebSocketConnection):
|
||||
"""添加连接到指定会话
|
||||
|
||||
Args:
|
||||
conn: WebSocket 连接对象
|
||||
"""
|
||||
session_key = conn.session_key
|
||||
|
||||
if session_key not in self.connections:
|
||||
self.connections[session_key] = {}
|
||||
|
||||
self.connections[session_key][conn.connection_id] = conn
|
||||
|
||||
logger.info(
|
||||
f"WebSocket connection added: {conn.connection_id} "
|
||||
f"to session {session_key} "
|
||||
f"(total: {len(self.connections[session_key])} connections)"
|
||||
)
|
||||
|
||||
async def remove_connection(self, connection_id: str, session_key: str):
|
||||
"""从指定会话移除连接
|
||||
|
||||
Args:
|
||||
connection_id: 连接 ID
|
||||
session_key: 会话标识
|
||||
"""
|
||||
async with self._lock:
|
||||
if session_key in self.connections:
|
||||
conn = self.connections[session_key].pop(connection_id, None)
|
||||
|
||||
# 如果该会话没有连接了,清理会话
|
||||
if not self.connections[session_key]:
|
||||
del self.connections[session_key]
|
||||
|
||||
if conn:
|
||||
logger.info(
|
||||
f"WebSocket connection removed: {connection_id} "
|
||||
f"from session {session_key} "
|
||||
f"(remaining: {len(self.connections.get(session_key, {}))} connections)"
|
||||
)
|
||||
|
||||
def get_connection(self, connection_id: str, session_key: str) -> Optional[WebSocketConnection]:
|
||||
"""获取指定连接
|
||||
|
||||
Args:
|
||||
connection_id: 连接 ID
|
||||
session_key: 会话标识
|
||||
|
||||
Returns:
|
||||
WebSocketConnection 或 None
|
||||
"""
|
||||
return self.connections.get(session_key, {}).get(connection_id)
|
||||
|
||||
def get_connections_by_session(self, pipeline_uuid: str, session_type: str) -> list[WebSocketConnection]:
|
||||
"""获取指定会话的所有连接
|
||||
|
||||
Args:
|
||||
pipeline_uuid: 流水线 UUID
|
||||
session_type: 会话类型 ('person' 或 'group')
|
||||
|
||||
Returns:
|
||||
连接列表
|
||||
"""
|
||||
session_key = f"{pipeline_uuid}:{session_type}"
|
||||
return list(self.connections.get(session_key, {}).values())
|
||||
|
||||
async def broadcast_to_session(self, pipeline_uuid: str, session_type: str, event_type: str, data: dict):
|
||||
"""广播消息到指定会话的所有连接
|
||||
|
||||
Args:
|
||||
pipeline_uuid: 流水线 UUID
|
||||
session_type: 会话类型 ('person' 或 'group')
|
||||
event_type: 事件类型
|
||||
data: 事件数据
|
||||
"""
|
||||
connections = self.get_connections_by_session(pipeline_uuid, session_type)
|
||||
|
||||
if not connections:
|
||||
logger.debug(f"No connections for session {pipeline_uuid}:{session_type}, skipping broadcast")
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Broadcasting {event_type} to session {pipeline_uuid}:{session_type}, " f"{len(connections)} connections"
|
||||
)
|
||||
|
||||
# 并发发送到所有连接,忽略失败的连接
|
||||
results = await asyncio.gather(*[conn.send(event_type, data) for conn in connections], return_exceptions=True)
|
||||
|
||||
# 统计失败的连接
|
||||
failed_count = sum(1 for result in results if isinstance(result, Exception))
|
||||
if failed_count > 0:
|
||||
logger.warning(f"Failed to send to {failed_count}/{len(connections)} connections")
|
||||
|
||||
def get_all_sessions(self) -> list[str]:
|
||||
"""获取所有活跃会话的 session_key 列表
|
||||
|
||||
Returns:
|
||||
会话标识列表
|
||||
"""
|
||||
return list(self.connections.keys())
|
||||
|
||||
def get_connection_count(self, pipeline_uuid: str, session_type: str) -> int:
|
||||
"""获取指定会话的连接数量
|
||||
|
||||
Args:
|
||||
pipeline_uuid: 流水线 UUID
|
||||
session_type: 会话类型
|
||||
|
||||
Returns:
|
||||
连接数量
|
||||
"""
|
||||
session_key = f"{pipeline_uuid}:{session_type}"
|
||||
return len(self.connections.get(session_key, {}))
|
||||
|
||||
async def cleanup_stale_connections(self, timeout_seconds: int = 120):
|
||||
"""清理超时的连接
|
||||
|
||||
Args:
|
||||
timeout_seconds: 超时时间(秒)
|
||||
"""
|
||||
now = datetime.now()
|
||||
stale_connections = []
|
||||
|
||||
# 查找超时连接
|
||||
for session_key, session_conns in self.connections.items():
|
||||
for conn_id, conn in session_conns.items():
|
||||
elapsed = (now - conn.last_ping).total_seconds()
|
||||
if elapsed > timeout_seconds:
|
||||
stale_connections.append((conn_id, session_key))
|
||||
|
||||
# 移除超时连接
|
||||
for conn_id, session_key in stale_connections:
|
||||
logger.warning(f"Removing stale connection: {conn_id} from {session_key}")
|
||||
await self.remove_connection(conn_id, session_key)
|
||||
|
||||
# 尝试关闭 WebSocket
|
||||
try:
|
||||
conn = self.get_connection(conn_id, session_key)
|
||||
if conn:
|
||||
await conn.websocket.close(1000, "Connection timeout")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing stale connection {conn_id}: {e}")
|
||||
|
||||
if stale_connections:
|
||||
logger.info(f"Cleaned up {len(stale_connections)} stale connections")
|
||||
@@ -59,15 +59,14 @@ class CommandManager:
|
||||
context: command_context.ExecuteContext,
|
||||
operator_list: list[operator.CommandOperator],
|
||||
operator: operator.CommandOperator = None,
|
||||
bound_plugins: list[str] | None = None,
|
||||
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
|
||||
"""执行命令"""
|
||||
|
||||
command_list = await self.ap.plugin_connector.list_commands(bound_plugins)
|
||||
command_list = await self.ap.plugin_connector.list_commands()
|
||||
|
||||
for command in command_list:
|
||||
if command.metadata.name == context.command:
|
||||
async for ret in self.ap.plugin_connector.execute_command(context, bound_plugins):
|
||||
async for ret in self.ap.plugin_connector.execute_command(context):
|
||||
yield ret
|
||||
break
|
||||
else:
|
||||
@@ -103,8 +102,5 @@ class CommandManager:
|
||||
|
||||
ctx.shift()
|
||||
|
||||
# Get bound plugins from query
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
|
||||
async for ret in self._execute(ctx, self.cmd_list, bound_plugins=bound_plugins):
|
||||
async for ret in self._execute(ctx, self.cmd_list):
|
||||
yield ret
|
||||
|
||||
@@ -22,8 +22,6 @@ from ..api.http.service import model as model_service
|
||||
from ..api.http.service import pipeline as pipeline_service
|
||||
from ..api.http.service import bot as bot_service
|
||||
from ..api.http.service import knowledge as knowledge_service
|
||||
from ..api.http.service import mcp as mcp_service
|
||||
from ..api.http.service import apikey as apikey_service
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
from ..utils import logcache
|
||||
@@ -107,6 +105,10 @@ class Application:
|
||||
|
||||
storage_mgr: storagemgr.StorageMgr = None
|
||||
|
||||
# ========= WebSocket =========
|
||||
|
||||
ws_pool = None # WebSocketConnectionPool
|
||||
|
||||
# ========= HTTP Services =========
|
||||
|
||||
user_service: user_service.UserService = None
|
||||
@@ -121,10 +123,6 @@ class Application:
|
||||
|
||||
knowledge_service: knowledge_service.KnowledgeService = None
|
||||
|
||||
mcp_service: mcp_service.MCPService = None
|
||||
|
||||
apikey_service: apikey_service.ApiKeyService = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ from ...api.http.service import model as model_service
|
||||
from ...api.http.service import pipeline as pipeline_service
|
||||
from ...api.http.service import bot as bot_service
|
||||
from ...api.http.service import knowledge as knowledge_service
|
||||
from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import websocket_pool
|
||||
from ...discover import engine as discover_engine
|
||||
from ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
@@ -89,10 +88,18 @@ class BuildAppStage(stage.BootingStage):
|
||||
await llm_tool_mgr_inst.initialize()
|
||||
ap.tool_mgr = llm_tool_mgr_inst
|
||||
|
||||
# Initialize WebSocket connection pool
|
||||
ws_pool_inst = websocket_pool.WebSocketConnectionPool()
|
||||
ap.ws_pool = ws_pool_inst
|
||||
|
||||
im_mgr_inst = im_mgr.PlatformManager(ap=ap)
|
||||
await im_mgr_inst.initialize()
|
||||
ap.platform_mgr = im_mgr_inst
|
||||
|
||||
# Inject WebSocket pool into WebChatAdapter
|
||||
if hasattr(ap.platform_mgr, 'webchat_proxy_bot') and ap.platform_mgr.webchat_proxy_bot:
|
||||
ap.platform_mgr.webchat_proxy_bot.adapter.set_ws_pool(ws_pool_inst)
|
||||
|
||||
pipeline_mgr = pipelinemgr.PipelineManager(ap)
|
||||
await pipeline_mgr.initialize()
|
||||
ap.pipeline_mgr = pipeline_mgr
|
||||
@@ -128,11 +135,5 @@ class BuildAppStage(stage.BootingStage):
|
||||
knowledge_service_inst = knowledge_service.KnowledgeService(ap)
|
||||
ap.knowledge_service = knowledge_service_inst
|
||||
|
||||
mcp_service_inst = mcp_service.MCPService(ap)
|
||||
ap.mcp_service = mcp_service_inst
|
||||
|
||||
apikey_service_inst = apikey_service.ApiKeyService(ap)
|
||||
ap.apikey_service = apikey_service_inst
|
||||
|
||||
ctrl = controller.Controller(ap)
|
||||
ap.ctrl = ctrl
|
||||
|
||||
@@ -1,93 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .. import stage, app
|
||||
from ..bootutils import config
|
||||
|
||||
|
||||
def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||
"""Apply environment variable overrides to data/config.yaml
|
||||
|
||||
Environment variables should be uppercase and use __ (double underscore)
|
||||
to represent nested keys. For example:
|
||||
- CONCURRENCY__PIPELINE overrides concurrency.pipeline
|
||||
- PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url
|
||||
|
||||
Arrays and dict types are ignored.
|
||||
|
||||
Args:
|
||||
cfg: Configuration dictionary
|
||||
|
||||
Returns:
|
||||
Updated configuration dictionary
|
||||
"""
|
||||
|
||||
def convert_value(value: str, original_value: Any) -> Any:
|
||||
"""Convert string value to appropriate type based on original value
|
||||
|
||||
Args:
|
||||
value: String value from environment variable
|
||||
original_value: Original value to infer type from
|
||||
|
||||
Returns:
|
||||
Converted value (falls back to string if conversion fails)
|
||||
"""
|
||||
if isinstance(original_value, bool):
|
||||
return value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif isinstance(original_value, int):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
# If conversion fails, keep as string (user error, but non-breaking)
|
||||
return value
|
||||
elif isinstance(original_value, float):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
# If conversion fails, keep as string (user error, but non-breaking)
|
||||
return value
|
||||
else:
|
||||
return value
|
||||
|
||||
# Process environment variables
|
||||
for env_key, env_value in os.environ.items():
|
||||
# Check if the environment variable is uppercase and contains __
|
||||
if not env_key.isupper():
|
||||
continue
|
||||
if '__' not in env_key:
|
||||
continue
|
||||
|
||||
print(f'apply env overrides to config: env_key: {env_key}, env_value: {env_value}')
|
||||
|
||||
# Convert environment variable name to config path
|
||||
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
|
||||
keys = [key.lower() for key in env_key.split('__')]
|
||||
|
||||
# Navigate to the target value and validate the path
|
||||
current = cfg
|
||||
|
||||
for i, key in enumerate(keys):
|
||||
if not isinstance(current, dict) or key not in current:
|
||||
break
|
||||
|
||||
if i == len(keys) - 1:
|
||||
# At the final key - check if it's a scalar value
|
||||
if isinstance(current[key], (dict, list)):
|
||||
# Skip dict and list types
|
||||
pass
|
||||
else:
|
||||
# Valid scalar value - convert and set it
|
||||
converted_value = convert_value(env_value, current[key])
|
||||
current[key] = converted_value
|
||||
else:
|
||||
# Navigate deeper
|
||||
current = current[key]
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
@stage.stage_class('LoadConfigStage')
|
||||
class LoadConfigStage(stage.BootingStage):
|
||||
"""Load config file stage"""
|
||||
@@ -136,10 +54,6 @@ class LoadConfigStage(stage.BootingStage):
|
||||
ap.instance_config = await config.load_yaml_config(
|
||||
'data/config.yaml', 'templates/config.yaml', completion=False
|
||||
)
|
||||
|
||||
# Apply environment variable overrides to data/config.yaml
|
||||
ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data)
|
||||
|
||||
await ap.instance_config.dump_config()
|
||||
|
||||
ap.sensitive_meta = await config.load_json_config(
|
||||
|
||||
@@ -156,7 +156,7 @@ class TaskWrapper:
|
||||
'state': self.task._state,
|
||||
'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None,
|
||||
'exception_traceback': exception_traceback,
|
||||
'result': self.assume_result() if self.assume_result() is not None else None,
|
||||
'result': self.assume_result().__str__() if self.assume_result() is not None else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""API Key for external service authentication"""
|
||||
|
||||
__tablename__ = 'api_keys'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class MCPServer(Base):
|
||||
__tablename__ = 'mcp_servers'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
@@ -1,13 +1,12 @@
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
from ...utils import constants
|
||||
|
||||
|
||||
initial_metadata = [
|
||||
{
|
||||
'key': 'database_version',
|
||||
'value': str(constants.required_database_version),
|
||||
'value': '0',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ class LegacyPipeline(Base):
|
||||
is_default = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
stages = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
extensions_preferences = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
|
||||
|
||||
class PipelineRunRecord(Base):
|
||||
|
||||
@@ -78,8 +78,6 @@ class PersistenceManager:
|
||||
|
||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||
|
||||
await self.write_default_pipeline()
|
||||
|
||||
async def create_tables(self):
|
||||
# create tables
|
||||
async with self.get_db_engine().connect() as conn:
|
||||
@@ -100,7 +98,6 @@ class PersistenceManager:
|
||||
if row is None:
|
||||
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
|
||||
|
||||
async def write_default_pipeline(self):
|
||||
# write default pipeline
|
||||
result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))
|
||||
default_pipeline_uuid = None
|
||||
@@ -118,7 +115,6 @@ class PersistenceManager:
|
||||
'name': 'ChatPipeline',
|
||||
'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线',
|
||||
'config': pipeline_config,
|
||||
'extensions_preferences': {},
|
||||
}
|
||||
|
||||
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(9)
|
||||
class DBMigratePipelineExtensionPreferences(migration.DBMigration):
|
||||
"""Pipeline extension preferences"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
|
||||
sql_text = sqlalchemy.text(
|
||||
"ALTER TABLE legacy_pipelines ADD COLUMN extensions_preferences JSON NOT NULL DEFAULT '{}'"
|
||||
)
|
||||
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
sql_text = sqlalchemy.text('ALTER TABLE legacy_pipelines DROP COLUMN extensions_preferences')
|
||||
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||
@@ -68,12 +68,6 @@ class RuntimePipeline:
|
||||
|
||||
stage_containers: list[StageInstContainer]
|
||||
"""阶段实例容器"""
|
||||
|
||||
bound_plugins: list[str]
|
||||
"""绑定到此流水线的插件列表(格式:author/plugin_name)"""
|
||||
|
||||
bound_mcp_servers: list[str]
|
||||
"""绑定到此流水线的MCP服务器列表(格式:uuid)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -84,20 +78,9 @@ class RuntimePipeline:
|
||||
self.ap = ap
|
||||
self.pipeline_entity = pipeline_entity
|
||||
self.stage_containers = stage_containers
|
||||
|
||||
# Extract bound plugins and MCP servers from extensions_preferences
|
||||
extensions_prefs = pipeline_entity.extensions_preferences or {}
|
||||
plugin_list = extensions_prefs.get('plugins', [])
|
||||
self.bound_plugins = [f"{p['author']}/{p['name']}" for p in plugin_list] if plugin_list else []
|
||||
|
||||
mcp_server_list = extensions_prefs.get('mcp_servers', [])
|
||||
self.bound_mcp_servers = mcp_server_list if mcp_server_list else []
|
||||
|
||||
async def run(self, query: pipeline_query.Query):
|
||||
query.pipeline_config = self.pipeline_entity.config
|
||||
# Store bound plugins and MCP servers in query for filtering
|
||||
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
|
||||
query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
|
||||
await self.process_query(query)
|
||||
|
||||
async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult):
|
||||
@@ -205,9 +188,6 @@ class RuntimePipeline:
|
||||
async def process_query(self, query: pipeline_query.Query):
|
||||
"""处理请求"""
|
||||
try:
|
||||
# Get bound plugins for this pipeline
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
|
||||
# ======== 触发 MessageReceived 事件 ========
|
||||
event_type = (
|
||||
events.PersonMessageReceived
|
||||
@@ -223,7 +203,7 @@ class RuntimePipeline:
|
||||
message_chain=query.message_chain,
|
||||
)
|
||||
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event_obj)
|
||||
|
||||
if event_ctx.is_prevented_default():
|
||||
return
|
||||
|
||||
@@ -65,14 +65,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||
|
||||
if llm_model.model_entity.abilities.__contains__('func_call'):
|
||||
# Get bound plugins and MCP servers for filtering tools
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||
|
||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
||||
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools()
|
||||
|
||||
variables = {
|
||||
'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
@@ -137,9 +130,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
query=query,
|
||||
)
|
||||
|
||||
# Get bound plugins for filtering
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event)
|
||||
|
||||
query.prompt.messages = event_ctx.event.default_prompt
|
||||
query.messages = event_ctx.event.prompt
|
||||
|
||||
@@ -43,9 +43,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
query=query,
|
||||
)
|
||||
|
||||
# Get bound plugins for filtering
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event)
|
||||
|
||||
is_create_card = False # 判断下是否需要创建流式卡片
|
||||
|
||||
|
||||
@@ -45,9 +45,7 @@ class CommandHandler(handler.MessageHandler):
|
||||
query=query,
|
||||
)
|
||||
|
||||
# Get bound plugins for filtering
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event)
|
||||
|
||||
if event_ctx.is_prevented_default():
|
||||
if event_ctx.event.reply_message_chain is not None:
|
||||
|
||||
@@ -72,9 +72,7 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
query=query,
|
||||
)
|
||||
|
||||
# Get bound plugins for filtering
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event)
|
||||
|
||||
if event_ctx.is_prevented_default():
|
||||
yield entities.StageProcessResult(
|
||||
@@ -117,9 +115,7 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
query=query,
|
||||
)
|
||||
|
||||
# Get bound plugins for filtering
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event)
|
||||
|
||||
if event_ctx.is_prevented_default():
|
||||
yield entities.StageProcessResult(
|
||||
|
||||
@@ -157,9 +157,6 @@ class PlatformManager:
|
||||
self.adapter_dict = {}
|
||||
|
||||
async def initialize(self):
|
||||
# delete all bot log images
|
||||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
||||
|
||||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||||
for component in self.adapter_components:
|
||||
|
||||
@@ -149,7 +149,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
extension = mimetypes.guess_extension(mime_type)
|
||||
if extension is None:
|
||||
extension = '.jpg'
|
||||
image_key = f'bot_log_images/{message_session_id}-{uuid.uuid4()}{extension}'
|
||||
image_key = f'{message_session_id}-{uuid.uuid4()}{extension}'
|
||||
await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes)
|
||||
image_keys.append(image_key)
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_
|
||||
from ..logger import EventLogger
|
||||
|
||||
|
||||
|
||||
# 语音功能相关异常定义
|
||||
class VoiceConnectionError(Exception):
|
||||
"""语音连接基础异常"""
|
||||
|
||||
@@ -58,6 +58,7 @@ class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
debug_messages: dict[str, list[dict]] = pydantic.Field(default_factory=dict, exclude=True)
|
||||
|
||||
ap: app.Application = pydantic.Field(exclude=True)
|
||||
ws_pool: typing.Any = pydantic.Field(exclude=True, default=None) # WebSocketConnectionPool
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
super().__init__(
|
||||
@@ -72,6 +73,15 @@ class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.bot_account_id = 'webchatbot'
|
||||
|
||||
self.debug_messages = {}
|
||||
self.ws_pool = None
|
||||
|
||||
def set_ws_pool(self, ws_pool):
|
||||
"""设置 WebSocket 连接池
|
||||
|
||||
Args:
|
||||
ws_pool: WebSocketConnectionPool 实例
|
||||
"""
|
||||
self.ws_pool = ws_pool
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -130,7 +140,7 @@ class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
) -> dict:
|
||||
"""回复消息"""
|
||||
"""Reply message chunk - supports both SSE (legacy) and WebSocket"""
|
||||
message_data = WebChatMessage(
|
||||
id=-1,
|
||||
role='assistant',
|
||||
@@ -139,24 +149,32 @@ class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
# notify waiter
|
||||
session = (
|
||||
self.webchat_group_session
|
||||
if isinstance(message_source, platform_events.GroupMessage)
|
||||
else self.webchat_person_session
|
||||
)
|
||||
if message_source.message_chain.message_id not in session.resp_waiters:
|
||||
# session.resp_waiters[message_source.message_chain.message_id] = asyncio.Queue()
|
||||
queue = session.resp_queues[message_source.message_chain.message_id]
|
||||
# Determine session type
|
||||
if isinstance(message_source, platform_events.GroupMessage):
|
||||
session_type = 'group'
|
||||
session = self.webchat_group_session
|
||||
else: # FriendMessage
|
||||
session_type = 'person'
|
||||
session = self.webchat_person_session
|
||||
|
||||
# if isinstance(message_source, platform_events.FriendMessage):
|
||||
# queue = self.webchat_person_session.resp_queues[message_source.message_chain.message_id]
|
||||
# elif isinstance(message_source, platform_events.GroupMessage):
|
||||
# queue = self.webchat_group_session.resp_queues[message_source.message_chain.message_id]
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
message_data.is_final = True
|
||||
# print(message_data)
|
||||
await queue.put(message_data)
|
||||
# Legacy SSE support: put message into queue
|
||||
if message_source.message_chain.message_id in session.resp_queues:
|
||||
queue = session.resp_queues[message_source.message_chain.message_id]
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
message_data.is_final = True
|
||||
await queue.put(message_data)
|
||||
|
||||
# WebSocket support: broadcast to all connections
|
||||
if self.ws_pool:
|
||||
pipeline_uuid = self.ap.platform_mgr.webchat_proxy_bot.bot_entity.use_pipeline_uuid
|
||||
|
||||
# Determine event type
|
||||
event_type = 'message_complete' if (is_final and bot_message.tool_calls is None) else 'message_chunk'
|
||||
|
||||
# Broadcast to specified session only
|
||||
await self.ws_pool.broadcast_to_session(
|
||||
pipeline_uuid=pipeline_uuid, session_type=session_type, event_type=event_type, data=message_data.model_dump()
|
||||
)
|
||||
|
||||
return message_data.model_dump()
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.userid,
|
||||
nickname=event.username,
|
||||
nickname='',
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
@@ -61,10 +61,10 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
sender = platform_entities.GroupMember(
|
||||
id=event.userid,
|
||||
permission='MEMBER',
|
||||
member_name=event.username,
|
||||
member_name=event.userid,
|
||||
group=platform_entities.Group(
|
||||
id=str(event.chatid),
|
||||
name=event.chatname,
|
||||
name='',
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
@@ -117,50 +117,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
content = await self.message_converter.yiri2target(message)
|
||||
await self.bot.set_message(message_source.source_platform_object.message_id, content)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
"""将流水线增量输出写入企业微信 stream 会话。
|
||||
|
||||
Args:
|
||||
message_source: 流水线提供的原始消息事件。
|
||||
bot_message: 当前片段对应的模型元信息(未使用)。
|
||||
message: 需要回复的消息链。
|
||||
quote_origin: 是否引用原消息(企业微信暂不支持)。
|
||||
is_final: 标记当前片段是否为最终回复。
|
||||
|
||||
Returns:
|
||||
dict: 包含 `stream` 键,标识写入是否成功。
|
||||
|
||||
Example:
|
||||
在流水线 `reply_message_chunk` 调用中自动触发,无需手动调用。
|
||||
"""
|
||||
# 转换为纯文本(智能机器人当前协议仅支持文本流)
|
||||
content = await self.message_converter.yiri2target(message)
|
||||
msg_id = message_source.source_platform_object.message_id
|
||||
|
||||
# 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑
|
||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||
if not success and is_final:
|
||||
# 未命中流式队列时使用旧有 set_message 兜底
|
||||
await self.bot.set_message(msg_id, content)
|
||||
return {'stream': success}
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
"""智能机器人侧默认开启流式能力。
|
||||
|
||||
Returns:
|
||||
bool: 恒定返回 True。
|
||||
|
||||
Example:
|
||||
流水线执行阶段会调用此方法以确认是否启用流式。"""
|
||||
return True
|
||||
|
||||
async def send_message(self, target_type, target_id, message):
|
||||
pass
|
||||
|
||||
|
||||
@@ -6,24 +6,19 @@ from typing import Any
|
||||
import typing
|
||||
import os
|
||||
import sys
|
||||
import httpx
|
||||
|
||||
from async_lru import alru_cache
|
||||
|
||||
from ..core import app
|
||||
from . import handler
|
||||
from ..utils import platform
|
||||
from langbot_plugin.runtime.io.controllers.stdio import (
|
||||
client as stdio_client_controller,
|
||||
)
|
||||
from langbot_plugin.runtime.io.controllers.stdio import client as stdio_client_controller
|
||||
from langbot_plugin.runtime.io.controllers.ws import client as ws_client_controller
|
||||
from langbot_plugin.api.entities import events
|
||||
from langbot_plugin.api.entities import context
|
||||
import langbot_plugin.runtime.io.connection as base_connection
|
||||
from langbot_plugin.api.definition.components.manifest import ComponentManifest
|
||||
from langbot_plugin.api.entities.builtin.command import (
|
||||
context as command_context,
|
||||
errors as command_errors,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
from ..core import taskmgr
|
||||
|
||||
@@ -43,10 +38,6 @@ class PluginRuntimeConnector:
|
||||
|
||||
ctrl: stdio_client_controller.StdioClientController | ws_client_controller.WebSocketClientController
|
||||
|
||||
runtime_subprocess_on_windows: asyncio.subprocess.Process | None = None
|
||||
|
||||
runtime_subprocess_on_windows_task: asyncio.Task | None = None
|
||||
|
||||
runtime_disconnect_callback: typing.Callable[
|
||||
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
|
||||
]
|
||||
@@ -67,7 +58,7 @@ class PluginRuntimeConnector:
|
||||
|
||||
async def heartbeat_loop(self):
|
||||
while True:
|
||||
await asyncio.sleep(20)
|
||||
await asyncio.sleep(10)
|
||||
try:
|
||||
await self.ping_plugin_runtime()
|
||||
self.ap.logger.debug('Heartbeat to plugin runtime success.')
|
||||
@@ -80,9 +71,7 @@ class PluginRuntimeConnector:
|
||||
return
|
||||
|
||||
async def new_connection_callback(connection: base_connection.Connection):
|
||||
async def disconnect_callback(
|
||||
rchandler: handler.RuntimeConnectionHandler,
|
||||
) -> bool:
|
||||
async def disconnect_callback(rchandler: handler.RuntimeConnectionHandler) -> bool:
|
||||
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
|
||||
self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...')
|
||||
await self.runtime_disconnect_callback(self)
|
||||
@@ -109,8 +98,7 @@ class PluginRuntimeConnector:
|
||||
)
|
||||
|
||||
async def make_connection_failed_callback(
|
||||
ctrl: ws_client_controller.WebSocketClientController,
|
||||
exc: Exception = None,
|
||||
ctrl: ws_client_controller.WebSocketClientController, exc: Exception = None
|
||||
) -> None:
|
||||
if exc is not None:
|
||||
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')
|
||||
@@ -123,41 +111,6 @@ class PluginRuntimeConnector:
|
||||
make_connection_failed_callback=make_connection_failed_callback,
|
||||
)
|
||||
task = self.ctrl.run(new_connection_callback)
|
||||
elif platform.get_platform() == 'win32':
|
||||
# Due to Windows's lack of supports for both stdio and subprocess:
|
||||
# See also: https://docs.python.org/zh-cn/3.13/library/asyncio-platforms.html
|
||||
# We have to launch runtime via cmd but communicate via ws.
|
||||
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
|
||||
|
||||
python_path = sys.executable
|
||||
env = os.environ.copy()
|
||||
self.runtime_subprocess_on_windows = await asyncio.create_subprocess_exec(
|
||||
python_path,
|
||||
'-m', 'langbot_plugin.cli.__init__', 'rt',
|
||||
env=env,
|
||||
)
|
||||
|
||||
# hold the process
|
||||
self.runtime_subprocess_on_windows_task = asyncio.create_task(self.runtime_subprocess_on_windows.wait())
|
||||
|
||||
ws_url = 'ws://localhost:5400/control/ws'
|
||||
|
||||
async def make_connection_failed_callback(
|
||||
ctrl: ws_client_controller.WebSocketClientController,
|
||||
exc: Exception = None,
|
||||
) -> None:
|
||||
if exc is not None:
|
||||
self.ap.logger.error(f'(windows) Failed to connect to plugin runtime({ws_url}): {exc}')
|
||||
else:
|
||||
self.ap.logger.error(f'(windows) Failed to connect to plugin runtime({ws_url}), trying to reconnect...')
|
||||
await self.runtime_disconnect_callback(self)
|
||||
|
||||
self.ctrl = ws_client_controller.WebSocketClientController(
|
||||
ws_url=ws_url,
|
||||
make_connection_failed_callback=make_connection_failed_callback,
|
||||
)
|
||||
task = self.ctrl.run(new_connection_callback)
|
||||
|
||||
else: # stdio
|
||||
self.ap.logger.info('use stdio to connect to plugin runtime')
|
||||
# cmd: lbp rt -s
|
||||
@@ -197,25 +150,6 @@ class PluginRuntimeConnector:
|
||||
install_info['plugin_file_key'] = file_key
|
||||
del install_info['plugin_file']
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
elif install_source == PluginInstallSource.GITHUB:
|
||||
# download and transfer file
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
follow_redirects=True,
|
||||
timeout=20,
|
||||
) as client:
|
||||
response = await client.get(
|
||||
install_info['asset_url'],
|
||||
)
|
||||
response.raise_for_status()
|
||||
file_bytes = response.content
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to download file from GitHub: {e}')
|
||||
raise Exception(f'Failed to download file from GitHub: {e}')
|
||||
|
||||
async for ret in self.handler.install_plugin(install_source.value, install_info):
|
||||
current_action = ret.get('current_action', None)
|
||||
@@ -229,10 +163,7 @@ class PluginRuntimeConnector:
|
||||
task_context.trace(trace)
|
||||
|
||||
async def upgrade_plugin(
|
||||
self,
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None
|
||||
) -> dict[str, Any]:
|
||||
async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name):
|
||||
current_action = ret.get('current_action', None)
|
||||
@@ -246,11 +177,7 @@ class PluginRuntimeConnector:
|
||||
task_context.trace(trace)
|
||||
|
||||
async def delete_plugin(
|
||||
self,
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
delete_data: bool = False,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None
|
||||
) -> dict[str, Any]:
|
||||
async for ret in self.handler.delete_plugin(plugin_author, plugin_name):
|
||||
current_action = ret.get('current_action', None)
|
||||
@@ -263,12 +190,6 @@ class PluginRuntimeConnector:
|
||||
if task_context is not None:
|
||||
task_context.trace(trace)
|
||||
|
||||
# Clean up plugin settings and binary storage if requested
|
||||
if delete_data:
|
||||
if task_context is not None:
|
||||
task_context.trace('Cleaning up plugin configuration and storage...')
|
||||
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
|
||||
|
||||
async def list_plugins(self) -> list[dict[str, Any]]:
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
@@ -288,62 +209,47 @@ class PluginRuntimeConnector:
|
||||
async def emit_event(
|
||||
self,
|
||||
event: events.BaseEventModel,
|
||||
bound_plugins: list[str] | None = None,
|
||||
) -> context.EventContext:
|
||||
event_ctx = context.EventContext.from_event(event)
|
||||
|
||||
if not self.is_enable_plugin:
|
||||
return event_ctx
|
||||
|
||||
# Pass include_plugins to runtime for filtering
|
||||
event_ctx_result = await self.handler.emit_event(
|
||||
event_ctx.model_dump(serialize_as_any=False), include_plugins=bound_plugins
|
||||
)
|
||||
event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=False))
|
||||
|
||||
event_ctx = context.EventContext.model_validate(event_ctx_result['event_context'])
|
||||
|
||||
return event_ctx
|
||||
|
||||
async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
||||
async def list_tools(self) -> list[ComponentManifest]:
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
|
||||
# Pass include_plugins to runtime for filtering
|
||||
list_tools_data = await self.handler.list_tools(include_plugins=bound_plugins)
|
||||
list_tools_data = await self.handler.list_tools()
|
||||
|
||||
tools = [ComponentManifest.model_validate(tool) for tool in list_tools_data]
|
||||
return [ComponentManifest.model_validate(tool) for tool in list_tools_data]
|
||||
|
||||
return tools
|
||||
|
||||
async def call_tool(
|
||||
self, tool_name: str, parameters: dict[str, Any], bound_plugins: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]:
|
||||
if not self.is_enable_plugin:
|
||||
return {'error': 'Tool not found: plugin system is disabled'}
|
||||
|
||||
# Pass include_plugins to runtime for validation
|
||||
return await self.handler.call_tool(tool_name, parameters, include_plugins=bound_plugins)
|
||||
return await self.handler.call_tool(tool_name, parameters)
|
||||
|
||||
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
||||
async def list_commands(self) -> list[ComponentManifest]:
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
|
||||
# Pass include_plugins to runtime for filtering
|
||||
list_commands_data = await self.handler.list_commands(include_plugins=bound_plugins)
|
||||
list_commands_data = await self.handler.list_commands()
|
||||
|
||||
commands = [ComponentManifest.model_validate(command) for command in list_commands_data]
|
||||
|
||||
return commands
|
||||
return [ComponentManifest.model_validate(command) for command in list_commands_data]
|
||||
|
||||
async def execute_command(
|
||||
self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None
|
||||
self, command_ctx: command_context.ExecuteContext
|
||||
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
|
||||
if not self.is_enable_plugin:
|
||||
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
|
||||
return
|
||||
|
||||
# Pass include_plugins to runtime for validation
|
||||
gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True), include_plugins=bound_plugins)
|
||||
gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True))
|
||||
|
||||
async for ret in gen:
|
||||
cmd_ret = command_context.CommandReturn.model_validate(ret)
|
||||
@@ -351,9 +257,6 @@ class PluginRuntimeConnector:
|
||||
yield cmd_ret
|
||||
|
||||
def dispose(self):
|
||||
# No need to consider the shutdown on Windows
|
||||
# for Windows can kill processes and subprocesses chainly
|
||||
|
||||
if self.is_enable_plugin and isinstance(self.ctrl, stdio_client_controller.StdioClientController):
|
||||
self.ap.logger.info('Terminating plugin runtime process...')
|
||||
self.ctrl.process.terminate()
|
||||
|
||||
@@ -56,9 +56,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
|
||||
)
|
||||
|
||||
setting = result.first()
|
||||
|
||||
if setting is not None:
|
||||
if result.first() is not None:
|
||||
# delete plugin setting
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_plugin.PluginSetting)
|
||||
@@ -73,10 +71,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
plugin_name=plugin_name,
|
||||
install_source=install_source,
|
||||
install_info=install_info,
|
||||
# inherit from existing setting
|
||||
enabled=setting.enabled if setting is not None else True,
|
||||
priority=setting.priority if setting is not None else 0,
|
||||
config=setting.config if setting is not None else {}, # noqa: F821
|
||||
)
|
||||
)
|
||||
|
||||
@@ -436,25 +430,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
},
|
||||
)
|
||||
|
||||
@self.action(RuntimeToLangBotAction.GET_CONFIG_FILE)
|
||||
async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Get a config file by file key"""
|
||||
file_key = data['file_key']
|
||||
|
||||
try:
|
||||
# Load file from storage
|
||||
file_bytes = await self.ap.storage_mgr.storage_provider.load(file_key)
|
||||
|
||||
return handler.ActionResponse.success(
|
||||
data={
|
||||
'file_base64': base64.b64encode(file_bytes).decode('utf-8'),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Failed to load config file {file_key}: {e}',
|
||||
)
|
||||
|
||||
async def ping(self) -> dict[str, Any]:
|
||||
"""Ping the runtime"""
|
||||
return await self.call_action(
|
||||
@@ -554,27 +529,23 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
async def emit_event(
|
||||
self,
|
||||
event_context: dict[str, Any],
|
||||
include_plugins: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Emit event"""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.EMIT_EVENT,
|
||||
{
|
||||
'event_context': event_context,
|
||||
'include_plugins': include_plugins,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def list_tools(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
async def list_tools(self) -> list[dict[str, Any]]:
|
||||
"""List tools"""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.LIST_TOOLS,
|
||||
{
|
||||
'include_plugins': include_plugins,
|
||||
},
|
||||
{},
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
@@ -602,59 +573,34 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
'mime_type': mime_type,
|
||||
}
|
||||
|
||||
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
|
||||
"""Cleanup plugin settings and binary storage"""
|
||||
# Delete plugin settings
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_plugin.PluginSetting)
|
||||
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
|
||||
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
|
||||
)
|
||||
|
||||
# Delete all binary storage for this plugin
|
||||
owner = f'{plugin_author}/{plugin_name}'
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
|
||||
.where(persistence_bstorage.BinaryStorage.owner_type == 'plugin')
|
||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||
)
|
||||
|
||||
async def call_tool(
|
||||
self, tool_name: str, parameters: dict[str, Any], include_plugins: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Call tool"""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.CALL_TOOL,
|
||||
{
|
||||
'tool_name': tool_name,
|
||||
'tool_parameters': parameters,
|
||||
'include_plugins': include_plugins,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
return result['tool_response']
|
||||
|
||||
async def list_commands(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
async def list_commands(self) -> list[dict[str, Any]]:
|
||||
"""List commands"""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.LIST_COMMANDS,
|
||||
{
|
||||
'include_plugins': include_plugins,
|
||||
},
|
||||
{},
|
||||
timeout=10,
|
||||
)
|
||||
return result['commands']
|
||||
|
||||
async def execute_command(
|
||||
self, command_context: dict[str, Any], include_plugins: list[str] | None = None
|
||||
) -> typing.AsyncGenerator[dict[str, Any], None]:
|
||||
async def execute_command(self, command_context: dict[str, Any]) -> typing.AsyncGenerator[dict[str, Any], None]:
|
||||
"""Execute command"""
|
||||
gen = self.call_action_generator(
|
||||
LangBotToRuntimeAction.EXECUTE_COMMAND,
|
||||
{
|
||||
'command_context': command_context,
|
||||
'include_plugins': include_plugins,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
@@ -59,7 +59,7 @@ class ModelManager:
|
||||
try:
|
||||
await self.load_llm_model(llm_model)
|
||||
except provider_errors.RequesterNotFoundError as e:
|
||||
self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping llm model {llm_model.uuid}')
|
||||
self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping model {llm_model.uuid}')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}')
|
||||
|
||||
@@ -67,14 +67,7 @@ class ModelManager:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
|
||||
embedding_models = result.all()
|
||||
for embedding_model in embedding_models:
|
||||
try:
|
||||
await self.load_embedding_model(embedding_model)
|
||||
except provider_errors.RequesterNotFoundError as e:
|
||||
self.ap.logger.warning(
|
||||
f'Requester {e.requester_name} not found, skipping embedding model {embedding_model.uuid}'
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
|
||||
await self.load_embedding_model(embedding_model)
|
||||
|
||||
async def init_runtime_llm_model(
|
||||
self,
|
||||
@@ -114,9 +107,6 @@ class ModelManager:
|
||||
elif isinstance(model_info, dict):
|
||||
model_info = persistence_model.EmbeddingModel(**model_info)
|
||||
|
||||
if model_info.requester not in self.requester_dict:
|
||||
raise provider_errors.RequesterNotFoundError(model_info.requester)
|
||||
|
||||
requester_inst = self.requester_dict[model_info.requester](ap=self.ap, config=model_info.requester_config)
|
||||
|
||||
await requester_inst.initialize()
|
||||
|
||||
@@ -152,7 +152,6 @@ class CozeAPIRunner(runner.RequestRunner):
|
||||
|
||||
event_type = chunk.get('event')
|
||||
data = chunk.get('data', {})
|
||||
# Removed debug print statement to avoid cluttering logs in production
|
||||
|
||||
if event_type == 'conversation.message.delta':
|
||||
# 收集内容
|
||||
@@ -163,7 +162,7 @@ class CozeAPIRunner(runner.RequestRunner):
|
||||
if 'reasoning_content' in data:
|
||||
full_reasoning += data.get('reasoning_content', '')
|
||||
|
||||
elif event_type.split(".")[-1] == 'done' : # 本地部署coze时,结束event不为done
|
||||
elif event_type == 'done':
|
||||
# 保存会话ID
|
||||
if 'conversation_id' in data:
|
||||
conversation_id = data.get('conversation_id')
|
||||
@@ -259,7 +258,7 @@ class CozeAPIRunner(runner.RequestRunner):
|
||||
stop_reasoning = True
|
||||
|
||||
|
||||
elif event_type.split(".")[-1] == 'done' : # 本地部署coze时,结束event不为done
|
||||
elif event_type == 'done':
|
||||
# 保存会话ID
|
||||
if 'conversation_id' in data:
|
||||
conversation_id = data.get('conversation_id')
|
||||
|
||||
@@ -35,7 +35,7 @@ class ToolLoader(abc.ABC):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
async def get_tools(self) -> list[resource_tool.LLMTool]:
|
||||
"""获取所有工具"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import typing
|
||||
from contextlib import AsyncExitStack
|
||||
import traceback
|
||||
import sqlalchemy
|
||||
import asyncio
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
@@ -14,13 +10,6 @@ from mcp.client.sse import sse_client
|
||||
from .. import loader
|
||||
from ....core import app
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
|
||||
|
||||
class MCPSessionStatus(enum.Enum):
|
||||
CONNECTING = 'connecting'
|
||||
CONNECTED = 'connected'
|
||||
ERROR = 'error'
|
||||
|
||||
|
||||
class RuntimeMCPSession:
|
||||
@@ -30,8 +19,6 @@ class RuntimeMCPSession:
|
||||
|
||||
server_name: str
|
||||
|
||||
server_uuid: str
|
||||
|
||||
server_config: dict
|
||||
|
||||
session: ClientSession
|
||||
@@ -40,34 +27,16 @@ class RuntimeMCPSession:
|
||||
|
||||
functions: list[resource_tool.LLMTool] = []
|
||||
|
||||
enable: bool
|
||||
|
||||
# connected: bool
|
||||
status: MCPSessionStatus
|
||||
|
||||
_lifecycle_task: asyncio.Task | None
|
||||
|
||||
_shutdown_event: asyncio.Event
|
||||
|
||||
_ready_event: asyncio.Event
|
||||
|
||||
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
|
||||
def __init__(self, server_name: str, server_config: dict, ap: app.Application):
|
||||
self.server_name = server_name
|
||||
self.server_uuid = server_config.get('uuid', '')
|
||||
self.server_config = server_config
|
||||
self.ap = ap
|
||||
self.enable = enable
|
||||
|
||||
self.session = None
|
||||
|
||||
self.exit_stack = AsyncExitStack()
|
||||
self.functions = []
|
||||
|
||||
self.status = MCPSessionStatus.CONNECTING
|
||||
|
||||
self._lifecycle_task = None
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._ready_event = asyncio.Event()
|
||||
|
||||
async def _init_stdio_python_server(self):
|
||||
server_params = StdioServerParameters(
|
||||
command=self.server_config['command'],
|
||||
@@ -89,7 +58,6 @@ class RuntimeMCPSession:
|
||||
self.server_config['url'],
|
||||
headers=self.server_config.get('headers', {}),
|
||||
timeout=self.server_config.get('timeout', 10),
|
||||
sse_read_timeout=self.server_config.get('ssereadtimeout', 30),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -99,65 +67,19 @@ class RuntimeMCPSession:
|
||||
|
||||
await self.session.initialize()
|
||||
|
||||
async def _lifecycle_loop(self):
|
||||
"""在后台任务中管理整个MCP会话的生命周期"""
|
||||
try:
|
||||
if self.server_config['mode'] == 'stdio':
|
||||
await self._init_stdio_python_server()
|
||||
elif self.server_config['mode'] == 'sse':
|
||||
await self._init_sse_server()
|
||||
else:
|
||||
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
|
||||
async def initialize(self):
|
||||
self.ap.logger.debug(f'初始化 MCP 会话: {self.server_name} {self.server_config}')
|
||||
|
||||
await self.refresh()
|
||||
|
||||
self.status = MCPSessionStatus.CONNECTED
|
||||
|
||||
# 通知start()方法连接已建立
|
||||
self._ready_event.set()
|
||||
|
||||
# 等待shutdown信号
|
||||
await self._shutdown_event.wait()
|
||||
|
||||
except Exception as e:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||
# 即使出错也要设置ready事件,让start()方法知道初始化已完成
|
||||
self._ready_event.set()
|
||||
finally:
|
||||
# 在同一个任务中清理所有资源
|
||||
try:
|
||||
if self.exit_stack:
|
||||
await self.exit_stack.aclose()
|
||||
self.functions.clear()
|
||||
self.session = None
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||
|
||||
async def start(self):
|
||||
if not self.enable:
|
||||
return
|
||||
|
||||
# 创建后台任务来管理生命周期
|
||||
self._lifecycle_task = asyncio.create_task(self._lifecycle_loop())
|
||||
|
||||
# 等待连接建立或失败(带超时)
|
||||
try:
|
||||
await asyncio.wait_for(self._ready_event.wait(), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
raise Exception('Connection timeout after 30 seconds')
|
||||
|
||||
# 检查是否有错误
|
||||
if self.status == MCPSessionStatus.ERROR:
|
||||
raise Exception('Connection failed, please check URL')
|
||||
|
||||
async def refresh(self):
|
||||
self.functions.clear()
|
||||
if self.server_config['mode'] == 'stdio':
|
||||
await self._init_stdio_python_server()
|
||||
elif self.server_config['mode'] == 'sse':
|
||||
await self._init_sse_server()
|
||||
else:
|
||||
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
|
||||
|
||||
tools = await self.session.list_tools()
|
||||
|
||||
self.ap.logger.debug(f'Refresh MCP tools: {tools}')
|
||||
self.ap.logger.debug(f'获取 MCP 工具: {tools}')
|
||||
|
||||
for tool in tools.tools:
|
||||
|
||||
@@ -179,220 +101,58 @@ class RuntimeMCPSession:
|
||||
)
|
||||
)
|
||||
|
||||
def get_tools(self) -> list[resource_tool.LLMTool]:
|
||||
return self.functions
|
||||
|
||||
def get_runtime_info_dict(self) -> dict:
|
||||
return {
|
||||
'status': self.status.value,
|
||||
'tool_count': len(self.get_tools()),
|
||||
'tools': [
|
||||
{
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
}
|
||||
for tool in self.get_tools()
|
||||
],
|
||||
}
|
||||
|
||||
async def shutdown(self):
|
||||
"""关闭会话并清理资源"""
|
||||
try:
|
||||
# 设置shutdown事件,通知lifecycle任务退出
|
||||
self._shutdown_event.set()
|
||||
|
||||
# 等待lifecycle任务完成(带超时)
|
||||
if self._lifecycle_task and not self._lifecycle_task.done():
|
||||
try:
|
||||
await asyncio.wait_for(self._lifecycle_task, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
self.ap.logger.warning(f'MCP session {self.server_name} shutdown timeout, cancelling task')
|
||||
self._lifecycle_task.cancel()
|
||||
try:
|
||||
await self._lifecycle_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.ap.logger.info(f'MCP session {self.server_name} shutdown complete')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Error shutting down MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||
"""关闭工具"""
|
||||
await self.session._exit_stack.aclose()
|
||||
|
||||
|
||||
# @loader.loader_class('mcp')
|
||||
@loader.loader_class('mcp')
|
||||
class MCPLoader(loader.ToolLoader):
|
||||
"""MCP 工具加载器。
|
||||
|
||||
在此加载器中管理所有与 MCP Server 的连接。
|
||||
"""
|
||||
|
||||
sessions: dict[str, RuntimeMCPSession]
|
||||
sessions: dict[str, RuntimeMCPSession] = {}
|
||||
|
||||
_last_listed_functions: list[resource_tool.LLMTool]
|
||||
|
||||
_hosted_mcp_tasks: list[asyncio.Task]
|
||||
_last_listed_functions: list[resource_tool.LLMTool] = []
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
super().__init__(ap)
|
||||
self.sessions = {}
|
||||
self._last_listed_functions = []
|
||||
self._hosted_mcp_tasks = []
|
||||
|
||||
async def initialize(self):
|
||||
await self.load_mcp_servers_from_db()
|
||||
|
||||
async def load_mcp_servers_from_db(self):
|
||||
self.ap.logger.info('Loading MCP servers from db...')
|
||||
|
||||
self.sessions = {}
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
|
||||
servers = result.all()
|
||||
|
||||
for server in servers:
|
||||
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
|
||||
|
||||
task = asyncio.create_task(self.host_mcp_server(config))
|
||||
self._hosted_mcp_tasks.append(task)
|
||||
|
||||
async def host_mcp_server(self, server_config: dict):
|
||||
self.ap.logger.debug(f'Loading MCP server {server_config}')
|
||||
try:
|
||||
session = await self.load_mcp_server(server_config)
|
||||
for server_config in self.ap.instance_config.data.get('mcp', {}).get('servers', []):
|
||||
if not server_config['enable']:
|
||||
continue
|
||||
session = RuntimeMCPSession(server_config['name'], server_config, self.ap)
|
||||
await session.initialize()
|
||||
# self.ap.event_loop.create_task(session.initialize())
|
||||
self.sessions[server_config['name']] = session
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
|
||||
)
|
||||
return
|
||||
|
||||
self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})')
|
||||
try:
|
||||
await session.start()
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
f'Failed to start MCP server {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
|
||||
)
|
||||
return
|
||||
|
||||
self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})')
|
||||
|
||||
async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession:
|
||||
"""加载 MCP 服务器到运行时
|
||||
|
||||
Args:
|
||||
server_config: 服务器配置字典,必须包含:
|
||||
- name: 服务器名称
|
||||
- mode: 连接模式 (stdio/sse)
|
||||
- enable: 是否启用
|
||||
- extra_args: 额外的配置参数 (可选)
|
||||
"""
|
||||
|
||||
name = server_config['name']
|
||||
uuid = server_config['uuid']
|
||||
mode = server_config['mode']
|
||||
enable = server_config['enable']
|
||||
extra_args = server_config.get('extra_args', {})
|
||||
|
||||
mixed_config = {
|
||||
'name': name,
|
||||
'uuid': uuid,
|
||||
'mode': mode,
|
||||
'enable': enable,
|
||||
**extra_args,
|
||||
}
|
||||
|
||||
session = RuntimeMCPSession(name, mixed_config, enable, self.ap)
|
||||
|
||||
return session
|
||||
|
||||
async def get_tools(self, bound_mcp_servers: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
async def get_tools(self) -> list[resource_tool.LLMTool]:
|
||||
all_functions = []
|
||||
|
||||
for session in self.sessions.values():
|
||||
# If bound_mcp_servers is specified, only include tools from those servers
|
||||
if bound_mcp_servers is not None:
|
||||
if session.server_uuid in bound_mcp_servers:
|
||||
all_functions.extend(session.get_tools())
|
||||
else:
|
||||
# If no bound servers specified, include all tools
|
||||
all_functions.extend(session.get_tools())
|
||||
all_functions.extend(session.functions)
|
||||
|
||||
self._last_listed_functions = all_functions
|
||||
|
||||
return all_functions
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
"""检查工具是否存在"""
|
||||
for session in self.sessions.values():
|
||||
for function in session.get_tools():
|
||||
if function.name == name:
|
||||
return True
|
||||
return False
|
||||
return name in [f.name for f in self._last_listed_functions]
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
|
||||
"""执行工具调用"""
|
||||
for session in self.sessions.values():
|
||||
for function in session.get_tools():
|
||||
if function.name == name:
|
||||
self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}')
|
||||
try:
|
||||
result = await function.func(**parameters)
|
||||
self.ap.logger.debug(f'MCP tool {name} executed successfully')
|
||||
return result
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Error invoking MCP tool {name}: {e}\n{traceback.format_exc()}')
|
||||
raise
|
||||
|
||||
raise ValueError(f'Tool not found: {name}')
|
||||
|
||||
async def remove_mcp_server(self, server_name: str):
|
||||
"""移除 MCP 服务器"""
|
||||
if server_name not in self.sessions:
|
||||
self.ap.logger.warning(f'MCP server {server_name} not found in sessions, skipping removal')
|
||||
return
|
||||
|
||||
session = self.sessions.pop(server_name)
|
||||
await session.shutdown()
|
||||
self.ap.logger.info(f'Removed MCP server: {server_name}')
|
||||
|
||||
def get_session(self, server_name: str) -> RuntimeMCPSession | None:
|
||||
"""获取指定名称的 MCP 会话"""
|
||||
return self.sessions.get(server_name)
|
||||
|
||||
def has_session(self, server_name: str) -> bool:
|
||||
"""检查是否存在指定名称的 MCP 会话"""
|
||||
return server_name in self.sessions
|
||||
|
||||
def get_all_server_names(self) -> list[str]:
|
||||
"""获取所有已加载的 MCP 服务器名称"""
|
||||
return list(self.sessions.keys())
|
||||
|
||||
def get_server_tool_count(self, server_name: str) -> int:
|
||||
"""获取指定服务器的工具数量"""
|
||||
session = self.get_session(server_name)
|
||||
return len(session.get_tools()) if session else 0
|
||||
|
||||
def get_all_servers_info(self) -> dict[str, dict]:
|
||||
"""获取所有服务器的信息"""
|
||||
info = {}
|
||||
for server_name, session in self.sessions.items():
|
||||
info[server_name] = {
|
||||
'name': server_name,
|
||||
'mode': session.server_config.get('mode'),
|
||||
'enable': session.enable,
|
||||
'tools_count': len(session.get_tools()),
|
||||
'tool_names': [f.name for f in session.get_tools()],
|
||||
}
|
||||
return info
|
||||
for function in session.functions:
|
||||
if function.name == name:
|
||||
return await function.func(**parameters)
|
||||
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
|
||||
async def shutdown(self):
|
||||
"""关闭所有工具"""
|
||||
self.ap.logger.info('Shutting down all MCP sessions...')
|
||||
for server_name, session in list(self.sessions.items()):
|
||||
try:
|
||||
await session.shutdown()
|
||||
self.ap.logger.debug(f'Shutdown MCP session: {server_name}')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}')
|
||||
self.sessions.clear()
|
||||
self.ap.logger.info('All MCP sessions shutdown complete')
|
||||
"""关闭工具"""
|
||||
for session in self.sessions.values():
|
||||
await session.shutdown()
|
||||
|
||||
@@ -7,18 +7,18 @@ from .. import loader
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
|
||||
# @loader.loader_class('plugin-tool-loader')
|
||||
@loader.loader_class('plugin-tool-loader')
|
||||
class PluginToolLoader(loader.ToolLoader):
|
||||
"""插件工具加载器。
|
||||
|
||||
本加载器中不存储工具信息,仅负责从插件系统中获取工具信息。
|
||||
"""
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
async def get_tools(self) -> list[resource_tool.LLMTool]:
|
||||
# 从插件系统获取工具(内容函数)
|
||||
all_functions: list[resource_tool.LLMTool] = []
|
||||
|
||||
for tool in await self.ap.plugin_connector.list_tools(bound_plugins):
|
||||
for tool in await self.ap.plugin_connector.list_tools():
|
||||
tool_obj = resource_tool.LLMTool(
|
||||
name=tool.metadata.name,
|
||||
human_desc=tool.metadata.description.en_US,
|
||||
|
||||
@@ -3,9 +3,9 @@ from __future__ import annotations
|
||||
import typing
|
||||
|
||||
from ...core import app
|
||||
from . import loader as tools_loader
|
||||
from ...utils import importutil
|
||||
from . import loaders
|
||||
from .loaders import mcp as mcp_loader, plugin as plugin_loader
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
importutil.import_modules_in_pkg(loaders)
|
||||
@@ -16,24 +16,25 @@ class ToolManager:
|
||||
|
||||
ap: app.Application
|
||||
|
||||
plugin_tool_loader: plugin_loader.PluginToolLoader
|
||||
mcp_tool_loader: mcp_loader.MCPLoader
|
||||
loaders: list[tools_loader.ToolLoader]
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
self.all_functions = []
|
||||
self.loaders = []
|
||||
|
||||
async def initialize(self):
|
||||
self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap)
|
||||
await self.plugin_tool_loader.initialize()
|
||||
self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)
|
||||
await self.mcp_tool_loader.initialize()
|
||||
for loader_cls in tools_loader.preregistered_loaders:
|
||||
loader_inst = loader_cls(self.ap)
|
||||
await loader_inst.initialize()
|
||||
self.loaders.append(loader_inst)
|
||||
|
||||
async def get_all_tools(self, bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
async def get_all_tools(self) -> list[resource_tool.LLMTool]:
|
||||
"""获取所有函数"""
|
||||
all_functions: list[resource_tool.LLMTool] = []
|
||||
|
||||
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
|
||||
all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers))
|
||||
for loader in self.loaders:
|
||||
all_functions.extend(await loader.get_tools())
|
||||
|
||||
return all_functions
|
||||
|
||||
@@ -92,14 +93,13 @@ class ToolManager:
|
||||
async def execute_func_call(self, name: str, parameters: dict) -> typing.Any:
|
||||
"""执行函数调用"""
|
||||
|
||||
if await self.plugin_tool_loader.has_tool(name):
|
||||
return await self.plugin_tool_loader.invoke_tool(name, parameters)
|
||||
elif await self.mcp_tool_loader.has_tool(name):
|
||||
return await self.mcp_tool_loader.invoke_tool(name, parameters)
|
||||
for loader in self.loaders:
|
||||
if await loader.has_tool(name):
|
||||
return await loader.invoke_tool(name, parameters)
|
||||
else:
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
|
||||
async def shutdown(self):
|
||||
"""关闭所有工具"""
|
||||
await self.plugin_tool_loader.shutdown()
|
||||
await self.mcp_tool_loader.shutdown()
|
||||
for loader in self.loaders:
|
||||
await loader.shutdown()
|
||||
|
||||
@@ -4,7 +4,6 @@ import json
|
||||
from typing import List
|
||||
from pkg.rag.knowledge.services import base_service
|
||||
from pkg.core import app
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
|
||||
class Chunker(base_service.BaseService):
|
||||
@@ -28,6 +27,21 @@ class Chunker(base_service.BaseService):
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
# words = text.split()
|
||||
# chunks = []
|
||||
# current_chunk = []
|
||||
|
||||
# for word in words:
|
||||
# current_chunk.append(word)
|
||||
# if len(current_chunk) > self.chunk_size:
|
||||
# chunks.append(" ".join(current_chunk[:self.chunk_size]))
|
||||
# current_chunk = current_chunk[self.chunk_size - self.chunk_overlap:]
|
||||
|
||||
# if current_chunk:
|
||||
# chunks.append(" ".join(current_chunk))
|
||||
|
||||
# A more robust chunking strategy (e.g., using recursive character text splitter)
|
||||
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
||||
|
||||
text_splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=self.chunk_size,
|
||||
|
||||
@@ -42,10 +42,3 @@ class StorageProvider(abc.ABC):
|
||||
key: str,
|
||||
):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_dir_recursive(
|
||||
self,
|
||||
dir_path: str,
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import aiofiles
|
||||
import shutil
|
||||
|
||||
from ...core import app
|
||||
|
||||
@@ -23,8 +22,6 @@ class LocalStorageProvider(provider.StorageProvider):
|
||||
key: str,
|
||||
value: bytes,
|
||||
):
|
||||
if not os.path.exists(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key))):
|
||||
os.makedirs(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key)))
|
||||
async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'wb') as f:
|
||||
await f.write(value)
|
||||
|
||||
@@ -46,11 +43,3 @@ class LocalStorageProvider(provider.StorageProvider):
|
||||
key: str,
|
||||
):
|
||||
os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
|
||||
|
||||
async def delete_dir_recursive(
|
||||
self,
|
||||
dir_path: str,
|
||||
):
|
||||
# 直接删除整个目录
|
||||
if os.path.exists(os.path.join(LOCAL_STORAGE_PATH, dir_path)):
|
||||
shutil.rmtree(os.path.join(LOCAL_STORAGE_PATH, dir_path))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
semantic_version = 'v4.4.1'
|
||||
semantic_version = 'v4.3.9'
|
||||
|
||||
required_database_version = 9
|
||||
required_database_version = 8
|
||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||
|
||||
debug_mode = False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.4.1"
|
||||
version = "4.3.9"
|
||||
description = "Easy-to-use global IM bot platform designed for LLM era"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10.1,<4.0"
|
||||
@@ -60,10 +60,9 @@ dependencies = [
|
||||
"ebooklib>=0.18",
|
||||
"html2text>=2024.2.26",
|
||||
"langchain>=0.2.0",
|
||||
"langchain-text-splitters>=0.0.1",
|
||||
"chromadb>=0.4.24",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.1.9b2",
|
||||
"langbot-plugin==0.1.4",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
|
||||
@@ -10,6 +10,8 @@ command:
|
||||
concurrency:
|
||||
pipeline: 20
|
||||
session: 1
|
||||
mcp:
|
||||
servers: []
|
||||
proxy:
|
||||
http: ''
|
||||
https: ''
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Config unit tests
|
||||
@@ -1,332 +0,0 @@
|
||||
"""
|
||||
Tests for environment variable override functionality in YAML config
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||
"""Apply environment variable overrides to data/config.yaml
|
||||
|
||||
Environment variables should be uppercase and use __ (double underscore)
|
||||
to represent nested keys. For example:
|
||||
- CONCURRENCY__PIPELINE overrides concurrency.pipeline
|
||||
- PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url
|
||||
|
||||
Arrays and dict types are ignored.
|
||||
|
||||
Args:
|
||||
cfg: Configuration dictionary
|
||||
|
||||
Returns:
|
||||
Updated configuration dictionary
|
||||
"""
|
||||
def convert_value(value: str, original_value: Any) -> Any:
|
||||
"""Convert string value to appropriate type based on original value
|
||||
|
||||
Args:
|
||||
value: String value from environment variable
|
||||
original_value: Original value to infer type from
|
||||
|
||||
Returns:
|
||||
Converted value (falls back to string if conversion fails)
|
||||
"""
|
||||
if isinstance(original_value, bool):
|
||||
return value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif isinstance(original_value, int):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
# If conversion fails, keep as string (user error, but non-breaking)
|
||||
return value
|
||||
elif isinstance(original_value, float):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
# If conversion fails, keep as string (user error, but non-breaking)
|
||||
return value
|
||||
else:
|
||||
return value
|
||||
|
||||
# Process environment variables
|
||||
for env_key, env_value in os.environ.items():
|
||||
# Check if the environment variable is uppercase and contains __
|
||||
if not env_key.isupper():
|
||||
continue
|
||||
if '__' not in env_key:
|
||||
continue
|
||||
|
||||
# Convert environment variable name to config path
|
||||
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
|
||||
keys = [key.lower() for key in env_key.split('__')]
|
||||
|
||||
# Navigate to the target value and validate the path
|
||||
current = cfg
|
||||
|
||||
for i, key in enumerate(keys):
|
||||
if not isinstance(current, dict) or key not in current:
|
||||
break
|
||||
|
||||
if i == len(keys) - 1:
|
||||
# At the final key - check if it's a scalar value
|
||||
if isinstance(current[key], (dict, list)):
|
||||
# Skip dict and list types
|
||||
pass
|
||||
else:
|
||||
# Valid scalar value - convert and set it
|
||||
converted_value = convert_value(env_value, current[key])
|
||||
current[key] = converted_value
|
||||
else:
|
||||
# Navigate deeper
|
||||
current = current[key]
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
class TestEnvOverrides:
|
||||
"""Test environment variable override functionality"""
|
||||
|
||||
def test_simple_string_override(self):
|
||||
"""Test overriding a simple string value"""
|
||||
cfg = {
|
||||
'api': {
|
||||
'port': 5300
|
||||
}
|
||||
}
|
||||
|
||||
# Set environment variable
|
||||
os.environ['API__PORT'] = '8080'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['api']['port'] == 8080
|
||||
|
||||
# Cleanup
|
||||
del os.environ['API__PORT']
|
||||
|
||||
def test_nested_key_override(self):
|
||||
"""Test overriding nested keys with __ delimiter"""
|
||||
cfg = {
|
||||
'concurrency': {
|
||||
'pipeline': 20,
|
||||
'session': 1
|
||||
}
|
||||
}
|
||||
|
||||
os.environ['CONCURRENCY__PIPELINE'] = '50'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['concurrency']['pipeline'] == 50
|
||||
assert result['concurrency']['session'] == 1 # Unchanged
|
||||
|
||||
del os.environ['CONCURRENCY__PIPELINE']
|
||||
|
||||
def test_deep_nested_override(self):
|
||||
"""Test overriding deeply nested keys"""
|
||||
cfg = {
|
||||
'system': {
|
||||
'jwt': {
|
||||
'expire': 604800,
|
||||
'secret': ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
os.environ['SYSTEM__JWT__EXPIRE'] = '86400'
|
||||
os.environ['SYSTEM__JWT__SECRET'] = 'my_secret_key'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['system']['jwt']['expire'] == 86400
|
||||
assert result['system']['jwt']['secret'] == 'my_secret_key'
|
||||
|
||||
del os.environ['SYSTEM__JWT__EXPIRE']
|
||||
del os.environ['SYSTEM__JWT__SECRET']
|
||||
|
||||
def test_underscore_in_key(self):
|
||||
"""Test keys with underscores like runtime_ws_url"""
|
||||
cfg = {
|
||||
'plugin': {
|
||||
'enable': True,
|
||||
'runtime_ws_url': 'ws://localhost:5400/control/ws'
|
||||
}
|
||||
}
|
||||
|
||||
os.environ['PLUGIN__RUNTIME_WS_URL'] = 'ws://newhost:6000/ws'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['plugin']['runtime_ws_url'] == 'ws://newhost:6000/ws'
|
||||
|
||||
del os.environ['PLUGIN__RUNTIME_WS_URL']
|
||||
|
||||
def test_boolean_conversion(self):
|
||||
"""Test boolean value conversion"""
|
||||
cfg = {
|
||||
'plugin': {
|
||||
'enable': True,
|
||||
'enable_marketplace': False
|
||||
}
|
||||
}
|
||||
|
||||
os.environ['PLUGIN__ENABLE'] = 'false'
|
||||
os.environ['PLUGIN__ENABLE_MARKETPLACE'] = 'true'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['plugin']['enable'] is False
|
||||
assert result['plugin']['enable_marketplace'] is True
|
||||
|
||||
del os.environ['PLUGIN__ENABLE']
|
||||
del os.environ['PLUGIN__ENABLE_MARKETPLACE']
|
||||
|
||||
def test_ignore_dict_type(self):
|
||||
"""Test that dict types are ignored"""
|
||||
cfg = {
|
||||
'database': {
|
||||
'use': 'sqlite',
|
||||
'sqlite': {
|
||||
'path': 'data/langbot.db'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Try to override a dict value - should be ignored
|
||||
os.environ['DATABASE__SQLITE'] = 'new_value'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
# Should remain a dict, not overridden
|
||||
assert isinstance(result['database']['sqlite'], dict)
|
||||
assert result['database']['sqlite']['path'] == 'data/langbot.db'
|
||||
|
||||
del os.environ['DATABASE__SQLITE']
|
||||
|
||||
def test_ignore_list_type(self):
|
||||
"""Test that list/array types are ignored"""
|
||||
cfg = {
|
||||
'admins': ['admin1', 'admin2'],
|
||||
'command': {
|
||||
'enable': True,
|
||||
'prefix': ['!', '!']
|
||||
}
|
||||
}
|
||||
|
||||
# Try to override list values - should be ignored
|
||||
os.environ['ADMINS'] = 'admin3'
|
||||
os.environ['COMMAND__PREFIX'] = '?'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
# Should remain lists, not overridden
|
||||
assert isinstance(result['admins'], list)
|
||||
assert result['admins'] == ['admin1', 'admin2']
|
||||
assert isinstance(result['command']['prefix'], list)
|
||||
assert result['command']['prefix'] == ['!', '!']
|
||||
|
||||
del os.environ['ADMINS']
|
||||
del os.environ['COMMAND__PREFIX']
|
||||
|
||||
def test_lowercase_env_var_ignored(self):
|
||||
"""Test that lowercase environment variables are ignored"""
|
||||
cfg = {
|
||||
'api': {
|
||||
'port': 5300
|
||||
}
|
||||
}
|
||||
|
||||
os.environ['api__port'] = '8080'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
# Should not be overridden
|
||||
assert result['api']['port'] == 5300
|
||||
|
||||
del os.environ['api__port']
|
||||
|
||||
def test_no_double_underscore_ignored(self):
|
||||
"""Test that env vars without __ are ignored"""
|
||||
cfg = {
|
||||
'api': {
|
||||
'port': 5300
|
||||
}
|
||||
}
|
||||
|
||||
os.environ['APIPORT'] = '8080'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
# Should not be overridden
|
||||
assert result['api']['port'] == 5300
|
||||
|
||||
del os.environ['APIPORT']
|
||||
|
||||
def test_nonexistent_key_ignored(self):
|
||||
"""Test that env vars for non-existent keys are ignored"""
|
||||
cfg = {
|
||||
'api': {
|
||||
'port': 5300
|
||||
}
|
||||
}
|
||||
|
||||
os.environ['API__NONEXISTENT'] = 'value'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
# Should not create new key
|
||||
assert 'nonexistent' not in result['api']
|
||||
|
||||
del os.environ['API__NONEXISTENT']
|
||||
|
||||
def test_integer_conversion(self):
|
||||
"""Test integer value conversion"""
|
||||
cfg = {
|
||||
'concurrency': {
|
||||
'pipeline': 20
|
||||
}
|
||||
}
|
||||
|
||||
os.environ['CONCURRENCY__PIPELINE'] = '100'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['concurrency']['pipeline'] == 100
|
||||
assert isinstance(result['concurrency']['pipeline'], int)
|
||||
|
||||
del os.environ['CONCURRENCY__PIPELINE']
|
||||
|
||||
def test_multiple_overrides(self):
|
||||
"""Test multiple environment variable overrides at once"""
|
||||
cfg = {
|
||||
'api': {
|
||||
'port': 5300
|
||||
},
|
||||
'concurrency': {
|
||||
'pipeline': 20,
|
||||
'session': 1
|
||||
},
|
||||
'plugin': {
|
||||
'enable': False
|
||||
}
|
||||
}
|
||||
|
||||
os.environ['API__PORT'] = '8080'
|
||||
os.environ['CONCURRENCY__PIPELINE'] = '50'
|
||||
os.environ['PLUGIN__ENABLE'] = 'true'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['api']['port'] == 8080
|
||||
assert result['concurrency']['pipeline'] == 50
|
||||
assert result['plugin']['enable'] is True
|
||||
|
||||
del os.environ['API__PORT']
|
||||
del os.environ['CONCURRENCY__PIPELINE']
|
||||
del os.environ['PLUGIN__ENABLE']
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
@@ -5,6 +5,7 @@ PipelineManager unit tests
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from importlib import import_module
|
||||
import sqlalchemy
|
||||
|
||||
|
||||
def get_pipelinemgr_module():
|
||||
@@ -53,7 +54,6 @@ async def test_load_pipeline(mock_app):
|
||||
pipeline_entity.uuid = 'test-uuid'
|
||||
pipeline_entity.stages = []
|
||||
pipeline_entity.config = {'test': 'config'}
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
|
||||
await manager.load_pipeline(pipeline_entity)
|
||||
|
||||
@@ -77,7 +77,6 @@ async def test_get_pipeline_by_uuid(mock_app):
|
||||
pipeline_entity.uuid = 'test-uuid'
|
||||
pipeline_entity.stages = []
|
||||
pipeline_entity.config = {}
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
|
||||
await manager.load_pipeline(pipeline_entity)
|
||||
|
||||
@@ -107,7 +106,6 @@ async def test_remove_pipeline(mock_app):
|
||||
pipeline_entity.uuid = 'test-uuid'
|
||||
pipeline_entity.stages = []
|
||||
pipeline_entity.config = {}
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
|
||||
await manager.load_pipeline(pipeline_entity)
|
||||
assert len(manager.pipelines) == 1
|
||||
@@ -136,7 +134,6 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
|
||||
|
||||
# Make it look like ResultType.CONTINUE
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
CONTINUE = MagicMock()
|
||||
CONTINUE.__eq__ = lambda self, other: True # Always equal for comparison
|
||||
mock_result.result_type = CONTINUE
|
||||
@@ -150,7 +147,6 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
|
||||
# Create pipeline entity
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.config = sample_query.pipeline_config
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
|
||||
# Create runtime pipeline
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [stage_container])
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
|
||||
@@ -115,6 +115,7 @@ export default function BotForm({
|
||||
|
||||
useEffect(() => {
|
||||
setBotFormValues();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function setBotFormValues() {
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Trash2, Plus } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiKeyManagementDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ApiKeyManagementDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ApiKeyManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKeyDescription, setNewKeyDescription] = useState('');
|
||||
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
|
||||
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);
|
||||
|
||||
// 清理 body 样式,防止对话框关闭后页面无法交互
|
||||
useEffect(() => {
|
||||
if (!deleteKeyId) {
|
||||
const cleanup = () => {
|
||||
document.body.style.removeProperty('pointer-events');
|
||||
};
|
||||
|
||||
cleanup();
|
||||
const timer = setTimeout(cleanup, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [deleteKeyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadApiKeys();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = (await backendClient.get('/api/v1/apikeys')) as {
|
||||
keys: ApiKey[];
|
||||
};
|
||||
setApiKeys(response.keys || []);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to load API keys: ${error}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateApiKey = async () => {
|
||||
if (!newKeyName.trim()) {
|
||||
toast.error(t('common.apiKeyNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await backendClient.post('/api/v1/apikeys', {
|
||||
name: newKeyName,
|
||||
description: newKeyDescription,
|
||||
})) as { key: ApiKey };
|
||||
|
||||
setCreatedKey(response.key);
|
||||
toast.success(t('common.apiKeyCreated'));
|
||||
setNewKeyName('');
|
||||
setNewKeyDescription('');
|
||||
setShowCreateDialog(false);
|
||||
loadApiKeys();
|
||||
} catch (error) {
|
||||
toast.error(`Failed to create API key: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteApiKey = async (keyId: number) => {
|
||||
try {
|
||||
await backendClient.delete(`/api/v1/apikeys/${keyId}`);
|
||||
toast.success(t('common.apiKeyDeleted'));
|
||||
loadApiKeys();
|
||||
setDeleteKeyId(null);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to delete API key: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyKey = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
toast.success(t('common.apiKeyCopied'));
|
||||
};
|
||||
|
||||
const maskApiKey = (key: string) => {
|
||||
if (key.length <= 8) return key;
|
||||
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
// 如果删除确认框是打开的,不允许关闭主对话框
|
||||
if (!newOpen && deleteKeyId) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.manageApiKeys')}</DialogTitle>
|
||||
<DialogDescription>{t('common.apiKeyHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noApiKeys')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('common.name')}</TableHead>
|
||||
<TableHead>{t('common.apiKeyValue')}</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{key.name}</div>
|
||||
{key.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{key.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(key.key)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyKey(key.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteKeyId(key.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.createApiKey')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t('common.name')}</label>
|
||||
<Input
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder={t('common.name')}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t('common.description')}
|
||||
</label>
|
||||
<Input
|
||||
value={newKeyDescription}
|
||||
onChange={(e) => setNewKeyDescription(e.target.value)}
|
||||
placeholder={t('common.description')}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCreateApiKey}>{t('common.create')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Show Created Key Dialog */}
|
||||
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('common.apiKeyCreatedMessage')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t('common.apiKeyValue')}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input value={createdKey?.key || ''} readOnly />
|
||||
<Button
|
||||
onClick={() => createdKey && handleCopyKey(createdKey.key)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setCreatedKey(null)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteKeyId}>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
className="z-[60]"
|
||||
onClick={() => setDeleteKeyId(null)}
|
||||
/>
|
||||
<AlertDialogPrimitive.Content
|
||||
className="fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg"
|
||||
onEscapeKeyDown={() => setDeleteKeyId(null)}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('common.apiKeyDeleteConfirm')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeleteKeyId(null)}>
|
||||
{t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteKeyId && handleDeleteApiKey(deleteKeyId)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,23 +11,18 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
export default function DynamicFormComponent({
|
||||
itemConfigList,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
onFileUploaded,
|
||||
}: {
|
||||
itemConfigList: IDynamicFormItemSchema[];
|
||||
onSubmit?: (val: object) => unknown;
|
||||
initialValues?: Record<string, object>;
|
||||
onFileUploaded?: (fileKey: string) => void;
|
||||
}) {
|
||||
const isInitialMount = useRef(true);
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
|
||||
// 根据 itemConfigList 动态生成 zod schema
|
||||
const formSchema = z.object(
|
||||
itemConfigList.reduce(
|
||||
@@ -58,9 +53,6 @@ export default function DynamicFormComponent({
|
||||
case 'knowledge-base-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'bot-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'prompt-editor':
|
||||
fieldSchema = z.array(
|
||||
z.object({
|
||||
@@ -105,24 +97,9 @@ export default function DynamicFormComponent({
|
||||
});
|
||||
|
||||
// 当 initialValues 变化时更新表单值
|
||||
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
|
||||
useEffect(() => {
|
||||
console.log('initialValues', initialValues);
|
||||
|
||||
// 首次挂载时,使用 initialValues 初始化表单
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
previousInitialValues.current = initialValues;
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 initialValues 是否真的发生了实质性变化
|
||||
// 使用 JSON.stringify 进行深度比较
|
||||
const hasRealChange =
|
||||
JSON.stringify(previousInitialValues.current) !==
|
||||
JSON.stringify(initialValues);
|
||||
|
||||
if (initialValues && hasRealChange) {
|
||||
if (initialValues) {
|
||||
// 合并默认值和初始值
|
||||
const mergedValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
@@ -135,8 +112,6 @@ export default function DynamicFormComponent({
|
||||
Object.entries(mergedValues).forEach(([key, value]) => {
|
||||
form.setValue(key as keyof FormValues, value);
|
||||
});
|
||||
|
||||
previousInitialValues.current = initialValues;
|
||||
}
|
||||
}, [initialValues, form, itemConfigList]);
|
||||
|
||||
@@ -174,11 +149,7 @@ export default function DynamicFormComponent({
|
||||
{config.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DynamicFormItemComponent
|
||||
config={config}
|
||||
field={field}
|
||||
onFileUploaded={onFileUploaded}
|
||||
/>
|
||||
<DynamicFormItemComponent config={config} field={field} />
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
DynamicFormItemType,
|
||||
IDynamicFormItemSchema,
|
||||
IFileConfig,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -17,7 +16,7 @@ import { ControllerRenderProps } from 'react-hook-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { LLMModel, Bot } from '@/app/infra/entities/api';
|
||||
import { LLMModel } from '@/app/infra/entities/api';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -28,54 +27,19 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export default function DynamicFormItemComponent({
|
||||
config,
|
||||
field,
|
||||
onFileUploaded,
|
||||
}: {
|
||||
config: IDynamicFormItemSchema;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
field: ControllerRenderProps<any, any>;
|
||||
onFileUploaded?: (fileKey: string) => void;
|
||||
}) {
|
||||
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [bots, setBots] = useState<Bot[]>([]);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error(t('plugins.fileUpload.tooLarge'));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const response = await httpClient.uploadPluginConfigFile(file);
|
||||
toast.success(t('plugins.fileUpload.success'));
|
||||
|
||||
// 通知父组件文件已上传
|
||||
onFileUploaded?.(response.file_key);
|
||||
|
||||
return {
|
||||
file_key: response.file_key,
|
||||
mimetype: file.type,
|
||||
};
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t('plugins.fileUpload.failed') + ': ' + (error as Error).message,
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
|
||||
httpClient
|
||||
@@ -84,7 +48,7 @@ export default function DynamicFormItemComponent({
|
||||
setLlmModels(resp.models);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to get LLM model list: ' + err.message);
|
||||
toast.error('获取 LLM 模型列表失败:' + err.message);
|
||||
});
|
||||
}
|
||||
}, [config.type]);
|
||||
@@ -97,20 +61,7 @@ export default function DynamicFormItemComponent({
|
||||
setKnowledgeBases(resp.bases);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to get knowledge base list: ' + err.message);
|
||||
});
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.BOT_SELECTOR) {
|
||||
httpClient
|
||||
.getBots()
|
||||
.then((resp) => {
|
||||
setBots(resp.bots);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to get bot list: ' + err.message);
|
||||
toast.error('获取知识库列表失败:' + err.message);
|
||||
});
|
||||
}
|
||||
}, [config.type]);
|
||||
@@ -129,9 +80,6 @@ export default function DynamicFormItemComponent({
|
||||
case DynamicFormItemType.STRING:
|
||||
return <Input {...field} />;
|
||||
|
||||
case DynamicFormItemType.TEXT:
|
||||
return <Textarea {...field} className="min-h-[120px]" />;
|
||||
|
||||
case DynamicFormItemType.BOOLEAN:
|
||||
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
||||
|
||||
@@ -336,24 +284,6 @@ export default function DynamicFormItemComponent({
|
||||
</Select>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.BOT_SELECTOR:
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('bots.selectBot')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{bots.map((bot) => (
|
||||
<SelectItem key={bot.uuid} value={bot.uuid ?? ''}>
|
||||
{bot.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.PROMPT_EDITOR:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -436,185 +366,6 @@ export default function DynamicFormItemComponent({
|
||||
</div>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.FILE:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{field.value && (field.value as IFileConfig).file_key ? (
|
||||
<Card className="py-3 max-w-full overflow-hidden bg-gray-900">
|
||||
<CardContent className="flex items-center gap-3 p-0 px-4 min-w-0">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className="text-sm font-medium truncate"
|
||||
title={(field.value as IFileConfig).file_key}
|
||||
>
|
||||
{(field.value as IFileConfig).file_key}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{(field.value as IFileConfig).mimetype}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0 h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
field.onChange(null);
|
||||
}}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4 text-destructive"
|
||||
>
|
||||
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept={config.accept}
|
||||
disabled={uploading}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const fileConfig = await handleFileUpload(file);
|
||||
if (fileConfig) {
|
||||
field.onChange(fileConfig);
|
||||
}
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="hidden"
|
||||
id={`file-input-${config.name}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading}
|
||||
onClick={() =>
|
||||
document.getElementById(`file-input-${config.name}`)?.click()
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
{uploading
|
||||
? t('plugins.fileUpload.uploading')
|
||||
: t('plugins.fileUpload.chooseFile')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.FILE_ARRAY:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{(field.value as IFileConfig[])?.map(
|
||||
(fileConfig: IFileConfig, index: number) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="py-3 max-w-full overflow-hidden bg-gray-900"
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-0 px-4 min-w-0">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className="text-sm font-medium truncate"
|
||||
title={fileConfig.file_key}
|
||||
>
|
||||
{fileConfig.file_key}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{fileConfig.mimetype}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0 h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const newValue = (field.value as IFileConfig[]).filter(
|
||||
(_: IFileConfig, i: number) => i !== index,
|
||||
);
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4 text-destructive"
|
||||
>
|
||||
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept={config.accept}
|
||||
disabled={uploading}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const fileConfig = await handleFileUpload(file);
|
||||
if (fileConfig) {
|
||||
field.onChange([...(field.value || []), fileConfig]);
|
||||
}
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="hidden"
|
||||
id={`file-array-input-${config.name}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading}
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById(`file-array-input-${config.name}`)
|
||||
?.click()
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
{uploading
|
||||
? t('plugins.fileUpload.uploading')
|
||||
: t('plugins.fileUpload.addFile')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return <Input {...field} />;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import PasswordChangeDialog from '@/app/home/components/password-change-dialog/PasswordChangeDialog';
|
||||
import ApiKeyManagementDialog from '@/app/home/components/api-key-management-dialog/ApiKeyManagementDialog';
|
||||
|
||||
// TODO 侧边导航栏要加动画
|
||||
export default function HomeSidebar({
|
||||
@@ -46,7 +45,6 @@ export default function HomeSidebar({
|
||||
const { t } = useTranslation();
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [passwordChangeOpen, setPasswordChangeOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false);
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
|
||||
@@ -67,6 +65,7 @@ export default function HomeSidebar({
|
||||
console.error('Failed to fetch GitHub star count:', error);
|
||||
});
|
||||
return () => console.log('sidebar.unmounted');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function handleChildClick(child: SidebarChildVO) {
|
||||
@@ -222,23 +221,6 @@ export default function HomeSidebar({
|
||||
name={t('common.helpDocs')}
|
||||
/>
|
||||
|
||||
<SidebarChild
|
||||
onClick={() => {
|
||||
setApiKeyDialogOpen(true);
|
||||
}}
|
||||
isSelected={false}
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M10.7577 11.8281L18.6066 3.97919L20.0208 5.3934L18.6066 6.80761L21.0815 9.28249L19.6673 10.6967L17.1924 8.22183L15.7782 9.63604L17.8995 11.7574L16.4853 13.1716L14.364 11.0503L12.1719 13.2423C13.4581 15.1837 13.246 17.8251 11.5355 19.5355C9.58291 21.4882 6.41709 21.4882 4.46447 19.5355C2.51184 17.5829 2.51184 14.4171 4.46447 12.4645C6.17493 10.754 8.81633 10.5419 10.7577 11.8281ZM10.1213 18.1213C11.2929 16.9497 11.2929 15.0503 10.1213 13.8787C8.94975 12.7071 7.05025 12.7071 5.87868 13.8787C4.70711 15.0503 4.70711 16.9497 5.87868 18.1213C7.05025 19.2929 8.94975 19.2929 10.1213 18.1213Z"></path>
|
||||
</svg>
|
||||
}
|
||||
name={t('common.apiKeys')}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -345,10 +327,6 @@ export default function HomeSidebar({
|
||||
open={passwordChangeOpen}
|
||||
onOpenChange={setPasswordChangeOpen}
|
||||
/>
|
||||
<ApiKeyManagementDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,13 +23,6 @@ export default function FileUploadZone({
|
||||
async (file: File) => {
|
||||
if (isUploading) return;
|
||||
|
||||
// Check file size (10MB limit)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error(t('knowledge.documentsTab.fileSizeExceeded'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
const toastId = toast.loading(t('knowledge.documentsTab.uploadingFile'));
|
||||
|
||||
@@ -53,7 +46,7 @@ export default function FileUploadZone({
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[kbId, isUploading, onUploadSuccess, onUploadError, t],
|
||||
[kbId, isUploading, onUploadSuccess, onUploadError],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import styles from './layout.module.css';
|
||||
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
||||
import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
@@ -18,15 +18,11 @@ export default function HomeLayout({
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
});
|
||||
|
||||
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
|
||||
const onSelectedChangeAction = (child: SidebarChildVO) => {
|
||||
setTitle(child.name);
|
||||
setSubtitle(child.description);
|
||||
setHelpLink(child.helpLink);
|
||||
}, []);
|
||||
|
||||
// Memoize the main content area to prevent re-renders when sidebar state changes
|
||||
const mainContent = useMemo(() => children, [children]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.homeLayoutContainer}>
|
||||
@@ -37,7 +33,7 @@ export default function HomeLayout({
|
||||
<div className={styles.main}>
|
||||
<HomeTitleBar title={title} subtitle={subtitle} helpLink={helpLink} />
|
||||
|
||||
<main className={styles.mainContent}>{mainContent}</main>
|
||||
<main className={styles.mainContent}>{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
.string()
|
||||
.min(1, { message: t('models.modelProviderRequired') }),
|
||||
url: z.string().min(1, { message: t('models.requestURLRequired') }),
|
||||
api_key: z.string().optional(),
|
||||
api_key: z.string().min(1, { message: t('models.apiKeyRequired') }),
|
||||
extra_args: z.array(getExtraArgSchema(t)).optional(),
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function EmbeddingForm({
|
||||
name: '',
|
||||
model_provider: '',
|
||||
url: '',
|
||||
api_key: '',
|
||||
api_key: 'sk-xxxxx',
|
||||
extra_args: [],
|
||||
},
|
||||
});
|
||||
@@ -245,7 +245,7 @@ export default function EmbeddingForm({
|
||||
timeout: 120,
|
||||
},
|
||||
extra_args: extraArgsObj,
|
||||
api_keys: value.api_key ? [value.api_key] : [],
|
||||
api_keys: [value.api_key],
|
||||
};
|
||||
|
||||
if (editMode) {
|
||||
@@ -310,7 +310,6 @@ export default function EmbeddingForm({
|
||||
extraArgsObj[arg.key] = arg.value;
|
||||
}
|
||||
});
|
||||
const apiKey = form.getValues('api_key');
|
||||
httpClient
|
||||
.testEmbeddingModel('_', {
|
||||
uuid: '',
|
||||
@@ -321,7 +320,7 @@ export default function EmbeddingForm({
|
||||
base_url: form.getValues('url'),
|
||||
timeout: 120,
|
||||
},
|
||||
api_keys: apiKey ? [apiKey] : [],
|
||||
api_keys: [form.getValues('api_key')],
|
||||
extra_args: extraArgsObj,
|
||||
})
|
||||
.then((res) => {
|
||||
@@ -462,7 +461,10 @@ export default function EmbeddingForm({
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.apiKey')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t('models.apiKey')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
@@ -76,7 +76,7 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
.string()
|
||||
.min(1, { message: t('models.modelProviderRequired') }),
|
||||
url: z.string().min(1, { message: t('models.requestURLRequired') }),
|
||||
api_key: z.string().optional(),
|
||||
api_key: z.string().min(1, { message: t('models.apiKeyRequired') }),
|
||||
abilities: z.array(z.string()),
|
||||
extra_args: z.array(getExtraArgSchema(t)).optional(),
|
||||
});
|
||||
@@ -103,7 +103,7 @@ export default function LLMForm({
|
||||
name: '',
|
||||
model_provider: '',
|
||||
url: '',
|
||||
api_key: '',
|
||||
api_key: 'sk-xxxxx',
|
||||
abilities: [],
|
||||
extra_args: [],
|
||||
},
|
||||
@@ -261,7 +261,7 @@ export default function LLMForm({
|
||||
timeout: 120,
|
||||
},
|
||||
extra_args: extraArgsObj,
|
||||
api_keys: value.api_key ? [value.api_key] : [],
|
||||
api_keys: [value.api_key],
|
||||
abilities: value.abilities,
|
||||
};
|
||||
|
||||
@@ -324,7 +324,6 @@ export default function LLMForm({
|
||||
extraArgsObj[arg.key] = arg.value;
|
||||
}
|
||||
});
|
||||
const apiKey = form.getValues('api_key');
|
||||
httpClient
|
||||
.testLLMModel('_', {
|
||||
uuid: '',
|
||||
@@ -335,7 +334,7 @@ export default function LLMForm({
|
||||
base_url: form.getValues('url'),
|
||||
timeout: 120,
|
||||
},
|
||||
api_keys: apiKey ? [apiKey] : [],
|
||||
api_keys: [form.getValues('api_key')],
|
||||
abilities: form.getValues('abilities'),
|
||||
extra_args: extraArgsObj,
|
||||
})
|
||||
@@ -479,7 +478,10 @@ export default function LLMForm({
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.apiKey')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t('models.apiKey')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';
|
||||
import DebugDialog from './components/debug-dialog/DebugDialog';
|
||||
import PipelineExtension from './components/pipeline-extensions/PipelineExtension';
|
||||
|
||||
interface PipelineDialogProps {
|
||||
open: boolean;
|
||||
@@ -32,7 +31,7 @@ interface PipelineDialogProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type DialogMode = 'config' | 'debug' | 'extensions';
|
||||
type DialogMode = 'config' | 'debug';
|
||||
|
||||
export default function PipelineDialog({
|
||||
open,
|
||||
@@ -82,19 +81,6 @@ export default function PipelineDialog({
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'extensions',
|
||||
label: t('pipelines.extensions.title'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'debug',
|
||||
label: t('pipelines.debugChat'),
|
||||
@@ -116,9 +102,6 @@ export default function PipelineDialog({
|
||||
? t('pipelines.editPipeline')
|
||||
: t('pipelines.createPipeline');
|
||||
}
|
||||
if (currentMode === 'extensions') {
|
||||
return t('pipelines.extensions.title');
|
||||
}
|
||||
return t('pipelines.debugDialog.title');
|
||||
};
|
||||
|
||||
@@ -210,11 +193,6 @@ export default function PipelineDialog({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentMode === 'extensions' && pipelineId && (
|
||||
<PipelineExtension pipelineId={pipelineId} />
|
||||
)}
|
||||
|
||||
{currentMode === 'debug' && pipelineId && (
|
||||
<DebugDialog
|
||||
open={true}
|
||||
|
||||
@@ -11,6 +11,10 @@ import { Message } from '@/app/infra/entities/message';
|
||||
import { toast } from 'sonner';
|
||||
import AtBadge from './AtBadge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
PipelineWebSocketClient,
|
||||
SessionType,
|
||||
} from '@/app/infra/websocket/PipelineWebSocketClient';
|
||||
|
||||
interface MessageComponent {
|
||||
type: 'At' | 'Plain';
|
||||
@@ -31,17 +35,27 @@ export default function DebugDialog({
|
||||
}: DebugDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
||||
const [sessionType, setSessionType] = useState<'person' | 'group'>('person');
|
||||
const [sessionType, setSessionType] = useState<SessionType>('person');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showAtPopover, setShowAtPopover] = useState(false);
|
||||
const [hasAt, setHasAt] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(true);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// WebSocket states
|
||||
const [wsClient, setWsClient] = useState<PipelineWebSocketClient | null>(
|
||||
null,
|
||||
);
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
'disconnected' | 'connecting' | 'connected'
|
||||
>('disconnected');
|
||||
const [pendingMessages, setPendingMessages] = useState<
|
||||
Map<string, Message>
|
||||
>(new Map());
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
// 使用setTimeout确保在DOM更新后执行滚动
|
||||
setTimeout(() => {
|
||||
@@ -57,37 +71,161 @@ export default function DebugDialog({
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const loadMessages = useCallback(
|
||||
async (pipelineId: string) => {
|
||||
try {
|
||||
const response = await httpClient.getWebChatHistoryMessages(
|
||||
pipelineId,
|
||||
sessionType,
|
||||
);
|
||||
setMessages(response.messages);
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error);
|
||||
}
|
||||
},
|
||||
[sessionType],
|
||||
);
|
||||
// 在useEffect中监听messages变化时滚动
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// WebSocket connection setup
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedPipelineId(pipelineId);
|
||||
loadMessages(pipelineId);
|
||||
}
|
||||
}, [open, pipelineId]);
|
||||
if (!open) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadMessages(selectedPipelineId);
|
||||
}
|
||||
}, [sessionType, selectedPipelineId, open, loadMessages]);
|
||||
const client = new PipelineWebSocketClient(
|
||||
selectedPipelineId,
|
||||
sessionType,
|
||||
);
|
||||
|
||||
// Setup event handlers
|
||||
client.onConnected = (data) => {
|
||||
console.log('[DebugDialog] WebSocket connected:', data);
|
||||
setConnectionStatus('connected');
|
||||
// Load history messages after connection
|
||||
client.loadHistory();
|
||||
};
|
||||
|
||||
client.onHistory = (data) => {
|
||||
console.log('[DebugDialog] History loaded:', data?.messages.length);
|
||||
if (data) {
|
||||
setMessages(data.messages);
|
||||
}
|
||||
};
|
||||
|
||||
client.onMessageSent = (data) => {
|
||||
console.log('[DebugDialog] Message sent confirmed:', data);
|
||||
if (data) {
|
||||
// Update client message ID to server message ID
|
||||
const clientMsgId = data.client_message_id;
|
||||
const serverMsgId = data.server_message_id;
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === -1 && pendingMessages.has(clientMsgId)
|
||||
? { ...msg, id: serverMsgId }
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
|
||||
setPendingMessages((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(clientMsgId);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
client.onMessageStart = (data) => {
|
||||
console.log('[DebugDialog] Message start:', data);
|
||||
if (data) {
|
||||
const placeholderMessage: Message = {
|
||||
id: data.message_id,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
message_chain: [],
|
||||
timestamp: data.timestamp,
|
||||
};
|
||||
setMessages((prev) => [...prev, placeholderMessage]);
|
||||
}
|
||||
};
|
||||
|
||||
client.onMessageChunk = (data) => {
|
||||
if (data) {
|
||||
// Update streaming message (content is cumulative)
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === data.message_id
|
||||
? {
|
||||
...msg,
|
||||
content: data.content,
|
||||
message_chain: data.message_chain,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
client.onMessageComplete = (data) => {
|
||||
console.log('[DebugDialog] Message complete:', data);
|
||||
if (data) {
|
||||
// Mark message as complete
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === data.message_id
|
||||
? {
|
||||
...msg,
|
||||
content: data.final_content,
|
||||
message_chain: data.message_chain,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
client.onMessageError = (data) => {
|
||||
console.error('[DebugDialog] Message error:', data);
|
||||
if (data) {
|
||||
toast.error(`Message error: ${data.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
client.onPluginMessage = (data) => {
|
||||
console.log('[DebugDialog] Plugin message:', data);
|
||||
if (data) {
|
||||
const pluginMessage: Message = {
|
||||
id: data.message_id,
|
||||
role: 'assistant',
|
||||
content: data.content,
|
||||
message_chain: data.message_chain,
|
||||
timestamp: data.timestamp,
|
||||
};
|
||||
setMessages((prev) => [...prev, pluginMessage]);
|
||||
}
|
||||
};
|
||||
|
||||
client.onError = (data) => {
|
||||
console.error('[DebugDialog] WebSocket error:', data);
|
||||
if (data) {
|
||||
toast.error(`WebSocket error: ${data.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
client.onDisconnected = () => {
|
||||
console.log('[DebugDialog] WebSocket disconnected');
|
||||
setConnectionStatus('disconnected');
|
||||
};
|
||||
|
||||
// Connect to WebSocket
|
||||
setConnectionStatus('connecting');
|
||||
client
|
||||
.connect(httpClient.getSessionSync())
|
||||
.then(() => {
|
||||
console.log('[DebugDialog] WebSocket connection established');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[DebugDialog] Failed to connect WebSocket:', err);
|
||||
toast.error('Failed to connect to server');
|
||||
setConnectionStatus('disconnected');
|
||||
});
|
||||
|
||||
setWsClient(client);
|
||||
|
||||
// Cleanup on unmount or session type change
|
||||
return () => {
|
||||
console.log('[DebugDialog] Cleaning up WebSocket connection');
|
||||
client.disconnect();
|
||||
};
|
||||
}, [open, selectedPipelineId, sessionType]); // Reconnect when session type changes
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -150,6 +288,12 @@ export default function DebugDialog({
|
||||
const sendMessage = async () => {
|
||||
if (!inputValue.trim() && !hasAt) return;
|
||||
|
||||
// Check WebSocket connection
|
||||
if (!wsClient || connectionStatus !== 'connected') {
|
||||
toast.error('Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const messageChain = [];
|
||||
|
||||
@@ -170,7 +314,7 @@ export default function DebugDialog({
|
||||
});
|
||||
|
||||
if (hasAt) {
|
||||
// for showing
|
||||
// For display
|
||||
text_content = '@webchatbot' + text_content;
|
||||
}
|
||||
|
||||
@@ -181,97 +325,26 @@ export default function DebugDialog({
|
||||
timestamp: new Date().toISOString(),
|
||||
message_chain: messageChain,
|
||||
};
|
||||
// 根据isStreaming状态决定使用哪种传输方式
|
||||
if (isStreaming) {
|
||||
// streaming
|
||||
// 创建初始bot消息
|
||||
const placeholderRandomId = Math.floor(Math.random() * 1000000);
|
||||
const botMessagePlaceholder: Message = {
|
||||
id: placeholderRandomId,
|
||||
role: 'assistant',
|
||||
content: 'Generating...',
|
||||
timestamp: new Date().toISOString(),
|
||||
message_chain: [{ type: 'Plain', text: 'Generating...' }],
|
||||
};
|
||||
|
||||
// 添加用户消息和初始bot消息到状态
|
||||
// Add user message to UI immediately
|
||||
setMessages((prevMessages) => [...prevMessages, userMessage]);
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
|
||||
setMessages((prevMessages) => [
|
||||
...prevMessages,
|
||||
userMessage,
|
||||
botMessagePlaceholder,
|
||||
]);
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
try {
|
||||
await httpClient.sendStreamingWebChatMessage(
|
||||
sessionType,
|
||||
messageChain,
|
||||
selectedPipelineId,
|
||||
(data) => {
|
||||
// 处理流式响应数据
|
||||
console.log('data', data);
|
||||
if (data.message) {
|
||||
// 更新完整内容
|
||||
// Send via WebSocket
|
||||
const clientMessageId = wsClient.sendMessage(messageChain);
|
||||
|
||||
setMessages((prevMessages) => {
|
||||
const updatedMessages = [...prevMessages];
|
||||
const botMessageIndex = updatedMessages.findIndex(
|
||||
(message) => message.id === placeholderRandomId,
|
||||
);
|
||||
if (botMessageIndex !== -1) {
|
||||
updatedMessages[botMessageIndex] = {
|
||||
...updatedMessages[botMessageIndex],
|
||||
content: data.message.content,
|
||||
message_chain: [
|
||||
{ type: 'Plain', text: data.message.content },
|
||||
],
|
||||
};
|
||||
}
|
||||
return updatedMessages;
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
(error) => {
|
||||
// 处理错误
|
||||
console.error('Streaming error:', error);
|
||||
if (sessionType === 'person') {
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to send streaming message:', error);
|
||||
if (sessionType === 'person') {
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// non-streaming
|
||||
setMessages((prevMessages) => [...prevMessages, userMessage]);
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
// Track pending message for ID mapping
|
||||
setPendingMessages((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(clientMessageId, userMessage);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
const response = await httpClient.sendWebChatMessage(
|
||||
sessionType,
|
||||
messageChain,
|
||||
selectedPipelineId,
|
||||
180000,
|
||||
);
|
||||
|
||||
setMessages((prevMessages) => [...prevMessages, response.message]);
|
||||
}
|
||||
} catch (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: any
|
||||
) {
|
||||
console.log(error, 'type of error', typeof error);
|
||||
console.error('Failed to send message:', error);
|
||||
|
||||
if (!error.message.includes('timeout') && sessionType === 'person') {
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
}
|
||||
console.log('[DebugDialog] Message sent:', clientMessageId);
|
||||
} catch (error) {
|
||||
console.error('[DebugDialog] Failed to send message:', error);
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
} finally {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
@@ -390,12 +463,6 @@ export default function DebugDialog({
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-4 pb-0 bg-white dark:bg-black flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
{t('pipelines.debugDialog.streaming')}
|
||||
</span>
|
||||
<Switch checked={isStreaming} onCheckedChange={setIsStreaming} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{hasAt && (
|
||||
<AtBadge targetName="webchatbot" onRemove={handleAtRemove} />
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Plus, X, Server, Wrench } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import { MCPServer } from '@/app/infra/entities/api';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
|
||||
export default function PipelineExtension({
|
||||
pipelineId,
|
||||
}: {
|
||||
pipelineId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPlugins, setSelectedPlugins] = useState<Plugin[]>([]);
|
||||
const [allPlugins, setAllPlugins] = useState<Plugin[]>([]);
|
||||
const [selectedMCPServers, setSelectedMCPServers] = useState<MCPServer[]>([]);
|
||||
const [allMCPServers, setAllMCPServers] = useState<MCPServer[]>([]);
|
||||
const [pluginDialogOpen, setPluginDialogOpen] = useState(false);
|
||||
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
|
||||
const [tempSelectedPluginIds, setTempSelectedPluginIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [tempSelectedMCPIds, setTempSelectedMCPIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadExtensions();
|
||||
}, [pipelineId]);
|
||||
|
||||
const getPluginId = (plugin: Plugin): string => {
|
||||
const author = plugin.manifest.manifest.metadata.author;
|
||||
const name = plugin.manifest.manifest.metadata.name;
|
||||
return `${author}/${name}`;
|
||||
};
|
||||
|
||||
const loadExtensions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await backendClient.getPipelineExtensions(pipelineId);
|
||||
|
||||
const boundPluginIds = new Set(
|
||||
data.bound_plugins.map((p) => `${p.author}/${p.name}`),
|
||||
);
|
||||
|
||||
const selected = data.available_plugins.filter((plugin) =>
|
||||
boundPluginIds.has(getPluginId(plugin)),
|
||||
);
|
||||
|
||||
setSelectedPlugins(selected);
|
||||
setAllPlugins(data.available_plugins);
|
||||
|
||||
// Load MCP servers
|
||||
const boundMCPServerIds = new Set(data.bound_mcp_servers || []);
|
||||
const selectedMCP = data.available_mcp_servers.filter((server) =>
|
||||
boundMCPServerIds.has(server.uuid || ''),
|
||||
);
|
||||
|
||||
setSelectedMCPServers(selectedMCP);
|
||||
setAllMCPServers(data.available_mcp_servers);
|
||||
} catch (error) {
|
||||
console.error('Failed to load extensions:', error);
|
||||
toast.error(t('pipelines.extensions.loadError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveToBackend = async (plugins: Plugin[], mcpServers: MCPServer[]) => {
|
||||
try {
|
||||
const boundPluginsArray = plugins.map((plugin) => {
|
||||
const metadata = plugin.manifest.manifest.metadata;
|
||||
return {
|
||||
author: metadata.author || '',
|
||||
name: metadata.name,
|
||||
};
|
||||
});
|
||||
|
||||
const boundMCPServerIds = mcpServers.map((server) => server.uuid || '');
|
||||
|
||||
await backendClient.updatePipelineExtensions(
|
||||
pipelineId,
|
||||
boundPluginsArray,
|
||||
boundMCPServerIds,
|
||||
);
|
||||
toast.success(t('pipelines.extensions.saveSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save extensions:', error);
|
||||
toast.error(t('pipelines.extensions.saveError'));
|
||||
// Reload on error to restore correct state
|
||||
loadExtensions();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePlugin = async (pluginId: string) => {
|
||||
const newPlugins = selectedPlugins.filter(
|
||||
(p) => getPluginId(p) !== pluginId,
|
||||
);
|
||||
setSelectedPlugins(newPlugins);
|
||||
await saveToBackend(newPlugins, selectedMCPServers);
|
||||
};
|
||||
|
||||
const handleRemoveMCPServer = async (serverUuid: string) => {
|
||||
const newServers = selectedMCPServers.filter((s) => s.uuid !== serverUuid);
|
||||
setSelectedMCPServers(newServers);
|
||||
await saveToBackend(selectedPlugins, newServers);
|
||||
};
|
||||
|
||||
const handleOpenPluginDialog = () => {
|
||||
setTempSelectedPluginIds(selectedPlugins.map((p) => getPluginId(p)));
|
||||
setPluginDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenMCPDialog = () => {
|
||||
setTempSelectedMCPIds(selectedMCPServers.map((s) => s.uuid || ''));
|
||||
setMcpDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleTogglePlugin = (pluginId: string) => {
|
||||
setTempSelectedPluginIds((prev) =>
|
||||
prev.includes(pluginId)
|
||||
? prev.filter((id) => id !== pluginId)
|
||||
: [...prev, pluginId],
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleMCPServer = (serverUuid: string) => {
|
||||
setTempSelectedMCPIds((prev) =>
|
||||
prev.includes(serverUuid)
|
||||
? prev.filter((id) => id !== serverUuid)
|
||||
: [...prev, serverUuid],
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfirmPluginSelection = async () => {
|
||||
const newSelected = allPlugins.filter((p) =>
|
||||
tempSelectedPluginIds.includes(getPluginId(p)),
|
||||
);
|
||||
setSelectedPlugins(newSelected);
|
||||
setPluginDialogOpen(false);
|
||||
await saveToBackend(newSelected, selectedMCPServers);
|
||||
};
|
||||
|
||||
const handleConfirmMCPSelection = async () => {
|
||||
const newSelected = allMCPServers.filter((s) =>
|
||||
tempSelectedMCPIds.includes(s.uuid || ''),
|
||||
);
|
||||
setSelectedMCPServers(newSelected);
|
||||
setMcpDialogOpen(false);
|
||||
await saveToBackend(selectedPlugins, newSelected);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Plugins Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t('pipelines.extensions.pluginsTitle')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedPlugins.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('pipelines.extensions.noPluginsSelected')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedPlugins.map((plugin) => {
|
||||
const pluginId = getPluginId(plugin);
|
||||
const metadata = plugin.manifest.manifest.metadata;
|
||||
return (
|
||||
<div
|
||||
key={pluginId}
|
||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<img
|
||||
src={backendClient.getPluginIconURL(
|
||||
metadata.author || '',
|
||||
metadata.name,
|
||||
)}
|
||||
alt={metadata.name}
|
||||
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{metadata.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{metadata.author} • v{metadata.version}
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<PluginComponentList
|
||||
components={plugin.components}
|
||||
showComponentName={true}
|
||||
showTitle={false}
|
||||
useBadge={true}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!plugin.enabled && (
|
||||
<Badge variant="secondary">
|
||||
{t('pipelines.extensions.disabled')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemovePlugin(pluginId)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleOpenPluginDialog}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('pipelines.extensions.addPlugin')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* MCP Servers Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t('pipelines.extensions.mcpServersTitle')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedMCPServers.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('pipelines.extensions.noMCPServersSelected')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedMCPServers.map((server) => (
|
||||
<div
|
||||
key={server.uuid}
|
||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{server.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{server.mode}
|
||||
</div>
|
||||
{server.runtime_info &&
|
||||
server.runtime_info.status === 'connected' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 mt-1"
|
||||
>
|
||||
<Wrench className="h-3 w-3 text-white" />
|
||||
<span className="text-xs text-white">
|
||||
{t('pipelines.extensions.toolCount', {
|
||||
count: server.runtime_info.tool_count || 0,
|
||||
})}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!server.enable && (
|
||||
<Badge variant="secondary">
|
||||
{t('pipelines.extensions.disabled')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveMCPServer(server.uuid || '')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleOpenMCPDialog}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('pipelines.extensions.addMCPServer')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Plugin Selection Dialog */}
|
||||
<Dialog open={pluginDialogOpen} onOpenChange={setPluginDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('pipelines.extensions.selectPlugins')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
||||
{allPlugins.map((plugin) => {
|
||||
const pluginId = getPluginId(plugin);
|
||||
const metadata = plugin.manifest.manifest.metadata;
|
||||
const isSelected = tempSelectedPluginIds.includes(pluginId);
|
||||
return (
|
||||
<div
|
||||
key={pluginId}
|
||||
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
||||
onClick={() => handleTogglePlugin(pluginId)}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
<img
|
||||
src={backendClient.getPluginIconURL(
|
||||
metadata.author || '',
|
||||
metadata.name,
|
||||
)}
|
||||
alt={metadata.name}
|
||||
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{metadata.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{metadata.author} • v{metadata.version}
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<PluginComponentList
|
||||
components={plugin.components}
|
||||
showComponentName={true}
|
||||
showTitle={false}
|
||||
useBadge={true}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!plugin.enabled && (
|
||||
<Badge variant="secondary">
|
||||
{t('pipelines.extensions.disabled')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPluginDialogOpen(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmPluginSelection}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* MCP Server Selection Dialog */}
|
||||
<Dialog open={mcpDialogOpen} onOpenChange={setMcpDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('pipelines.extensions.selectMCPServers')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
||||
{allMCPServers.map((server) => {
|
||||
const isSelected = tempSelectedMCPIds.includes(server.uuid || '');
|
||||
return (
|
||||
<div
|
||||
key={server.uuid}
|
||||
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
||||
onClick={() => handleToggleMCPServer(server.uuid || '')}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{server.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{server.mode}
|
||||
</div>
|
||||
{server.runtime_info &&
|
||||
server.runtime_info.status === 'connected' && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('pipelines.extensions.toolCount', {
|
||||
count: server.runtime_info.tool_count || 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!server.enable && (
|
||||
<Badge variant="secondary">
|
||||
{t('pipelines.extensions.disabled')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setMcpDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmMCPSelection}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { toast } from 'sonner';
|
||||
@@ -44,7 +43,6 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
PluginOperationType.DELETE,
|
||||
);
|
||||
const [targetPlugin, setTargetPlugin] = useState<PluginCardVO | null>(null);
|
||||
const [deleteData, setDeleteData] = useState<boolean>(false);
|
||||
|
||||
const asyncTask = useAsyncTask({
|
||||
onSuccess: () => {
|
||||
@@ -63,6 +61,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
|
||||
useEffect(() => {
|
||||
initData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function initData() {
|
||||
@@ -110,7 +109,6 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
setTargetPlugin(plugin);
|
||||
setOperationType(PluginOperationType.DELETE);
|
||||
setShowOperationModal(true);
|
||||
setDeleteData(false);
|
||||
asyncTask.reset();
|
||||
}
|
||||
|
||||
@@ -126,11 +124,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
|
||||
const apiCall =
|
||||
operationType === PluginOperationType.DELETE
|
||||
? httpClient.removePlugin(
|
||||
targetPlugin.author,
|
||||
targetPlugin.name,
|
||||
deleteData,
|
||||
)
|
||||
? httpClient.removePlugin(targetPlugin.author, targetPlugin.name)
|
||||
: httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);
|
||||
|
||||
apiCall
|
||||
@@ -168,35 +162,16 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.confirmDeletePlugin', {
|
||||
author: targetPlugin?.author ?? '',
|
||||
name: targetPlugin?.name ?? '',
|
||||
})
|
||||
: t('plugins.confirmUpdatePlugin', {
|
||||
author: targetPlugin?.author ?? '',
|
||||
name: targetPlugin?.name ?? '',
|
||||
})}
|
||||
</div>
|
||||
{operationType === PluginOperationType.DELETE && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="delete-data"
|
||||
checked={deleteData}
|
||||
onCheckedChange={(checked) =>
|
||||
setDeleteData(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="delete-data"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{t('plugins.deleteDataCheckbox')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.confirmDeletePlugin', {
|
||||
author: targetPlugin?.author ?? '',
|
||||
name: targetPlugin?.name ?? '',
|
||||
})
|
||||
: t('plugins.confirmUpdatePlugin', {
|
||||
author: targetPlugin?.author ?? '',
|
||||
name: targetPlugin?.name ?? '',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
@@ -24,9 +24,6 @@ export default function PluginForm({
|
||||
const [pluginInfo, setPluginInfo] = useState<Plugin>();
|
||||
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
|
||||
const [isSaving, setIsLoading] = useState(false);
|
||||
const currentFormValues = useRef<object>({});
|
||||
const uploadedFileKeys = useRef<Set<string>>(new Set());
|
||||
const initialFileKeys = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// 获取插件信息
|
||||
@@ -36,103 +33,28 @@ export default function PluginForm({
|
||||
// 获取插件配置
|
||||
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
|
||||
setPluginConfig(res);
|
||||
|
||||
// 提取初始配置中的所有文件 key
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const extractFileKeys = (obj: any): string[] => {
|
||||
const keys: string[] = [];
|
||||
if (obj && typeof obj === 'object') {
|
||||
if ('file_key' in obj && typeof obj.file_key === 'string') {
|
||||
keys.push(obj.file_key);
|
||||
}
|
||||
for (const value of Object.values(obj)) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => keys.push(...extractFileKeys(item)));
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
keys.push(...extractFileKeys(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
const fileKeys = extractFileKeys(res.config);
|
||||
initialFileKeys.current = new Set(fileKeys);
|
||||
});
|
||||
}, [pluginAuthor, pluginName]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (values: object) => {
|
||||
setIsLoading(true);
|
||||
const isDebugPlugin = pluginInfo?.debug;
|
||||
|
||||
try {
|
||||
// 保存配置
|
||||
await httpClient.updatePluginConfig(
|
||||
pluginAuthor,
|
||||
pluginName,
|
||||
currentFormValues.current,
|
||||
);
|
||||
|
||||
// 提取最终保存的配置中的所有文件 key
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const extractFileKeys = (obj: any): string[] => {
|
||||
const keys: string[] = [];
|
||||
if (obj && typeof obj === 'object') {
|
||||
if ('file_key' in obj && typeof obj.file_key === 'string') {
|
||||
keys.push(obj.file_key);
|
||||
}
|
||||
for (const value of Object.values(obj)) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => keys.push(...extractFileKeys(item)));
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
keys.push(...extractFileKeys(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
const finalFileKeys = new Set(extractFileKeys(currentFormValues.current));
|
||||
|
||||
// 计算需要删除的文件:
|
||||
// 1. 在编辑期间上传的,但最终未保存的文件
|
||||
// 2. 初始配置中有的,但最终配置中没有的文件(被删除的文件)
|
||||
const filesToDelete: string[] = [];
|
||||
|
||||
// 上传了但未使用的文件
|
||||
uploadedFileKeys.current.forEach((key) => {
|
||||
if (!finalFileKeys.has(key)) {
|
||||
filesToDelete.push(key);
|
||||
}
|
||||
httpClient
|
||||
.updatePluginConfig(pluginAuthor, pluginName, values)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
isDebugPlugin
|
||||
? t('plugins.saveConfigSuccessDebugPlugin')
|
||||
: t('plugins.saveConfigSuccessNormal'),
|
||||
);
|
||||
onFormSubmit(1000);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t('plugins.saveConfigError') + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// 初始有但最终没有的文件(被删除的)
|
||||
initialFileKeys.current.forEach((key) => {
|
||||
if (!finalFileKeys.has(key)) {
|
||||
filesToDelete.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
// 删除不需要的文件
|
||||
const deletePromises = filesToDelete.map((fileKey) =>
|
||||
httpClient.deletePluginConfigFile(fileKey).catch((err) => {
|
||||
console.warn(`Failed to delete file ${fileKey}:`, err);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
toast.success(
|
||||
isDebugPlugin
|
||||
? t('plugins.saveConfigSuccessDebugPlugin')
|
||||
: t('plugins.saveConfigSuccessNormal'),
|
||||
);
|
||||
onFormSubmit(1000);
|
||||
} catch (error) {
|
||||
toast.error(t('plugins.saveConfigError') + (error as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!pluginInfo || !pluginConfig) {
|
||||
@@ -173,12 +95,14 @@ export default function PluginForm({
|
||||
itemConfigList={pluginInfo.manifest.manifest.spec.config}
|
||||
initialValues={pluginConfig.config as Record<string, object>}
|
||||
onSubmit={(values) => {
|
||||
// 只保存表单值的引用,不触发状态更新
|
||||
currentFormValues.current = values;
|
||||
}}
|
||||
onFileUploaded={(fileKey) => {
|
||||
// 追踪上传的文件
|
||||
uploadedFileKeys.current.add(fileKey);
|
||||
let config = pluginConfig.config;
|
||||
config = {
|
||||
...config,
|
||||
...values,
|
||||
};
|
||||
setPluginConfig({
|
||||
config: config,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -193,7 +117,7 @@ export default function PluginForm({
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => handleSubmit()}
|
||||
onClick={() => handleSubmit(pluginConfig.config)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
|
||||
|
||||
@@ -172,6 +172,7 @@ function MarketPageContent({
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
fetchPlugins(1, false, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 搜索功能
|
||||
@@ -283,7 +284,7 @@ function MarketPageContent({
|
||||
// };
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6">
|
||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-full max-w-2xl">
|
||||
@@ -301,19 +302,19 @@ function MarketPageContent({
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
className="pl-10 pr-4 text-sm sm:text-base"
|
||||
className="pl-10 pr-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 排序下拉框 */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-full max-w-2xl flex items-center gap-2 sm:gap-3">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||
<div className="w-full max-w-2xl flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{t('market.sortBy')}:
|
||||
</span>
|
||||
<Select value={sortOption} onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -329,7 +330,7 @@ function MarketPageContent({
|
||||
|
||||
{/* 搜索结果统计 */}
|
||||
{total > 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
<div className="text-center text-muted-foreground">
|
||||
{searchQuery
|
||||
? t('market.searchResults', { count: total })
|
||||
: t('market.totalPlugins', { count: total })}
|
||||
|
||||
@@ -228,30 +228,6 @@ export default function PluginDetailDialog({
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3
|
||||
className="text-xl font-semibold mb-2 mt-4 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h4: ({ ...props }) => (
|
||||
<h4
|
||||
className="text-lg font-semibold mb-2 mt-4 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h5: ({ ...props }) => (
|
||||
<h5
|
||||
className="text-base font-semibold mb-2 mt-4 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h6: ({ ...props }) => (
|
||||
<h6
|
||||
className="text-sm font-semibold mb-2 mt-4 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p className="leading-relaxed dark:text-gray-400" {...props} />
|
||||
),
|
||||
@@ -298,57 +274,6 @@ export default function PluginDetailDialog({
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
// 图片组件 - 转换本地路径为API路径
|
||||
img: ({ src, alt, ...props }) => {
|
||||
// 处理图片路径
|
||||
let imageSrc = src || '';
|
||||
|
||||
// 确保 src 是字符串类型
|
||||
if (typeof imageSrc !== 'string') {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
className="max-w-full h-auto rounded-lg my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是相对路径,转换为API路径
|
||||
if (
|
||||
imageSrc &&
|
||||
!imageSrc.startsWith('http://') &&
|
||||
!imageSrc.startsWith('https://') &&
|
||||
!imageSrc.startsWith('data:')
|
||||
) {
|
||||
// 移除开头的 ./ 或 / (支持多个前缀)
|
||||
imageSrc = imageSrc.replace(/^(\.\/|\/)+/, '');
|
||||
|
||||
// 如果路径以 assets/ 开头,直接使用
|
||||
// 否则假设它在 assets/ 目录下
|
||||
if (!imageSrc.startsWith('assets/')) {
|
||||
imageSrc = `assets/${imageSrc}`;
|
||||
}
|
||||
|
||||
// 移除 assets/ 前缀以构建API URL
|
||||
const assetPath = imageSrc.replace(/^assets\//, '');
|
||||
imageSrc = getCloudServiceClientSync().getPluginAssetURL(
|
||||
author!,
|
||||
pluginName!,
|
||||
assetPath,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt || ''}
|
||||
className="max-w-lg h-auto my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{readme}
|
||||
|
||||
@@ -15,37 +15,35 @@ export default function PluginMarketCardComponent({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[100%] h-auto min-h-[8rem] sm:h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22]"
|
||||
className="w-[100%] h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22]"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="w-full h-full flex flex-col justify-between gap-2">
|
||||
<div className="w-full h-full flex flex-col justify-between">
|
||||
{/* 上部分:插件信息 */}
|
||||
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0">
|
||||
<img
|
||||
src={cardVO.iconURL}
|
||||
alt="plugin icon"
|
||||
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
<img src={cardVO.iconURL} alt="plugin icon" className="w-16 h-16" />
|
||||
|
||||
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col items-start justify-start w-full min-w-0">
|
||||
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
|
||||
<div className="flex-1 flex flex-col items-start justify-start gap-[0.6rem]">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="text-[0.7rem] text-[#666] dark:text-[#999]">
|
||||
{cardVO.pluginId}
|
||||
</div>
|
||||
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate w-full">
|
||||
{cardVO.label}
|
||||
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
|
||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0]">
|
||||
{cardVO.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[0.7rem] sm:text-[0.8rem] text-[#666] dark:text-[#999] line-clamp-2 overflow-hidden">
|
||||
<div className="text-[0.8rem] text-[#666] dark:text-[#999] line-clamp-2">
|
||||
{cardVO.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-start justify-center gap-[0.4rem] flex-shrink-0">
|
||||
<div className="flex h-full flex-row items-start justify-center gap-[0.4rem]">
|
||||
{cardVO.githubURL && (
|
||||
<svg
|
||||
className="w-5 h-5 sm:w-[1.4rem] sm:h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0] flex-shrink-0"
|
||||
className="w-[1.4rem] h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
@@ -61,9 +59,9 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
|
||||
{/* 下部分:下载量 */}
|
||||
<div className="w-full flex flex-row items-center justify-start gap-[0.3rem] sm:gap-[0.4rem] px-0 sm:px-[0.4rem] flex-shrink-0">
|
||||
<div className="w-full flex flex-row items-center justify-start gap-[0.4rem] px-[0.4rem]">
|
||||
<svg
|
||||
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
|
||||
className="w-[1.2rem] h-[1.2rem] text-[#2563eb]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -74,7 +72,7 @@ export default function PluginMarketCardComponent({
|
||||
<polyline points="7,10 12,15 17,10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
|
||||
<div className="text-sm text-[#2563eb] font-medium">
|
||||
{cardVO.installCount.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';
|
||||
|
||||
export class MCPCardVO {
|
||||
name: string;
|
||||
mode: 'stdio' | 'sse';
|
||||
enable: boolean;
|
||||
status: MCPSessionStatus;
|
||||
tools: number;
|
||||
error?: string;
|
||||
|
||||
constructor(data: MCPServer) {
|
||||
this.name = data.name;
|
||||
this.mode = data.mode;
|
||||
this.enable = data.enable;
|
||||
|
||||
// Determine status from runtime_info
|
||||
if (!data.runtime_info) {
|
||||
this.status = MCPSessionStatus.ERROR;
|
||||
this.tools = 0;
|
||||
} else if (data.runtime_info.status === MCPSessionStatus.CONNECTED) {
|
||||
this.status = data.runtime_info.status;
|
||||
this.tools = data.runtime_info.tool_count || 0;
|
||||
} else {
|
||||
this.status = data.runtime_info.status;
|
||||
this.tools = 0;
|
||||
this.error = data.runtime_info.error_message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent';
|
||||
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MCPSessionStatus } from '@/app/infra/entities/api';
|
||||
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
export default function MCPComponent({
|
||||
onEditServer,
|
||||
}: {
|
||||
askInstallServer?: (githubURL: string) => void;
|
||||
onEditServer?: (serverName: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstalledServers();
|
||||
|
||||
return () => {
|
||||
// Cleanup: clear polling interval when component unmounts
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check if any enabled server is connecting and start/stop polling accordingly
|
||||
useEffect(() => {
|
||||
const hasConnecting = installedServers.some(
|
||||
(server) =>
|
||||
server.enable && server.status === MCPSessionStatus.CONNECTING,
|
||||
);
|
||||
|
||||
if (hasConnecting && !pollingIntervalRef.current) {
|
||||
// Start polling every 3 seconds
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
fetchInstalledServers();
|
||||
}, 3000);
|
||||
} else if (!hasConnecting && pollingIntervalRef.current) {
|
||||
// Stop polling when no enabled server is connecting
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [installedServers]);
|
||||
|
||||
function fetchInstalledServers() {
|
||||
setLoading(true);
|
||||
httpClient
|
||||
.getMCPServers()
|
||||
.then((resp) => {
|
||||
const servers = resp.servers.map((server) => new MCPCardVO(server));
|
||||
setInstalledServers(servers);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch MCP servers:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
{/* 已安装的服务器列表 */}
|
||||
<div className="w-full px-[0.8rem] pt-[0rem] gap-4">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
|
||||
{t('mcp.loading')}
|
||||
</div>
|
||||
) : installedServers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
|
||||
<svg
|
||||
className="h-[3rem] w-[3rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
|
||||
</svg>
|
||||
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem]">
|
||||
{installedServers.map((server, index) => (
|
||||
<div key={`${server.name}-${index}`}>
|
||||
<MCPCardComponent
|
||||
cardVO={server}
|
||||
onCardClick={() => {
|
||||
if (onEditServer) {
|
||||
onEditServer(server.name);
|
||||
}
|
||||
}}
|
||||
onRefresh={fetchInstalledServers}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { MCPSessionStatus } from '@/app/infra/entities/api';
|
||||
|
||||
export default function MCPCardComponent({
|
||||
cardVO,
|
||||
onCardClick,
|
||||
onRefresh,
|
||||
}: {
|
||||
cardVO: MCPCardVO;
|
||||
onCardClick: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(cardVO.enable);
|
||||
const [switchEnable, setSwitchEnable] = useState(true);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [toolsCount, setToolsCount] = useState(cardVO.tools);
|
||||
const [status, setStatus] = useState(cardVO.status);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(cardVO.status);
|
||||
setToolsCount(cardVO.tools);
|
||||
setEnabled(cardVO.enable);
|
||||
}, [cardVO.status, cardVO.tools, cardVO.enable]);
|
||||
|
||||
function handleEnable(checked: boolean) {
|
||||
setSwitchEnable(false);
|
||||
httpClient
|
||||
.toggleMCPServer(cardVO.name, checked)
|
||||
.then(() => {
|
||||
setEnabled(checked);
|
||||
toast.success(t('mcp.saveSuccess'));
|
||||
onRefresh();
|
||||
setSwitchEnable(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('mcp.modifyFailed') + err.message);
|
||||
setSwitchEnable(true);
|
||||
});
|
||||
}
|
||||
|
||||
function handleTest(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setTesting(true);
|
||||
|
||||
httpClient
|
||||
.testMCPServer(cardVO.name, {})
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((taskResp) => {
|
||||
if (taskResp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
setTesting(false);
|
||||
|
||||
if (taskResp.runtime.exception) {
|
||||
toast.error(
|
||||
t('mcp.refreshFailed') + taskResp.runtime.exception,
|
||||
);
|
||||
} else {
|
||||
toast.success(t('mcp.refreshSuccess'));
|
||||
}
|
||||
|
||||
// Refresh to get updated runtime_info
|
||||
onRefresh();
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('mcp.refreshFailed') + err.message);
|
||||
setTesting(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[100%] h-[10rem] bg-white dark:bg-[#1f1f22] rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] dark:shadow-none p-[1.2rem] cursor-pointer transition-all duration-200 hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.1)] dark:hover:shadow-none"
|
||||
onClick={onCardClick}
|
||||
>
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="64"
|
||||
height="64"
|
||||
fill="rgba(70,146,221,1)"
|
||||
>
|
||||
<path d="M17.6567 14.8284L16.2425 13.4142L17.6567 12C19.2188 10.4379 19.2188 7.90524 17.6567 6.34314C16.0946 4.78105 13.5619 4.78105 11.9998 6.34314L10.5856 7.75736L9.17139 6.34314L10.5856 4.92893C12.9287 2.58578 16.7277 2.58578 19.0709 4.92893C21.414 7.27208 21.414 11.0711 19.0709 13.4142L17.6567 14.8284ZM14.8282 17.6569L13.414 19.0711C11.0709 21.4142 7.27189 21.4142 4.92875 19.0711C2.5856 16.7279 2.5856 12.9289 4.92875 10.5858L6.34296 9.17157L7.75717 10.5858L6.34296 12C4.78086 13.5621 4.78086 16.0948 6.34296 17.6569C7.90506 19.2189 10.4377 19.2189 11.9998 17.6569L13.414 16.2426L14.8282 17.6569ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></path>
|
||||
</svg>
|
||||
|
||||
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium">
|
||||
{cardVO.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
|
||||
{!enabled ? (
|
||||
// 未启用 - 橙色
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
<Ban className="w-4 h-4 text-orange-500 dark:text-orange-400" />
|
||||
<div className="text-sm text-orange-500 dark:text-orange-400 font-medium">
|
||||
{t('mcp.statusDisabled')}
|
||||
</div>
|
||||
</div>
|
||||
) : status === MCPSessionStatus.CONNECTED ? (
|
||||
// 连接成功 - 显示工具数量
|
||||
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
|
||||
<Wrench className="w-5 h-5" />
|
||||
<div className="text-base text-black dark:text-[#f0f0f0] font-medium">
|
||||
{t('mcp.toolCount', { count: toolsCount })}
|
||||
</div>
|
||||
</div>
|
||||
) : status === MCPSessionStatus.CONNECTING ? (
|
||||
// 连接中 - 蓝色加载
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
|
||||
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
|
||||
{t('mcp.connecting')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 连接失败 - 红色
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
|
||||
<div className="text-sm text-red-500 dark:text-red-400 font-medium">
|
||||
{t('mcp.connectionFailedStatus')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-between h-full">
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Switch
|
||||
className="cursor-pointer"
|
||||
checked={enabled}
|
||||
onCheckedChange={handleEnable}
|
||||
disabled={!switchEnable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-[0.4rem]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-8 w-8"
|
||||
onClick={(e) => handleTest(e)}
|
||||
disabled={testing}
|
||||
>
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
interface MCPDeleteConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
serverName: string | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function MCPDeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
serverName,
|
||||
onSuccess,
|
||||
}: MCPDeleteConfirmDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function handleDelete() {
|
||||
if (!serverName) return;
|
||||
|
||||
try {
|
||||
await httpClient.deleteMCPServer(serverName);
|
||||
toast.success(t('mcp.deleteSuccess'));
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete server:', error);
|
||||
toast.error(t('mcp.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('mcp.confirmDeleteTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>{t('mcp.confirmDeleteServer')}</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,673 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
MCPServerRuntimeInfo,
|
||||
MCPTool,
|
||||
MCPServer,
|
||||
MCPSessionStatus,
|
||||
} from '@/app/infra/entities/api';
|
||||
|
||||
// Status Display Component - 在测试中、连接中或连接失败时使用
|
||||
function StatusDisplay({
|
||||
testing,
|
||||
runtimeInfo,
|
||||
t,
|
||||
}: {
|
||||
testing: boolean;
|
||||
runtimeInfo: MCPServerRuntimeInfo;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (testing) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<svg
|
||||
className="w-5 h-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.testing')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 连接中
|
||||
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<svg
|
||||
className="w-5 h-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.connecting')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 连接失败
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
</div>
|
||||
{/* {runtimeInfo.error_message && (
|
||||
<div className="text-sm text-red-500 pl-7">
|
||||
{runtimeInfo.error_message}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tools List Component
|
||||
function ToolsList({ tools }: { tools: MCPTool[] }) {
|
||||
return (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{tools.map((tool, index) => (
|
||||
<Card key={index} className="py-3 shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{tool.name}</CardTitle>
|
||||
{tool.description && (
|
||||
<CardDescription className="text-xs">
|
||||
{tool.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z
|
||||
.string({ required_error: t('mcp.nameRequired') })
|
||||
.min(1, { message: t('mcp.nameRequired') }),
|
||||
timeout: z
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(30),
|
||||
ssereadtimeout: z
|
||||
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(300),
|
||||
url: z
|
||||
.string({ required_error: t('mcp.urlRequired') })
|
||||
.min(1, { message: t('mcp.urlRequired') }),
|
||||
extra_args: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
type: z.enum(['string', 'number', 'boolean']),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
||||
timeout: number;
|
||||
ssereadtimeout: number;
|
||||
};
|
||||
|
||||
interface MCPFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
serverName?: string | null;
|
||||
isEditMode?: boolean;
|
||||
onSuccess?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export default function MCPFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
serverName,
|
||||
isEditMode = false,
|
||||
onSuccess,
|
||||
onDelete,
|
||||
}: MCPFormDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
url: '',
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
},
|
||||
});
|
||||
|
||||
const [extraArgs, setExtraArgs] = useState<
|
||||
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
||||
>([]);
|
||||
const [mcpTesting, setMcpTesting] = useState(false);
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
|
||||
null,
|
||||
);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load server data when editing
|
||||
useEffect(() => {
|
||||
if (open && isEditMode && serverName) {
|
||||
loadServerForEdit(serverName);
|
||||
} else if (open && !isEditMode) {
|
||||
// Reset form when creating new server
|
||||
form.reset();
|
||||
setExtraArgs([]);
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
|
||||
// Cleanup polling interval when dialog closes
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, isEditMode, serverName]);
|
||||
|
||||
// Poll for updates when runtime_info status is CONNECTING
|
||||
useEffect(() => {
|
||||
if (
|
||||
!open ||
|
||||
!isEditMode ||
|
||||
!serverName ||
|
||||
!runtimeInfo ||
|
||||
runtimeInfo.status !== MCPSessionStatus.CONNECTING
|
||||
) {
|
||||
// Stop polling if conditions are not met
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Start polling if not already running
|
||||
if (!pollingIntervalRef.current) {
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
loadServerForEdit(serverName);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, isEditMode, serverName, runtimeInfo?.status]);
|
||||
|
||||
async function loadServerForEdit(serverName: string) {
|
||||
try {
|
||||
const resp = await httpClient.getMCPServer(serverName);
|
||||
const server = resp.server ?? resp;
|
||||
|
||||
const extraArgs = server.extra_args;
|
||||
form.setValue('name', server.name);
|
||||
form.setValue('url', extraArgs.url);
|
||||
form.setValue('timeout', extraArgs.timeout);
|
||||
form.setValue('ssereadtimeout', extraArgs.ssereadtimeout);
|
||||
|
||||
if (extraArgs.headers) {
|
||||
const headers = Object.entries(extraArgs.headers).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
type: 'string' as const,
|
||||
value: String(value),
|
||||
}),
|
||||
);
|
||||
setExtraArgs(headers);
|
||||
form.setValue('extra_args', headers);
|
||||
}
|
||||
|
||||
// Set runtime_info from server data
|
||||
if (server.runtime_info) {
|
||||
setRuntimeInfo(server.runtime_info);
|
||||
} else {
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load server:', error);
|
||||
toast.error(t('mcp.loadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||
// Convert extra_args to headers - all values must be strings according to MCPServerExtraArgsSSE
|
||||
const headers: Record<string, string> = {};
|
||||
value.extra_args?.forEach((arg) => {
|
||||
// Convert all values to strings to match MCPServerExtraArgsSSE.headers type
|
||||
headers[arg.key] = String(arg.value);
|
||||
});
|
||||
|
||||
try {
|
||||
const serverConfig: Omit<
|
||||
MCPServer,
|
||||
'uuid' | 'created_at' | 'updated_at' | 'runtime_info'
|
||||
> = {
|
||||
name: value.name,
|
||||
mode: 'sse' as const,
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url,
|
||||
headers: headers,
|
||||
timeout: value.timeout,
|
||||
ssereadtimeout: value.ssereadtimeout,
|
||||
},
|
||||
};
|
||||
|
||||
if (isEditMode && serverName) {
|
||||
await httpClient.updateMCPServer(serverName, serverConfig);
|
||||
toast.success(t('mcp.updateSuccess'));
|
||||
} else {
|
||||
await httpClient.createMCPServer(serverConfig);
|
||||
toast.success(t('mcp.createSuccess'));
|
||||
}
|
||||
|
||||
handleDialogClose(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to save MCP server:', error);
|
||||
toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function testMcp() {
|
||||
setMcpTesting(true);
|
||||
|
||||
try {
|
||||
const { task_id } = await httpClient.testMCPServer('_', {
|
||||
name: form.getValues('name'),
|
||||
mode: 'sse',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: form.getValues('url'),
|
||||
timeout: form.getValues('timeout'),
|
||||
ssereadtimeout: form.getValues('ssereadtimeout'),
|
||||
headers: Object.fromEntries(
|
||||
extraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
},
|
||||
});
|
||||
if (!task_id) {
|
||||
throw new Error(t('mcp.noTaskId'));
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const taskResp = await httpClient.getAsyncTask(task_id);
|
||||
|
||||
if (taskResp.runtime?.done) {
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
|
||||
if (taskResp.runtime.exception) {
|
||||
const errorMsg =
|
||||
taskResp.runtime.exception || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
setRuntimeInfo({
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: errorMsg,
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
});
|
||||
} else {
|
||||
if (isEditMode) {
|
||||
await loadServerForEdit(form.getValues('name'));
|
||||
}
|
||||
toast.success(t('mcp.testSuccess'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
const errorMsg = (err as Error).message || t('mcp.getTaskFailed');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setMcpTesting(false);
|
||||
const errorMsg = (err as Error).message || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const addExtraArg = () => {
|
||||
const newArgs = [
|
||||
...extraArgs,
|
||||
{ key: '', type: 'string' as const, value: '' },
|
||||
];
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs);
|
||||
};
|
||||
|
||||
const removeExtraArg = (index: number) => {
|
||||
const newArgs = extraArgs.filter((_, i) => i !== index);
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs);
|
||||
};
|
||||
|
||||
const updateExtraArg = (
|
||||
index: number,
|
||||
field: 'key' | 'type' | 'value',
|
||||
value: string,
|
||||
) => {
|
||||
const newArgs = [...extraArgs];
|
||||
newArgs[index] = { ...newArgs[index], [field]: value };
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs);
|
||||
};
|
||||
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
onOpenChange(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setExtraArgs([]);
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isEditMode && runtimeInfo && (
|
||||
<div className="mb-0 space-y-3">
|
||||
{/* 测试中或连接失败时显示状态 */}
|
||||
{(mcpTesting ||
|
||||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
|
||||
<div className="p-3 rounded-lg border">
|
||||
<StatusDisplay
|
||||
testing={mcpTesting}
|
||||
runtimeInfo={runtimeInfo}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 连接成功时只显示工具列表 */}
|
||||
{!mcpTesting &&
|
||||
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
|
||||
runtimeInfo.tools?.length > 0 && (
|
||||
<>
|
||||
<div className="text-sm font-medium">
|
||||
{t('mcp.toolCount', {
|
||||
count: runtimeInfo.tools?.length || 0,
|
||||
})}
|
||||
</div>
|
||||
<ToolsList tools={runtimeInfo.tools} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.url')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.timeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('mcp.timeout')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ssereadtimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('mcp.sseTimeoutDescription')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.extraParameters')}</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{extraArgs.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('models.keyName')}
|
||||
value={arg.key}
|
||||
onChange={(e) =>
|
||||
updateExtraArg(index, 'key', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={arg.type}
|
||||
onValueChange={(value) =>
|
||||
updateExtraArg(index, 'type', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('models.type')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectItem value="string">
|
||||
{t('models.string')}
|
||||
</SelectItem>
|
||||
<SelectItem value="number">
|
||||
{t('models.number')}
|
||||
</SelectItem>
|
||||
<SelectItem value="boolean">
|
||||
{t('models.boolean')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder={t('models.value')}
|
||||
value={arg.value}
|
||||
onChange={(e) =>
|
||||
updateExtraArg(index, 'value', e.target.value)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
onClick={() => removeExtraArg(index)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5 text-red-500"
|
||||
>
|
||||
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addExtraArg}>
|
||||
{t('models.addParameter')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t('mcp.extraParametersDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
<DialogFooter>
|
||||
{isEditMode && onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit">
|
||||
{isEditMode ? t('common.save') : t('common.submit')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleDialogClose(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,10 @@ import PluginInstalledComponent, {
|
||||
PluginInstalledComponentRef,
|
||||
} from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent';
|
||||
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
|
||||
import MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent';
|
||||
import MCPFormDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPFormDialog';
|
||||
import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog';
|
||||
// import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog';
|
||||
import styles from './plugins.module.css';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
PlusIcon,
|
||||
ChevronDownIcon,
|
||||
@@ -22,8 +14,6 @@ import {
|
||||
StoreIcon,
|
||||
Download,
|
||||
Power,
|
||||
Github,
|
||||
ChevronLeft,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -39,7 +29,7 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -49,62 +39,28 @@ import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
|
||||
enum PluginInstallStatus {
|
||||
WAIT_INPUT = 'wait_input',
|
||||
SELECT_RELEASE = 'select_release',
|
||||
SELECT_ASSET = 'select_asset',
|
||||
ASK_CONFIRM = 'ask_confirm',
|
||||
INSTALLING = 'installing',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
published_at: string;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
interface GithubAsset {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
download_url: string;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export default function PluginConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('installed');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
// const [sortModalOpen, setSortModalOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('installed');
|
||||
const [installSource, setInstallSource] = useState<string>('local');
|
||||
const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const [mcpSSEModalOpen, setMcpSSEModalOpen] = useState(false);
|
||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [githubURL, setGithubURL] = useState('');
|
||||
const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]);
|
||||
const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(
|
||||
null,
|
||||
);
|
||||
const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]);
|
||||
const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(null);
|
||||
const [githubOwner, setGithubOwner] = useState('');
|
||||
const [githubRepo, setGithubRepo] = useState('');
|
||||
const [fetchingReleases, setFetchingReleases] = useState(false);
|
||||
const [fetchingAssets, setFetchingAssets] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [pluginSystemStatus, setPluginSystemStatus] =
|
||||
useState<ApiRespPluginSystemStatus | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
|
||||
const [editingServerName, setEditingServerName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPluginSystemStatus = async () => {
|
||||
@@ -121,33 +77,28 @@ export default function PluginConfigPage() {
|
||||
};
|
||||
|
||||
fetchPluginSystemStatus();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
function watchTask(taskId: number) {
|
||||
let alreadySuccess = false;
|
||||
console.log('taskId:', taskId);
|
||||
|
||||
// 每秒拉取一次任务状态
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((resp) => {
|
||||
console.log('task status:', resp);
|
||||
if (resp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
if (resp.runtime.exception) {
|
||||
setInstallError(resp.runtime.exception);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
} else {
|
||||
// success
|
||||
if (!alreadySuccess) {
|
||||
toast.success(t('plugins.installSuccess'));
|
||||
alreadySuccess = true;
|
||||
}
|
||||
resetGithubState();
|
||||
setGithubURL('');
|
||||
setModalOpen(false);
|
||||
pluginInstalledRef.current?.refreshPluginList();
|
||||
}
|
||||
@@ -156,96 +107,8 @@ export default function PluginConfigPage() {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
|
||||
|
||||
function resetGithubState() {
|
||||
setGithubURL('');
|
||||
setGithubReleases([]);
|
||||
setSelectedRelease(null);
|
||||
setGithubAssets([]);
|
||||
setSelectedAsset(null);
|
||||
setGithubOwner('');
|
||||
setGithubRepo('');
|
||||
setFetchingReleases(false);
|
||||
setFetchingAssets(false);
|
||||
}
|
||||
|
||||
async function fetchGithubReleases() {
|
||||
if (!githubURL.trim()) {
|
||||
toast.error(t('plugins.enterRepoUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingReleases(true);
|
||||
setInstallError(null);
|
||||
|
||||
try {
|
||||
const result = await httpClient.getGithubReleases(githubURL);
|
||||
setGithubReleases(result.releases);
|
||||
setGithubOwner(result.owner);
|
||||
setGithubRepo(result.repo);
|
||||
|
||||
if (result.releases.length === 0) {
|
||||
toast.warning(t('plugins.noReleasesFound'));
|
||||
} else {
|
||||
setPluginInstallStatus(PluginInstallStatus.SELECT_RELEASE);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch GitHub releases:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setInstallError(errorMessage || t('plugins.fetchReleasesError'));
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
} finally {
|
||||
setFetchingReleases(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReleaseSelect(release: GithubRelease) {
|
||||
setSelectedRelease(release);
|
||||
setFetchingAssets(true);
|
||||
setInstallError(null);
|
||||
|
||||
try {
|
||||
const result = await httpClient.getGithubReleaseAssets(
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
release.id,
|
||||
);
|
||||
setGithubAssets(result.assets);
|
||||
|
||||
if (result.assets.length === 0) {
|
||||
toast.warning(t('plugins.noAssetsFound'));
|
||||
} else {
|
||||
setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch GitHub release assets:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setInstallError(errorMessage || t('plugins.fetchAssetsError'));
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
} finally {
|
||||
setFetchingAssets(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAssetSelect(asset: GithubAsset) {
|
||||
setSelectedAsset(asset);
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
}
|
||||
|
||||
function handleModalConfirm() {
|
||||
if (installSource === 'github' && selectedAsset && selectedRelease) {
|
||||
installPlugin('github', {
|
||||
asset_url: selectedAsset.download_url,
|
||||
owner: githubOwner,
|
||||
repo: githubRepo,
|
||||
release_tag: selectedRelease.tag_name,
|
||||
});
|
||||
} else {
|
||||
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
function installPlugin(
|
||||
@@ -255,12 +118,7 @@ export default function PluginConfigPage() {
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
if (installSource === 'github') {
|
||||
httpClient
|
||||
.installPluginFromGithub(
|
||||
installInfo.asset_url,
|
||||
installInfo.owner,
|
||||
installInfo.repo,
|
||||
installInfo.release_tag,
|
||||
)
|
||||
.installPluginFromGithub(installInfo.url)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
watchTask(taskId);
|
||||
@@ -319,7 +177,7 @@ export default function PluginConfigPage() {
|
||||
setInstallError(null);
|
||||
installPlugin('local', { file });
|
||||
},
|
||||
[t, pluginSystemStatus, installPlugin],
|
||||
[t, pluginSystemStatus],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(() => {
|
||||
@@ -334,7 +192,7 @@ export default function PluginConfigPage() {
|
||||
if (file) {
|
||||
uploadPluginFile(file);
|
||||
}
|
||||
|
||||
// 清空input值,以便可以重复选择同一个文件
|
||||
event.target.value = '';
|
||||
},
|
||||
[uploadPluginFile],
|
||||
@@ -376,6 +234,7 @@ export default function PluginConfigPage() {
|
||||
[uploadPluginFile, isPluginSystemReady, t],
|
||||
);
|
||||
|
||||
// 插件系统未启用的状态显示
|
||||
const renderPluginDisabledState = () => (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
|
||||
<Power className="w-16 h-16 text-gray-400 mb-4" />
|
||||
@@ -388,6 +247,7 @@ export default function PluginConfigPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 插件系统连接异常的状态显示
|
||||
const renderPluginConnectionErrorState = () => (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
|
||||
<svg
|
||||
@@ -409,6 +269,7 @@ export default function PluginConfigPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 加载状态显示
|
||||
const renderLoadingState = () => (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] pt-[10vh]">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
@@ -417,6 +278,7 @@ export default function PluginConfigPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 根据状态返回不同的内容
|
||||
if (statusLoading) {
|
||||
return renderLoadingState();
|
||||
}
|
||||
@@ -454,69 +316,40 @@ export default function PluginConfigPage() {
|
||||
{t('plugins.marketplace')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="mcp-servers"
|
||||
className="px-6 py-4 cursor-pointer"
|
||||
>
|
||||
{t('mcp.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
{/* <Button
|
||||
variant="outline"
|
||||
className="px-6 py-4 cursor-pointer mr-2"
|
||||
onClick={() => {
|
||||
// setSortModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('plugins.arrange')}
|
||||
</Button> */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="default" className="px-6 py-4 cursor-pointer">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{activeTab === 'mcp-servers'
|
||||
? t('mcp.add')
|
||||
: t('plugins.install')}
|
||||
{t('plugins.install')}
|
||||
<ChevronDownIcon className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{activeTab === 'mcp-servers' ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setActiveTab('mcp-servers');
|
||||
setIsEditMode(false);
|
||||
setEditingServerName(null);
|
||||
setMcpSSEModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{t('mcp.createServer')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setActiveTab('market');
|
||||
}}
|
||||
>
|
||||
<StoreIcon className="w-4 h-4" />
|
||||
{t('plugins.marketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleFileSelect}>
|
||||
<UploadIcon className="w-4 h-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setInstallSource('github');
|
||||
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
|
||||
setInstallError(null);
|
||||
resetGithubState();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuItem onClick={handleFileSelect}>
|
||||
<UploadIcon className="w-4 h-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setActiveTab('market');
|
||||
}}
|
||||
>
|
||||
<StoreIcon className="w-4 h-4" />
|
||||
{t('plugins.marketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -539,259 +372,51 @@ export default function PluginConfigPage() {
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp-servers">
|
||||
<MCPServerComponent
|
||||
key={refreshKey}
|
||||
onEditServer={(serverName) => {
|
||||
setEditingServerName(serverName);
|
||||
setIsEditMode(true);
|
||||
setMcpSSEModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Dialog
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setModalOpen(open);
|
||||
if (!open) {
|
||||
resetGithubState();
|
||||
setInstallError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="w-[500px] p-6 bg-white dark:bg-[#1a1a1e]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-4">
|
||||
{installSource === 'github' ? (
|
||||
<Github className="size-6" />
|
||||
) : (
|
||||
<Download className="size-6" />
|
||||
)}
|
||||
<Download className="size-6" />
|
||||
<span>{t('plugins.installPlugin')}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* GitHub Install Flow */}
|
||||
{installSource === 'github' &&
|
||||
pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.enterRepoUrl')}</p>
|
||||
<Input
|
||||
placeholder={t('plugins.repoUrlPlaceholder')}
|
||||
value={githubURL}
|
||||
onChange={(e) => setGithubURL(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
{fetchingReleases && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('plugins.fetchingReleases')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installSource === 'github' &&
|
||||
pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="font-medium">{t('plugins.selectRelease')}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
|
||||
setGithubReleases([]);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('plugins.backToRepoUrl')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
|
||||
{githubReleases.map((release) => (
|
||||
<Card
|
||||
key={release.id}
|
||||
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-4"
|
||||
onClick={() => handleReleaseSelect(release)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-start justify-between px-3 space-y-0">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm">
|
||||
{release.name || release.tag_name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{t('plugins.releaseTag', { tag: release.tag_name })}{' '}
|
||||
•{' '}
|
||||
{t('plugins.publishedAt', {
|
||||
date: new Date(
|
||||
release.published_at,
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{release.prerelease && (
|
||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded ml-2 shrink-0">
|
||||
{t('plugins.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{fetchingAssets && (
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
{t('plugins.loading')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installSource === 'github' &&
|
||||
pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="font-medium">{t('plugins.selectAsset')}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPluginInstallStatus(
|
||||
PluginInstallStatus.SELECT_RELEASE,
|
||||
);
|
||||
setGithubAssets([]);
|
||||
setSelectedAsset(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('plugins.backToReleases')}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedRelease && (
|
||||
<div className="mb-4 p-2 bg-gray-50 dark:bg-gray-900 rounded">
|
||||
<div className="text-sm font-medium">
|
||||
{selectedRelease.name || selectedRelease.tag_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{selectedRelease.tag_name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
|
||||
{githubAssets.map((asset) => (
|
||||
<Card
|
||||
key={asset.id}
|
||||
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-3"
|
||||
onClick={() => handleAssetSelect(asset)}
|
||||
>
|
||||
<CardHeader className="px-3">
|
||||
<CardTitle className="text-sm">{asset.name}</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{t('plugins.assetSize', {
|
||||
size: formatFileSize(asset.size),
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Marketplace Install Confirm */}
|
||||
{installSource === 'marketplace' &&
|
||||
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">
|
||||
{t('plugins.askConfirm', {
|
||||
name: installInfo.plugin_name,
|
||||
version: installInfo.plugin_version,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GitHub Install Confirm */}
|
||||
{installSource === 'github' &&
|
||||
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="font-medium">{t('plugins.confirmInstall')}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);
|
||||
setSelectedAsset(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{t('plugins.backToAssets')}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedRelease && selectedAsset && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded space-y-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium">Repository: </span>
|
||||
<span className="text-sm">
|
||||
{githubOwner}/{githubRepo}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Release: </span>
|
||||
<span className="text-sm">
|
||||
{selectedRelease.tag_name}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">File: </span>
|
||||
<span className="text-sm">{selectedAsset.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installing State */}
|
||||
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.onlySupportGithub')}</p>
|
||||
<Input
|
||||
placeholder={t('plugins.enterGithubLink')}
|
||||
value={githubURL}
|
||||
onChange={(e) => setGithubURL(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">
|
||||
{t('plugins.askConfirm', {
|
||||
name: installInfo.plugin_name,
|
||||
version: installInfo.plugin_version,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.installing')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.installFailed')}</p>
|
||||
<p className="mb-2 text-red-500">{installError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT &&
|
||||
installSource === 'github' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setModalOpen(false);
|
||||
resetGithubState();
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={fetchGithubReleases}
|
||||
disabled={!githubURL.trim() || fetchingReleases}
|
||||
>
|
||||
{fetchingReleases
|
||||
? t('plugins.loading')
|
||||
: t('common.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
{(pluginInstallStatus === PluginInstallStatus.WAIT_INPUT ||
|
||||
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM) && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
@@ -810,6 +435,7 @@ export default function PluginConfigPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 拖拽提示覆盖层 */}
|
||||
{isDragOver && (
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-50 flex items-center justify-center z-50 pointer-events-none">
|
||||
<div className="bg-white rounded-lg p-8 shadow-lg border-2 border-dashed border-gray-500">
|
||||
@@ -823,32 +449,13 @@ export default function PluginConfigPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MCPFormDialog
|
||||
open={mcpSSEModalOpen}
|
||||
onOpenChange={setMcpSSEModalOpen}
|
||||
serverName={editingServerName}
|
||||
isEditMode={isEditMode}
|
||||
onSuccess={() => {
|
||||
setEditingServerName(null);
|
||||
setIsEditMode(false);
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
{/* <PluginSortDialog
|
||||
open={sortModalOpen}
|
||||
onOpenChange={setSortModalOpen}
|
||||
onSortComplete={() => {
|
||||
pluginInstalledRef.current?.refreshPluginList();
|
||||
}}
|
||||
onDelete={() => {
|
||||
setShowDeleteConfirmModal(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<MCPDeleteConfirmDialog
|
||||
open={showDeleteConfirmModal}
|
||||
onOpenChange={setShowDeleteConfirmModal}
|
||||
serverName={editingServerName}
|
||||
onSuccess={() => {
|
||||
setMcpSSEModalOpen(false);
|
||||
setEditingServerName(null);
|
||||
setIsEditMode(false);
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,49 +308,3 @@ export interface RetrieveResult {
|
||||
export interface ApiRespKnowledgeBaseRetrieve {
|
||||
results: RetrieveResult[];
|
||||
}
|
||||
|
||||
// MCP
|
||||
export interface ApiRespMCPServers {
|
||||
servers: MCPServer[];
|
||||
}
|
||||
|
||||
export interface ApiRespMCPServer {
|
||||
server: MCPServer;
|
||||
}
|
||||
|
||||
export interface MCPServerExtraArgsSSE {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
timeout: number;
|
||||
ssereadtimeout: number;
|
||||
}
|
||||
|
||||
export enum MCPSessionStatus {
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export interface MCPServerRuntimeInfo {
|
||||
status: MCPSessionStatus;
|
||||
error_message: string;
|
||||
tool_count: number;
|
||||
tools: MCPTool[];
|
||||
}
|
||||
|
||||
export interface MCPServer {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
mode: 'stdio' | 'sse';
|
||||
enable: boolean;
|
||||
extra_args: MCPServerExtraArgsSSE;
|
||||
runtime_info?: MCPServerRuntimeInfo;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface MCPTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters?: object;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ export interface IDynamicFormItemSchema {
|
||||
type: DynamicFormItemType;
|
||||
description?: I18nObject;
|
||||
options?: IDynamicFormItemOption[];
|
||||
|
||||
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
||||
scopes?: string[];
|
||||
accept?: string; // For file type: accepted MIME types
|
||||
}
|
||||
|
||||
export enum DynamicFormItemType {
|
||||
@@ -20,22 +16,12 @@ export enum DynamicFormItemType {
|
||||
FLOAT = 'float',
|
||||
BOOLEAN = 'boolean',
|
||||
STRING = 'string',
|
||||
TEXT = 'text',
|
||||
STRING_ARRAY = 'array[string]',
|
||||
FILE = 'file',
|
||||
FILE_ARRAY = 'array[file]',
|
||||
SELECT = 'select',
|
||||
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
||||
PROMPT_EDITOR = 'prompt-editor',
|
||||
UNKNOWN = 'unknown',
|
||||
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
|
||||
PLUGIN_SELECTOR = 'plugin-selector',
|
||||
BOT_SELECTOR = 'bot-selector',
|
||||
}
|
||||
|
||||
export interface IFileConfig {
|
||||
file_key: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export interface IDynamicFormItemOption {
|
||||
|
||||
@@ -33,11 +33,7 @@ import {
|
||||
ApiRespProviderEmbeddingModel,
|
||||
EmbeddingModel,
|
||||
ApiRespPluginSystemStatus,
|
||||
ApiRespMCPServers,
|
||||
ApiRespMCPServer,
|
||||
MCPServer,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
|
||||
@@ -170,26 +166,6 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.delete(`/api/v1/pipelines/${uuid}`);
|
||||
}
|
||||
|
||||
public getPipelineExtensions(uuid: string): Promise<{
|
||||
bound_plugins: Array<{ author: string; name: string }>;
|
||||
available_plugins: Plugin[];
|
||||
bound_mcp_servers: string[];
|
||||
available_mcp_servers: MCPServer[];
|
||||
}> {
|
||||
return this.get(`/api/v1/pipelines/${uuid}/extensions`);
|
||||
}
|
||||
|
||||
public updatePipelineExtensions(
|
||||
uuid: string,
|
||||
bound_plugins: Array<{ author: string; name: string }>,
|
||||
bound_mcp_servers: string[],
|
||||
): Promise<object> {
|
||||
return this.put(`/api/v1/pipelines/${uuid}/extensions`, {
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Debug WebChat API ============
|
||||
|
||||
// ============ Debug WebChat API ============
|
||||
@@ -463,26 +439,6 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.put(`/api/v1/plugins/${author}/${name}/config`, config);
|
||||
}
|
||||
|
||||
public uploadPluginConfigFile(file: File): Promise<{ file_key: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return this.request<{ file_key: string }>({
|
||||
method: 'post',
|
||||
url: '/api/v1/plugins/config-files',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public deletePluginConfigFile(
|
||||
fileKey: string,
|
||||
): Promise<{ deleted: boolean }> {
|
||||
return this.delete(`/api/v1/plugins/config-files/${fileKey}`);
|
||||
}
|
||||
|
||||
public getPluginIconURL(author: string, name: string): string {
|
||||
if (this.instance.defaults.baseURL === '/') {
|
||||
const url = window.location.href;
|
||||
@@ -495,52 +451,9 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
public installPluginFromGithub(
|
||||
assetUrl: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
releaseTag: string,
|
||||
source: string,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/plugins/install/github', {
|
||||
asset_url: assetUrl,
|
||||
owner,
|
||||
repo,
|
||||
release_tag: releaseTag,
|
||||
});
|
||||
}
|
||||
|
||||
public getGithubReleases(repoUrl: string): Promise<{
|
||||
releases: Array<{
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
published_at: string;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
}>;
|
||||
owner: string;
|
||||
repo: string;
|
||||
}> {
|
||||
return this.post('/api/v1/plugins/github/releases', { repo_url: repoUrl });
|
||||
}
|
||||
|
||||
public getGithubReleaseAssets(
|
||||
owner: string,
|
||||
repo: string,
|
||||
releaseId: number,
|
||||
): Promise<{
|
||||
assets: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
download_url: string;
|
||||
content_type: string;
|
||||
}>;
|
||||
}> {
|
||||
return this.post('/api/v1/plugins/github/release-assets', {
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
});
|
||||
return this.post('/api/v1/plugins/install/github', { source });
|
||||
}
|
||||
|
||||
public installPluginFromLocal(file: File): Promise<AsyncTaskCreatedResp> {
|
||||
@@ -564,11 +477,8 @@ export class BackendClient extends BaseHttpClient {
|
||||
public removePlugin(
|
||||
author: string,
|
||||
name: string,
|
||||
deleteData: boolean = false,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.delete(
|
||||
`/api/v1/plugins/${author}/${name}?delete_data=${deleteData}`,
|
||||
);
|
||||
return this.delete(`/api/v1/plugins/${author}/${name}`);
|
||||
}
|
||||
|
||||
public upgradePlugin(
|
||||
@@ -578,58 +488,6 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.post(`/api/v1/plugins/${author}/${name}/upgrade`);
|
||||
}
|
||||
|
||||
// ============ MCP API ============
|
||||
public getMCPServers(): Promise<ApiRespMCPServers> {
|
||||
return this.get('/api/v1/mcp/servers');
|
||||
}
|
||||
|
||||
public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {
|
||||
return this.get(`/api/v1/mcp/servers/${serverName}`);
|
||||
}
|
||||
|
||||
public createMCPServer(server: MCPServer): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/mcp/servers', server);
|
||||
}
|
||||
|
||||
public updateMCPServer(
|
||||
serverName: string,
|
||||
server: Partial<MCPServer>,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.put(`/api/v1/mcp/servers/${serverName}`, server);
|
||||
}
|
||||
|
||||
public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
|
||||
return this.delete(`/api/v1/mcp/servers/${serverName}`);
|
||||
}
|
||||
|
||||
public toggleMCPServer(
|
||||
serverName: string,
|
||||
target_enabled: boolean,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.put(`/api/v1/mcp/servers/${serverName}`, {
|
||||
enable: target_enabled,
|
||||
});
|
||||
}
|
||||
|
||||
public testMCPServer(
|
||||
serverName: string,
|
||||
serverData: object,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post(`/api/v1/mcp/servers/${serverName}/test`, serverData);
|
||||
}
|
||||
|
||||
public installMCPServerFromGithub(
|
||||
source: string,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/mcp/install/github', { source });
|
||||
}
|
||||
|
||||
public installMCPServerFromSSE(
|
||||
source: object,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/mcp/servers', { source });
|
||||
}
|
||||
|
||||
// ============ System API ============
|
||||
public getSystemInfo(): Promise<ApiRespSystemInfo> {
|
||||
return this.get('/api/v1/system/info');
|
||||
|
||||
@@ -38,7 +38,7 @@ export abstract class BaseHttpClient {
|
||||
|
||||
this.instance = axios.create({
|
||||
baseURL: baseURL,
|
||||
timeout: 30000,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
||||
@@ -69,14 +69,6 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${name}/resources/icon`;
|
||||
}
|
||||
|
||||
public getPluginAssetURL(
|
||||
author: string,
|
||||
pluginName: string,
|
||||
filepath: string,
|
||||
): string {
|
||||
return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${pluginName}/resources/assets/${filepath}`;
|
||||
}
|
||||
|
||||
public getPluginMarketplaceURL(author: string, name: string): string {
|
||||
return `${this.baseURL}/market?author=${author}&plugin=${name}`;
|
||||
}
|
||||
|
||||
394
web/src/app/infra/websocket/PipelineWebSocketClient.ts
Normal file
394
web/src/app/infra/websocket/PipelineWebSocketClient.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Pipeline WebSocket Client
|
||||
*
|
||||
* Provides real-time bidirectional communication for pipeline debugging.
|
||||
* Supports person and group session isolation.
|
||||
*/
|
||||
|
||||
import { Message } from '@/app/infra/entities/message';
|
||||
|
||||
export type SessionType = 'person' | 'group';
|
||||
|
||||
export interface WebSocketEventData {
|
||||
// Connected event
|
||||
connected?: {
|
||||
connection_id: string;
|
||||
session_type: SessionType;
|
||||
pipeline_uuid: string;
|
||||
};
|
||||
|
||||
// History event
|
||||
history?: {
|
||||
messages: Message[];
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
// Message sent confirmation
|
||||
message_sent?: {
|
||||
client_message_id: string;
|
||||
server_message_id: number;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// Message start
|
||||
message_start?: {
|
||||
message_id: number;
|
||||
role: 'assistant';
|
||||
timestamp: string;
|
||||
reply_to: number;
|
||||
};
|
||||
|
||||
// Message chunk
|
||||
message_chunk?: {
|
||||
message_id: number;
|
||||
content: string;
|
||||
message_chain: object[];
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// Message complete
|
||||
message_complete?: {
|
||||
message_id: number;
|
||||
final_content: string;
|
||||
message_chain: object[];
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// Message error
|
||||
message_error?: {
|
||||
message_id: number;
|
||||
error: string;
|
||||
error_code?: string;
|
||||
};
|
||||
|
||||
// Interrupted
|
||||
interrupted?: {
|
||||
message_id: number;
|
||||
partial_content: string;
|
||||
};
|
||||
|
||||
// Plugin message
|
||||
plugin_message?: {
|
||||
message_id: number;
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
message_chain: object[];
|
||||
timestamp: string;
|
||||
source: 'plugin';
|
||||
};
|
||||
|
||||
// Error
|
||||
error?: {
|
||||
error: string;
|
||||
error_code: string;
|
||||
details?: object;
|
||||
};
|
||||
|
||||
// Pong
|
||||
pong?: {
|
||||
timestamp: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class PipelineWebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private pipelineId: string;
|
||||
private sessionType: SessionType;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private pingInterval: NodeJS.Timeout | null = null;
|
||||
private reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
private isManualDisconnect = false;
|
||||
|
||||
// Event callbacks
|
||||
public onConnected?: (data: WebSocketEventData['connected']) => void;
|
||||
public onHistory?: (data: WebSocketEventData['history']) => void;
|
||||
public onMessageSent?: (data: WebSocketEventData['message_sent']) => void;
|
||||
public onMessageStart?: (data: WebSocketEventData['message_start']) => void;
|
||||
public onMessageChunk?: (data: WebSocketEventData['message_chunk']) => void;
|
||||
public onMessageComplete?: (
|
||||
data: WebSocketEventData['message_complete'],
|
||||
) => void;
|
||||
public onMessageError?: (data: WebSocketEventData['message_error']) => void;
|
||||
public onInterrupted?: (data: WebSocketEventData['interrupted']) => void;
|
||||
public onPluginMessage?: (data: WebSocketEventData['plugin_message']) => void;
|
||||
public onError?: (data: WebSocketEventData['error']) => void;
|
||||
public onDisconnected?: () => void;
|
||||
|
||||
constructor(pipelineId: string, sessionType: SessionType) {
|
||||
this.pipelineId = pipelineId;
|
||||
this.sessionType = sessionType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
connect(token: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.isManualDisconnect = false;
|
||||
|
||||
const wsUrl = this.buildWebSocketUrl();
|
||||
console.log(`[WebSocket] Connecting to ${wsUrl}...`);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Failed to create WebSocket:', error);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('[WebSocket] Connection opened');
|
||||
|
||||
// Send connect event with session type and token
|
||||
this.send('connect', {
|
||||
pipeline_uuid: this.pipelineId,
|
||||
session_type: this.sessionType,
|
||||
token,
|
||||
});
|
||||
|
||||
// Start ping interval
|
||||
this.startPing();
|
||||
|
||||
// Reset reconnect attempts on successful connection
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleMessage(event);
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('[WebSocket] Error:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log(
|
||||
`[WebSocket] Connection closed: code=${event.code}, reason=${event.reason}`,
|
||||
);
|
||||
this.handleDisconnect();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket message
|
||||
*/
|
||||
private handleMessage(event: MessageEvent) {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
const { type, data } = message;
|
||||
|
||||
console.log(`[WebSocket] Received: ${type}`, data);
|
||||
|
||||
switch (type) {
|
||||
case 'connected':
|
||||
this.onConnected?.(data);
|
||||
break;
|
||||
case 'history':
|
||||
this.onHistory?.(data);
|
||||
break;
|
||||
case 'message_sent':
|
||||
this.onMessageSent?.(data);
|
||||
break;
|
||||
case 'message_start':
|
||||
this.onMessageStart?.(data);
|
||||
break;
|
||||
case 'message_chunk':
|
||||
this.onMessageChunk?.(data);
|
||||
break;
|
||||
case 'message_complete':
|
||||
this.onMessageComplete?.(data);
|
||||
break;
|
||||
case 'message_error':
|
||||
this.onMessageError?.(data);
|
||||
break;
|
||||
case 'interrupted':
|
||||
this.onInterrupted?.(data);
|
||||
break;
|
||||
case 'plugin_message':
|
||||
this.onPluginMessage?.(data);
|
||||
break;
|
||||
case 'error':
|
||||
this.onError?.(data);
|
||||
break;
|
||||
case 'pong':
|
||||
// Heartbeat response, no action needed
|
||||
break;
|
||||
default:
|
||||
console.warn(`[WebSocket] Unknown message type: ${type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Failed to parse message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to server
|
||||
*/
|
||||
sendMessage(messageChain: object[]): string {
|
||||
const clientMessageId = this.generateMessageId();
|
||||
this.send('send_message', {
|
||||
message_chain: messageChain,
|
||||
client_message_id: clientMessageId,
|
||||
});
|
||||
return clientMessageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load history messages
|
||||
*/
|
||||
loadHistory(beforeMessageId?: number, limit?: number) {
|
||||
this.send('load_history', {
|
||||
before_message_id: beforeMessageId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt streaming message
|
||||
*/
|
||||
interrupt(messageId: number) {
|
||||
this.send('interrupt', { message_id: messageId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event to server
|
||||
*/
|
||||
private send(type: string, data: object) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
const message = JSON.stringify({ type, data });
|
||||
console.log(`[WebSocket] Sending: ${type}`, data);
|
||||
this.ws.send(message);
|
||||
} else {
|
||||
console.warn(
|
||||
`[WebSocket] Cannot send message, connection not open (state: ${this.ws?.readyState})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ping interval (heartbeat)
|
||||
*/
|
||||
private startPing() {
|
||||
this.stopPing();
|
||||
this.pingInterval = setInterval(() => {
|
||||
this.send('ping', { timestamp: Date.now() });
|
||||
}, 30000); // Ping every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ping interval
|
||||
*/
|
||||
private stopPing() {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle disconnection
|
||||
*/
|
||||
private handleDisconnect() {
|
||||
this.stopPing();
|
||||
this.onDisconnected?.();
|
||||
|
||||
// Auto reconnect if not manual disconnect
|
||||
if (
|
||||
!this.isManualDisconnect &&
|
||||
this.reconnectAttempts < this.maxReconnectAttempts
|
||||
) {
|
||||
const delay = Math.min(2000 * Math.pow(2, this.reconnectAttempts), 30000);
|
||||
console.log(
|
||||
`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})`,
|
||||
);
|
||||
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
// Note: Need to get token again, should be handled by caller
|
||||
console.warn(
|
||||
'[WebSocket] Auto-reconnect requires token, please reconnect manually',
|
||||
);
|
||||
}, delay);
|
||||
} else if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error(
|
||||
'[WebSocket] Max reconnect attempts reached, giving up',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server
|
||||
*/
|
||||
disconnect() {
|
||||
this.isManualDisconnect = true;
|
||||
this.stopPing();
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Client disconnect');
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
console.log('[WebSocket] Disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WebSocket URL
|
||||
*/
|
||||
private buildWebSocketUrl(): string {
|
||||
// Get current base URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
|
||||
return `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/chat/ws`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique client message ID
|
||||
*/
|
||||
private generateMessageId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection state
|
||||
*/
|
||||
getState():
|
||||
| 'CONNECTING'
|
||||
| 'OPEN'
|
||||
| 'CLOSING'
|
||||
| 'CLOSED'
|
||||
| 'DISCONNECTED' {
|
||||
if (!this.ws) return 'DISCONNECTED';
|
||||
|
||||
switch (this.ws.readyState) {
|
||||
case WebSocket.CONNECTING:
|
||||
return 'CONNECTING';
|
||||
case WebSocket.OPEN:
|
||||
return 'OPEN';
|
||||
case WebSocket.CLOSING:
|
||||
return 'CLOSING';
|
||||
case WebSocket.CLOSED:
|
||||
return 'CLOSED';
|
||||
default:
|
||||
return 'DISCONNECTED';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -7,71 +7,9 @@ import { XIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Dialog({
|
||||
onOpenChange,
|
||||
open,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
const handleOpenChange = React.useCallback(
|
||||
(isOpen: boolean) => {
|
||||
onOpenChange?.(isOpen);
|
||||
|
||||
// 当对话框关闭时,确保清理 body 样式
|
||||
if (!isOpen) {
|
||||
// 立即清理
|
||||
document.body.style.removeProperty('pointer-events');
|
||||
document.body.style.removeProperty('overflow');
|
||||
|
||||
// 延迟再次清理,确保覆盖 Radix 的设置
|
||||
setTimeout(() => {
|
||||
document.body.style.removeProperty('pointer-events');
|
||||
document.body.style.removeProperty('overflow');
|
||||
}, 0);
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.style.removeProperty('pointer-events');
|
||||
document.body.style.removeProperty('overflow');
|
||||
}, 50);
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.style.removeProperty('pointer-events');
|
||||
document.body.style.removeProperty('overflow');
|
||||
}, 150);
|
||||
}
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
// 使用 effect 监控 open 状态变化
|
||||
React.useEffect(() => {
|
||||
if (open === false) {
|
||||
const cleanup = () => {
|
||||
document.body.style.removeProperty('pointer-events');
|
||||
document.body.style.removeProperty('overflow');
|
||||
};
|
||||
|
||||
cleanup();
|
||||
const timer1 = setTimeout(cleanup, 0);
|
||||
const timer2 = setTimeout(cleanup, 50);
|
||||
const timer3 = setTimeout(cleanup, 150);
|
||||
const timer4 = setTimeout(cleanup, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer1);
|
||||
clearTimeout(timer2);
|
||||
clearTimeout(timer3);
|
||||
clearTimeout(timer4);
|
||||
};
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
open={open}
|
||||
{...props}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
);
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
@@ -122,6 +60,7 @@ function DialogContent({
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
onInteractOutside={() => {}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -58,23 +58,6 @@ const enUS = {
|
||||
changePasswordSuccess: 'Password changed successfully',
|
||||
changePasswordFailed:
|
||||
'Failed to change password, please check your current password',
|
||||
apiKeys: 'API Keys',
|
||||
manageApiKeys: 'Manage API Keys',
|
||||
createApiKey: 'Create API Key',
|
||||
apiKeyName: 'API Key Name',
|
||||
apiKeyDescription: 'API Key Description',
|
||||
apiKeyValue: 'API Key Value',
|
||||
apiKeyCreated: 'API key created successfully',
|
||||
apiKeyDeleted: 'API key deleted successfully',
|
||||
apiKeyDeleteConfirm: 'Are you sure you want to delete this API key?',
|
||||
apiKeyNameRequired: 'API key name is required',
|
||||
copyApiKey: 'Copy API Key',
|
||||
apiKeyCopied: 'API key copied to clipboard',
|
||||
noApiKeys: 'No API keys configured',
|
||||
apiKeyHint:
|
||||
'API keys allow external systems to access LangBot Service APIs',
|
||||
actions: 'Actions',
|
||||
apiKeyCreatedMessage: 'Please copy this API key.',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
@@ -156,7 +139,6 @@ const enUS = {
|
||||
adapterConfig: 'Adapter Configuration',
|
||||
bindPipeline: 'Bind Pipeline',
|
||||
selectPipeline: 'Select Pipeline',
|
||||
selectBot: 'Select Bot',
|
||||
botLogTitle: 'Bot Log',
|
||||
enableAutoRefresh: 'Enable Auto Refresh',
|
||||
session: 'Session',
|
||||
@@ -169,9 +151,9 @@ const enUS = {
|
||||
logs: 'Logs',
|
||||
},
|
||||
plugins: {
|
||||
title: 'Extensions',
|
||||
title: 'Plugins',
|
||||
description:
|
||||
'Install and configure plugins to extend functionality, please select them in the pipeline configuration',
|
||||
'Install and configure plugins to extend LangBot functionality',
|
||||
createPlugin: 'Create Plugin',
|
||||
editPlugin: 'Edit Plugin',
|
||||
installed: 'Installed',
|
||||
@@ -217,9 +199,7 @@ const enUS = {
|
||||
saveConfig: 'Save Config',
|
||||
saving: 'Saving...',
|
||||
confirmDeletePlugin:
|
||||
'Are you sure you want to delete the plugin ({{author}}/{{name}})?',
|
||||
deleteDataCheckbox:
|
||||
'Also delete plugin configuration and persistence storage',
|
||||
'Are you sure you want to delete the plugin ({{author}}/{{name}})? This will also delete the plugin configuration.',
|
||||
confirmDelete: 'Confirm Delete',
|
||||
deleteError: 'Delete failed: ',
|
||||
close: 'Close',
|
||||
@@ -260,34 +240,6 @@ const enUS = {
|
||||
saveConfigSuccessDebugPlugin:
|
||||
'Configuration saved successfully, please manually restart the plugin',
|
||||
saveConfigError: 'Configuration save failed: ',
|
||||
fileUpload: {
|
||||
tooLarge: 'File size exceeds 10MB limit',
|
||||
success: 'File uploaded successfully',
|
||||
failed: 'File upload failed',
|
||||
uploading: 'Uploading...',
|
||||
chooseFile: 'Choose File',
|
||||
addFile: 'Add File',
|
||||
},
|
||||
installFromGithub: 'From GitHub',
|
||||
enterRepoUrl: 'Enter GitHub repository URL',
|
||||
repoUrlPlaceholder: 'e.g., https://github.com/owner/repo',
|
||||
fetchingReleases: 'Fetching releases...',
|
||||
selectRelease: 'Select Release',
|
||||
noReleasesFound: 'No releases found',
|
||||
fetchReleasesError: 'Failed to fetch releases: ',
|
||||
selectAsset: 'Select file to install',
|
||||
noAssetsFound: 'No .lbpkg files available in this release',
|
||||
fetchAssetsError: 'Failed to fetch assets: ',
|
||||
backToReleases: 'Back to releases',
|
||||
backToRepoUrl: 'Back to repository URL',
|
||||
backToAssets: 'Back to assets',
|
||||
releaseTag: 'Tag: {{tag}}',
|
||||
releaseName: 'Name: {{name}}',
|
||||
publishedAt: 'Published at: {{date}}',
|
||||
prerelease: 'Pre-release',
|
||||
assetSize: 'Size: {{size}}',
|
||||
confirmInstall: 'Confirm Install',
|
||||
installFromGithubDesc: 'Install plugin from GitHub Release',
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
@@ -332,84 +284,6 @@ const enUS = {
|
||||
markAsReadSuccess: 'Marked as read',
|
||||
markAsReadFailed: 'Mark as read failed',
|
||||
},
|
||||
mcp: {
|
||||
title: 'MCP',
|
||||
createServer: 'Add MCP Server',
|
||||
editServer: 'Edit MCP Server',
|
||||
deleteServer: 'Delete MCP Server',
|
||||
confirmDeleteServer: 'Are you sure you want to delete this MCP server?',
|
||||
confirmDeleteTitle: 'Delete MCP Server',
|
||||
getServerListError: 'Failed to get MCP server list: ',
|
||||
serverName: 'Server Name',
|
||||
serverMode: 'Connection Mode',
|
||||
stdio: 'Stdio Mode',
|
||||
sse: 'SSE Mode',
|
||||
noServerInstalled: 'No MCP servers configured',
|
||||
serverNameRequired: 'Server name cannot be empty',
|
||||
commandRequired: 'Command cannot be empty',
|
||||
urlRequired: 'URL cannot be empty',
|
||||
timeoutMustBePositive: 'Timeout must be a positive number',
|
||||
command: 'Command',
|
||||
args: 'Arguments',
|
||||
env: 'Environment Variables',
|
||||
url: 'URL',
|
||||
headers: 'Headers',
|
||||
timeout: 'Timeout',
|
||||
addArgument: 'Add Argument',
|
||||
addEnvVar: 'Add Environment Variable',
|
||||
addHeader: 'Add Header',
|
||||
keyName: 'Key Name',
|
||||
value: 'Value',
|
||||
testing: 'Testing...',
|
||||
connecting: 'Connecting...',
|
||||
testSuccess: 'Test successful',
|
||||
testFailed: 'Test failed: ',
|
||||
testError: 'Test error',
|
||||
refreshSuccess: 'Refresh successful',
|
||||
refreshFailed: 'Refresh failed: ',
|
||||
connectionSuccess: 'Connection successful',
|
||||
connectionFailed: 'Connection failed, please check URL',
|
||||
connectionFailedStatus: 'Connection Failed',
|
||||
toolsFound: 'tools',
|
||||
unknownError: 'Unknown error',
|
||||
noToolsFound: 'No tools found',
|
||||
parseResultFailed: 'Failed to parse test result',
|
||||
noResultReturned: 'Test returned no result',
|
||||
getTaskFailed: 'Failed to get task status',
|
||||
noTaskId: 'No task ID obtained',
|
||||
deleteSuccess: 'Deleted successfully',
|
||||
deleteFailed: 'Delete failed: ',
|
||||
deleteError: 'Delete failed: ',
|
||||
saveSuccess: 'Saved successfully',
|
||||
saveError: 'Save failed: ',
|
||||
createSuccess: 'Created successfully',
|
||||
createFailed: 'Creation failed: ',
|
||||
createError: 'Creation failed: ',
|
||||
loadFailed: 'Load failed',
|
||||
modifyFailed: 'Modify failed: ',
|
||||
toolCount: 'Tools: {{count}}',
|
||||
statusConnected: 'Connected',
|
||||
statusDisconnected: 'Disconnected',
|
||||
statusError: 'Connection Error',
|
||||
statusDisabled: 'Disabled',
|
||||
loading: 'Loading...',
|
||||
starCount: 'Stars: {{count}}',
|
||||
install: 'Install',
|
||||
installFromGithub: 'Install MCP Server from GitHub',
|
||||
add: 'Add',
|
||||
name: 'Name',
|
||||
nameRequired: 'Name cannot be empty',
|
||||
sseTimeout: 'SSE Timeout',
|
||||
sseTimeoutDescription: 'Timeout for establishing SSE connection',
|
||||
extraParametersDescription:
|
||||
'Additional parameters for configuring specific MCP server behavior',
|
||||
timeoutMustBeNumber: 'Timeout must be a number',
|
||||
timeoutNonNegative: 'Timeout cannot be negative',
|
||||
sseTimeoutMustBeNumber: 'SSE timeout must be a number',
|
||||
sseTimeoutNonNegative: 'SSE timeout cannot be negative',
|
||||
updateSuccess: 'Updated successfully',
|
||||
updateFailed: 'Update failed: ',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'Pipelines',
|
||||
description:
|
||||
@@ -445,23 +319,6 @@ const enUS = {
|
||||
defaultPipelineCannotDelete: 'Default pipeline cannot be deleted',
|
||||
deleteSuccess: 'Deleted successfully',
|
||||
deleteError: 'Delete failed: ',
|
||||
extensions: {
|
||||
title: 'Plugins',
|
||||
loadError: 'Failed to load plugins',
|
||||
saveSuccess: 'Saved successfully',
|
||||
saveError: 'Save failed',
|
||||
noPluginsAvailable: 'No plugins available',
|
||||
disabled: 'Disabled',
|
||||
noPluginsSelected: 'No plugins selected',
|
||||
addPlugin: 'Add Plugin',
|
||||
selectPlugins: 'Select Plugins',
|
||||
pluginsTitle: 'Plugins',
|
||||
mcpServersTitle: 'MCP Servers',
|
||||
noMCPServersSelected: 'No MCP servers selected',
|
||||
addMCPServer: 'Add MCP Server',
|
||||
selectMCPServers: 'Select MCP Servers',
|
||||
toolCount: '{{count}} tools',
|
||||
},
|
||||
debugDialog: {
|
||||
title: 'Pipeline Chat',
|
||||
selectPipeline: 'Select Pipeline',
|
||||
@@ -526,8 +383,6 @@ const enUS = {
|
||||
uploadSuccess: 'File uploaded successfully!',
|
||||
uploadError: 'File upload failed, please try again',
|
||||
uploadingFile: 'Uploading file...',
|
||||
fileSizeExceeded:
|
||||
'File size exceeds 10MB limit. Please split into smaller files.',
|
||||
actions: 'Actions',
|
||||
delete: 'Delete File',
|
||||
fileDeleteSuccess: 'File deleted successfully',
|
||||
|
||||
@@ -59,23 +59,6 @@ const jaJP = {
|
||||
changePasswordSuccess: 'パスワードの変更に成功しました',
|
||||
changePasswordFailed:
|
||||
'パスワードの変更に失敗しました。現在のパスワードを確認してください',
|
||||
apiKeys: 'API キー',
|
||||
manageApiKeys: 'API キーの管理',
|
||||
createApiKey: 'API キーを作成',
|
||||
apiKeyName: 'API キー名',
|
||||
apiKeyDescription: 'API キーの説明',
|
||||
apiKeyValue: 'API キー値',
|
||||
apiKeyCreated: 'API キーの作成に成功しました',
|
||||
apiKeyDeleted: 'API キーの削除に成功しました',
|
||||
apiKeyDeleteConfirm: 'この API キーを削除してもよろしいですか?',
|
||||
apiKeyNameRequired: 'API キー名は必須です',
|
||||
copyApiKey: 'API キーをコピー',
|
||||
apiKeyCopied: 'API キーをクリップボードにコピーしました',
|
||||
noApiKeys: 'API キーが設定されていません',
|
||||
apiKeyHint:
|
||||
'API キーを使用すると、外部システムが LangBot Service API にアクセスできます',
|
||||
actions: 'アクション',
|
||||
apiKeyCreatedMessage: 'この API キーをコピーしてください。',
|
||||
},
|
||||
notFound: {
|
||||
title: 'ページが見つかりません',
|
||||
@@ -158,7 +141,6 @@ const jaJP = {
|
||||
adapterConfig: 'アダプター設定',
|
||||
bindPipeline: 'パイプラインを紐付け',
|
||||
selectPipeline: 'パイプラインを選択',
|
||||
selectBot: 'ボットを選択してください',
|
||||
botLogTitle: 'ボットログ',
|
||||
enableAutoRefresh: '自動更新を有効にする',
|
||||
session: 'セッション',
|
||||
@@ -171,9 +153,8 @@ const jaJP = {
|
||||
logs: 'ログ',
|
||||
},
|
||||
plugins: {
|
||||
title: '拡張機能',
|
||||
description:
|
||||
'LangBotの機能を拡張するプラグインをインストール・設定。流水線設定で使用します',
|
||||
title: 'プラグイン',
|
||||
description: 'LangBotの機能を拡張するプラグインをインストール・設定',
|
||||
createPlugin: 'プラグインを作成',
|
||||
editPlugin: 'プラグインを編集',
|
||||
installed: 'インストール済み',
|
||||
@@ -219,8 +200,7 @@ const jaJP = {
|
||||
saveConfig: '設定を保存',
|
||||
saving: '保存中...',
|
||||
confirmDeletePlugin:
|
||||
'プラグイン「{{author}}/{{name}}」を削除してもよろしいですか?',
|
||||
deleteDataCheckbox: 'プラグイン設定と永続化ストレージも削除する',
|
||||
'プラグイン「{{author}}/{{name}}」を削除してもよろしいですか?この操作により、プラグインの設定も削除されます。',
|
||||
confirmDelete: '削除を確認',
|
||||
deleteError: '削除に失敗しました:',
|
||||
close: '閉じる',
|
||||
@@ -261,34 +241,6 @@ const jaJP = {
|
||||
saveConfigSuccessDebugPlugin:
|
||||
'設定を保存しました。手動でプラグインを再起動してください',
|
||||
saveConfigError: '設定の保存に失敗しました:',
|
||||
fileUpload: {
|
||||
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
|
||||
success: 'ファイルのアップロードに成功しました',
|
||||
failed: 'ファイルのアップロードに失敗しました',
|
||||
uploading: 'アップロード中...',
|
||||
chooseFile: 'ファイルを選択',
|
||||
addFile: 'ファイルを追加',
|
||||
},
|
||||
installFromGithub: 'GitHubから',
|
||||
enterRepoUrl: 'GitHubリポジトリのURLを入力してください',
|
||||
repoUrlPlaceholder: '例: https://github.com/owner/repo',
|
||||
fetchingReleases: 'リリース一覧を取得中...',
|
||||
selectRelease: 'リリースを選択',
|
||||
noReleasesFound: 'リリースが見つかりません',
|
||||
fetchReleasesError: 'リリース一覧の取得に失敗しました:',
|
||||
selectAsset: 'インストールするファイルを選択',
|
||||
noAssetsFound: 'このリリースには利用可能な .lbpkg ファイルがありません',
|
||||
fetchAssetsError: 'ファイル一覧の取得に失敗しました:',
|
||||
backToReleases: 'リリース一覧に戻る',
|
||||
backToRepoUrl: 'リポジトリURLに戻る',
|
||||
backToAssets: 'ファイル選択に戻る',
|
||||
releaseTag: 'タグ: {{tag}}',
|
||||
releaseName: '名前: {{name}}',
|
||||
publishedAt: '公開日: {{date}}',
|
||||
prerelease: 'プレリリース',
|
||||
assetSize: 'サイズ: {{size}}',
|
||||
confirmInstall: 'インストールを確認',
|
||||
installFromGithubDesc: 'GitHubリリースからプラグインをインストール',
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'プラグインを検索...',
|
||||
@@ -334,84 +286,6 @@ const jaJP = {
|
||||
markAsReadSuccess: '既読に設定しました',
|
||||
markAsReadFailed: '既読に設定に失敗しました',
|
||||
},
|
||||
mcp: {
|
||||
title: 'MCP',
|
||||
createServer: 'MCPサーバーを追加',
|
||||
editServer: 'MCPサーバーを編集',
|
||||
deleteServer: 'MCPサーバーを削除',
|
||||
confirmDeleteServer: 'このMCPサーバーを削除してもよろしいですか?',
|
||||
confirmDeleteTitle: 'MCPサーバーを削除',
|
||||
getServerListError: 'MCPサーバーリストの取得に失敗しました:',
|
||||
serverName: 'サーバー名',
|
||||
serverMode: '接続モード',
|
||||
stdio: 'Stdioモード',
|
||||
sse: 'SSEモード',
|
||||
noServerInstalled: 'MCPサーバーが設定されていません',
|
||||
serverNameRequired: 'サーバー名は必須です',
|
||||
commandRequired: 'コマンドは必須です',
|
||||
urlRequired: 'URLは必須です',
|
||||
timeoutMustBePositive: 'タイムアウトは正の数でなければなりません',
|
||||
command: 'コマンド',
|
||||
args: '引数',
|
||||
env: '環境変数',
|
||||
url: 'URL',
|
||||
headers: 'ヘッダー',
|
||||
timeout: 'タイムアウト',
|
||||
addArgument: '引数を追加',
|
||||
addEnvVar: '環境変数を追加',
|
||||
addHeader: 'ヘッダーを追加',
|
||||
keyName: 'キー名',
|
||||
value: '値',
|
||||
testing: 'テスト中...',
|
||||
connecting: '接続中...',
|
||||
testSuccess: '刷新に成功しました',
|
||||
testFailed: '刷新に失敗しました:',
|
||||
testError: '刷新エラー',
|
||||
refreshSuccess: '刷新に成功しました',
|
||||
refreshFailed: '刷新に失敗しました:',
|
||||
connectionSuccess: '接続に成功しました',
|
||||
connectionFailed: '接続に失敗しました,URLを確認してください',
|
||||
connectionFailedStatus: '接続失敗',
|
||||
toolsFound: '個のツール',
|
||||
unknownError: '不明なエラー',
|
||||
noToolsFound: 'ツールが見つかりません',
|
||||
parseResultFailed: 'テスト結果の解析に失敗しました',
|
||||
noResultReturned: 'テスト結果が返されませんでした',
|
||||
getTaskFailed: 'タスクステータスの取得に失敗しました',
|
||||
noTaskId: 'タスクIDを取得できませんでした',
|
||||
deleteSuccess: '削除に成功しました',
|
||||
deleteFailed: '削除に失敗しました:',
|
||||
deleteError: '削除に失敗しました:',
|
||||
saveSuccess: '保存に成功しました',
|
||||
saveError: '保存に失敗しました:',
|
||||
createSuccess: '作成に成功しました',
|
||||
createFailed: '作成に失敗しました:',
|
||||
createError: '作成に失敗しました:',
|
||||
loadFailed: '読み込みに失敗しました',
|
||||
modifyFailed: '変更に失敗しました:',
|
||||
toolCount: 'ツール:{{count}}',
|
||||
statusConnected: '接続済み',
|
||||
statusDisconnected: '未接続',
|
||||
statusError: '接続エラー',
|
||||
statusDisabled: '無効',
|
||||
loading: '読み込み中...',
|
||||
starCount: 'スター:{{count}}',
|
||||
install: 'インストール',
|
||||
installFromGithub: 'GitHubからMCPサーバーをインストール',
|
||||
add: '追加',
|
||||
name: '名前',
|
||||
nameRequired: '名前は必須です',
|
||||
sseTimeout: 'SSEタイムアウト',
|
||||
sseTimeoutDescription: 'SSE接続を確立するためのタイムアウト',
|
||||
extraParametersDescription:
|
||||
'MCPサーバーの特定の動作を設定するための追加パラメータ',
|
||||
timeoutMustBeNumber: 'タイムアウトは数値である必要があります',
|
||||
timeoutNonNegative: 'タイムアウトは負の数にできません',
|
||||
sseTimeoutMustBeNumber: 'SSEタイムアウトは数値である必要があります',
|
||||
sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません',
|
||||
updateSuccess: '更新に成功しました',
|
||||
updateFailed: '更新に失敗しました:',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'パイプライン',
|
||||
description:
|
||||
@@ -448,23 +322,6 @@ const jaJP = {
|
||||
defaultPipelineCannotDelete: 'デフォルトパイプラインは削除できません',
|
||||
deleteSuccess: '削除に成功しました',
|
||||
deleteError: '削除に失敗しました:',
|
||||
extensions: {
|
||||
title: 'プラグイン統合',
|
||||
loadError: 'プラグインリストの読み込みに失敗しました',
|
||||
saveSuccess: '保存に成功しました',
|
||||
saveError: '保存に失敗しました',
|
||||
noPluginsAvailable: '利用可能なプラグインがありません',
|
||||
disabled: '無効',
|
||||
noPluginsSelected: 'プラグインが選択されていません',
|
||||
addPlugin: 'プラグインを追加',
|
||||
selectPlugins: 'プラグインを選択',
|
||||
pluginsTitle: 'プラグイン',
|
||||
mcpServersTitle: 'MCPサーバー',
|
||||
noMCPServersSelected: 'MCPサーバーが選択されていません',
|
||||
addMCPServer: 'MCPサーバーを追加',
|
||||
selectMCPServers: 'MCPサーバーを選択',
|
||||
toolCount: '{{count}}個のツール',
|
||||
},
|
||||
debugDialog: {
|
||||
title: 'パイプラインのチャット',
|
||||
selectPipeline: 'パイプラインを選択',
|
||||
@@ -530,8 +387,6 @@ const jaJP = {
|
||||
uploadSuccess: 'ファイルのアップロードに成功しました!',
|
||||
uploadError: 'ファイルのアップロードに失敗しました。再度お試しください',
|
||||
uploadingFile: 'ファイルをアップロード中...',
|
||||
fileSizeExceeded:
|
||||
'ファイルサイズが10MBの制限を超えています。より小さいファイルに分割してください。',
|
||||
actions: 'アクション',
|
||||
delete: 'ドキュメントを削除',
|
||||
fileDeleteSuccess: 'ドキュメントの削除に成功しました',
|
||||
|
||||
@@ -57,22 +57,6 @@ const zhHans = {
|
||||
passwordsDoNotMatch: '两次输入的密码不一致',
|
||||
changePasswordSuccess: '密码修改成功',
|
||||
changePasswordFailed: '密码修改失败,请检查当前密码是否正确',
|
||||
apiKeys: 'API 密钥',
|
||||
manageApiKeys: '管理 API 密钥',
|
||||
createApiKey: '创建 API 密钥',
|
||||
apiKeyName: 'API 密钥名称',
|
||||
apiKeyDescription: 'API 密钥描述',
|
||||
apiKeyValue: 'API 密钥值',
|
||||
apiKeyCreated: 'API 密钥创建成功',
|
||||
apiKeyDeleted: 'API 密钥删除成功',
|
||||
apiKeyDeleteConfirm: '确定要删除此 API 密钥吗?',
|
||||
apiKeyNameRequired: 'API 密钥名称不能为空',
|
||||
copyApiKey: '复制 API 密钥',
|
||||
apiKeyCopied: 'API 密钥已复制到剪贴板',
|
||||
noApiKeys: '暂无 API 密钥',
|
||||
apiKeyHint: 'API 密钥允许外部系统访问 LangBot 的 Service API',
|
||||
actions: '操作',
|
||||
apiKeyCreatedMessage: '请复制此 API 密钥。',
|
||||
},
|
||||
notFound: {
|
||||
title: '页面不存在',
|
||||
@@ -152,7 +136,6 @@ const zhHans = {
|
||||
adapterConfig: '适配器配置',
|
||||
bindPipeline: '绑定流水线',
|
||||
selectPipeline: '选择流水线',
|
||||
selectBot: '请选择机器人',
|
||||
botLogTitle: '机器人日志',
|
||||
enableAutoRefresh: '开启自动刷新',
|
||||
session: '会话',
|
||||
@@ -165,8 +148,8 @@ const zhHans = {
|
||||
logs: '日志',
|
||||
},
|
||||
plugins: {
|
||||
title: '插件扩展',
|
||||
description: '安装和配置用于扩展功能的插件,请在流水线配置中选用',
|
||||
title: '插件管理',
|
||||
description: '安装和配置用于扩展 LangBot 功能的插件',
|
||||
createPlugin: '创建插件',
|
||||
editPlugin: '编辑插件',
|
||||
installed: '已安装',
|
||||
@@ -208,8 +191,8 @@ const zhHans = {
|
||||
cancel: '取消',
|
||||
saveConfig: '保存配置',
|
||||
saving: '保存中...',
|
||||
confirmDeletePlugin: '你确定要删除插件({{author}}/{{name}})吗?',
|
||||
deleteDataCheckbox: '同时删除插件配置和持久化存储',
|
||||
confirmDeletePlugin:
|
||||
'你确定要删除插件({{author}}/{{name}})吗?这将同时删除插件的配置。',
|
||||
confirmDelete: '确认删除',
|
||||
deleteError: '删除失败:',
|
||||
close: '关闭',
|
||||
@@ -247,34 +230,6 @@ const zhHans = {
|
||||
saveConfigSuccessNormal: '保存配置成功',
|
||||
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
|
||||
saveConfigError: '保存配置失败:',
|
||||
fileUpload: {
|
||||
tooLarge: '文件大小超过 10MB 限制',
|
||||
success: '文件上传成功',
|
||||
failed: '文件上传失败',
|
||||
uploading: '上传中...',
|
||||
chooseFile: '选择文件',
|
||||
addFile: '添加文件',
|
||||
},
|
||||
installFromGithub: '来自 GitHub',
|
||||
enterRepoUrl: '请输入 GitHub 仓库地址',
|
||||
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
|
||||
fetchingReleases: '正在获取 Release 列表...',
|
||||
selectRelease: '选择 Release',
|
||||
noReleasesFound: '未找到 Release',
|
||||
fetchReleasesError: '获取 Release 列表失败:',
|
||||
selectAsset: '选择要安装的文件',
|
||||
noAssetsFound: '该 Release 没有可用的 .lbpkg 文件',
|
||||
fetchAssetsError: '获取文件列表失败:',
|
||||
backToReleases: '返回 Release 列表',
|
||||
backToRepoUrl: '返回仓库地址',
|
||||
backToAssets: '返回文件选择',
|
||||
releaseTag: 'Tag: {{tag}}',
|
||||
releaseName: '名称: {{name}}',
|
||||
publishedAt: '发布于: {{date}}',
|
||||
prerelease: '预发布',
|
||||
assetSize: '大小: {{size}}',
|
||||
confirmInstall: '确认安装',
|
||||
installFromGithubDesc: '从 GitHub Release 安装插件',
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: '搜索插件...',
|
||||
@@ -317,83 +272,6 @@ const zhHans = {
|
||||
markAsReadSuccess: '已标记为已读',
|
||||
markAsReadFailed: '标记为已读失败',
|
||||
},
|
||||
mcp: {
|
||||
title: 'MCP',
|
||||
createServer: '添加 MCP 服务器',
|
||||
editServer: '修改 MCP 服务器',
|
||||
deleteServer: '删除 MCP 服务器',
|
||||
confirmDeleteServer: '你确定要删除此 MCP 服务器吗?',
|
||||
confirmDeleteTitle: '删除 MCP 服务器',
|
||||
getServerListError: '获取 MCP 服务器列表失败:',
|
||||
serverName: '服务器名称',
|
||||
serverMode: '连接模式',
|
||||
stdio: 'Stdio模式',
|
||||
sse: 'SSE模式',
|
||||
noServerInstalled: '暂未配置任何 MCP 服务器',
|
||||
serverNameRequired: '服务器名称不能为空',
|
||||
commandRequired: '命令不能为空',
|
||||
urlRequired: 'URL 不能为空',
|
||||
timeoutMustBePositive: '超时时间必须是正数',
|
||||
command: '命令',
|
||||
args: '参数',
|
||||
env: '环境变量',
|
||||
url: 'URL地址',
|
||||
headers: '请求头',
|
||||
timeout: '超时时间',
|
||||
addArgument: '添加参数',
|
||||
addEnvVar: '添加环境变量',
|
||||
addHeader: '添加请求头',
|
||||
keyName: '键名',
|
||||
value: '值',
|
||||
testing: '测试中...',
|
||||
connecting: '连接中...',
|
||||
testSuccess: '测试成功',
|
||||
testFailed: '测试失败:',
|
||||
testError: '刷新出错',
|
||||
refreshSuccess: '刷新成功',
|
||||
refreshFailed: '刷新失败:',
|
||||
connectionSuccess: '连接成功',
|
||||
connectionFailed: '连接失败,请检查URL',
|
||||
connectionFailedStatus: '连接失败',
|
||||
toolsFound: '个工具',
|
||||
unknownError: '未知错误',
|
||||
noToolsFound: '未找到任何工具',
|
||||
parseResultFailed: '解析测试结果失败',
|
||||
noResultReturned: '测试未返回结果',
|
||||
getTaskFailed: '获取任务状态失败',
|
||||
noTaskId: '未获取到任务ID',
|
||||
deleteSuccess: '删除成功',
|
||||
deleteFailed: '删除失败:',
|
||||
deleteError: '删除失败:',
|
||||
saveSuccess: '保存成功',
|
||||
saveError: '保存失败:',
|
||||
createSuccess: '创建成功',
|
||||
createFailed: '创建失败:',
|
||||
createError: '创建失败:',
|
||||
loadFailed: '加载失败',
|
||||
modifyFailed: '修改失败:',
|
||||
toolCount: '工具:{{count}}',
|
||||
statusConnected: '已打开',
|
||||
statusDisconnected: '未打开',
|
||||
statusError: '连接错误',
|
||||
statusDisabled: '已禁用',
|
||||
loading: '加载中...',
|
||||
starCount: '星标:{{count}}',
|
||||
install: '安装',
|
||||
installFromGithub: '从Github安装MCP服务器',
|
||||
add: '添加',
|
||||
name: '名称',
|
||||
nameRequired: '名称不能为空',
|
||||
sseTimeout: 'SSE超时时间',
|
||||
sseTimeoutDescription: '用于建立SSE连接的超时时间',
|
||||
extraParametersDescription: '额外参数,用于配置MCP服务器的特定行为',
|
||||
timeoutMustBeNumber: '超时时间必须是数字',
|
||||
timeoutNonNegative: '超时时间不能为负数',
|
||||
sseTimeoutMustBeNumber: 'SSE超时时间必须是数字',
|
||||
sseTimeoutNonNegative: 'SSE超时时间不能为负数',
|
||||
updateSuccess: '更新成功',
|
||||
updateFailed: '更新失败:',
|
||||
},
|
||||
pipelines: {
|
||||
title: '流水线',
|
||||
description: '流水线定义了对消息事件的处理流程,用于绑定到机器人',
|
||||
@@ -428,23 +306,6 @@ const zhHans = {
|
||||
defaultPipelineCannotDelete: '默认流水线不可删除',
|
||||
deleteSuccess: '删除成功',
|
||||
deleteError: '删除失败:',
|
||||
extensions: {
|
||||
title: '扩展集成',
|
||||
loadError: '加载插件列表失败',
|
||||
saveSuccess: '保存成功',
|
||||
saveError: '保存失败',
|
||||
noPluginsAvailable: '暂无可用插件',
|
||||
disabled: '已禁用',
|
||||
noPluginsSelected: '未选择任何插件',
|
||||
addPlugin: '添加插件',
|
||||
selectPlugins: '选择插件',
|
||||
pluginsTitle: '插件',
|
||||
mcpServersTitle: 'MCP 服务器',
|
||||
noMCPServersSelected: '未选择任何 MCP 服务器',
|
||||
addMCPServer: '添加 MCP 服务器',
|
||||
selectMCPServers: '选择 MCP 服务器',
|
||||
toolCount: '{{count}} 个工具',
|
||||
},
|
||||
debugDialog: {
|
||||
title: '流水线对话',
|
||||
selectPipeline: '选择流水线',
|
||||
@@ -505,7 +366,6 @@ const zhHans = {
|
||||
uploadSuccess: '文件上传成功!',
|
||||
uploadError: '文件上传失败,请重试',
|
||||
uploadingFile: '上传文件中...',
|
||||
fileSizeExceeded: '文件大小超过 10MB 限制,请分割成较小的文件后上传',
|
||||
actions: '操作',
|
||||
delete: '删除文件',
|
||||
fileDeleteSuccess: '文件删除成功',
|
||||
|
||||
@@ -57,22 +57,6 @@ const zhHant = {
|
||||
passwordsDoNotMatch: '兩次輸入的密碼不一致',
|
||||
changePasswordSuccess: '密碼修改成功',
|
||||
changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確',
|
||||
apiKeys: 'API 金鑰',
|
||||
manageApiKeys: '管理 API 金鑰',
|
||||
createApiKey: '建立 API 金鑰',
|
||||
apiKeyName: 'API 金鑰名稱',
|
||||
apiKeyDescription: 'API 金鑰描述',
|
||||
apiKeyValue: 'API 金鑰值',
|
||||
apiKeyCreated: 'API 金鑰建立成功',
|
||||
apiKeyDeleted: 'API 金鑰刪除成功',
|
||||
apiKeyDeleteConfirm: '確定要刪除此 API 金鑰嗎?',
|
||||
apiKeyNameRequired: 'API 金鑰名稱不能為空',
|
||||
copyApiKey: '複製 API 金鑰',
|
||||
apiKeyCopied: 'API 金鑰已複製到剪貼簿',
|
||||
noApiKeys: '暫無 API 金鑰',
|
||||
apiKeyHint: 'API 金鑰允許外部系統訪問 LangBot 的 Service API',
|
||||
actions: '操作',
|
||||
apiKeyCreatedMessage: '請複製此 API 金鑰。',
|
||||
},
|
||||
notFound: {
|
||||
title: '頁面不存在',
|
||||
@@ -152,7 +136,6 @@ const zhHant = {
|
||||
adapterConfig: '適配器設定',
|
||||
bindPipeline: '綁定流程線',
|
||||
selectPipeline: '選擇流程線',
|
||||
selectBot: '請選擇機器人',
|
||||
botLogTitle: '機器人日誌',
|
||||
enableAutoRefresh: '開啟自動重新整理',
|
||||
session: '對話',
|
||||
@@ -165,15 +148,15 @@ const zhHant = {
|
||||
logs: '日誌',
|
||||
},
|
||||
plugins: {
|
||||
title: '外掛擴展',
|
||||
description: '安裝和設定用於擴展功能的外掛,請在流程線配置中選用',
|
||||
title: '外掛管理',
|
||||
description: '安裝和設定用於擴展 LangBot 功能的外掛',
|
||||
createPlugin: '建立外掛',
|
||||
editPlugin: '編輯外掛',
|
||||
installed: '已安裝',
|
||||
marketplace: 'Marketplace',
|
||||
arrange: '編排',
|
||||
install: '安裝',
|
||||
installFromGithub: '來自 GitHub',
|
||||
installFromGithub: '從 GitHub 安裝外掛',
|
||||
onlySupportGithub: '目前僅支援從 GitHub 安裝',
|
||||
enterGithubLink: '請輸入外掛的Github連結',
|
||||
installing: '正在安裝外掛...',
|
||||
@@ -209,7 +192,6 @@ const zhHant = {
|
||||
saveConfig: '儲存設定',
|
||||
saving: '儲存中...',
|
||||
confirmDeletePlugin: '您確定要刪除外掛({{author}}/{{name}})嗎?',
|
||||
deleteDataCheckbox: '同時刪除外掛設定和持久化儲存',
|
||||
confirmDelete: '確認刪除',
|
||||
deleteError: '刪除失敗:',
|
||||
close: '關閉',
|
||||
@@ -246,33 +228,6 @@ const zhHant = {
|
||||
saveConfigSuccessNormal: '儲存配置成功',
|
||||
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
|
||||
saveConfigError: '儲存配置失敗:',
|
||||
fileUpload: {
|
||||
tooLarge: '檔案大小超過 10MB 限制',
|
||||
success: '檔案上傳成功',
|
||||
failed: '檔案上傳失敗',
|
||||
uploading: '上傳中...',
|
||||
chooseFile: '選擇檔案',
|
||||
addFile: '新增檔案',
|
||||
},
|
||||
enterRepoUrl: '請輸入 GitHub 倉庫地址',
|
||||
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
|
||||
fetchingReleases: '正在獲取 Release 列表...',
|
||||
selectRelease: '選擇 Release',
|
||||
noReleasesFound: '未找到 Release',
|
||||
fetchReleasesError: '獲取 Release 列表失敗:',
|
||||
selectAsset: '選擇要安裝的文件',
|
||||
noAssetsFound: '該 Release 沒有可用的 .lbpkg 文件',
|
||||
fetchAssetsError: '獲取文件列表失敗:',
|
||||
backToReleases: '返回 Release 列表',
|
||||
backToRepoUrl: '返回倉庫地址',
|
||||
backToAssets: '返回文件選擇',
|
||||
releaseTag: 'Tag: {{tag}}',
|
||||
releaseName: '名稱: {{name}}',
|
||||
publishedAt: '發佈於: {{date}}',
|
||||
prerelease: '預發佈',
|
||||
assetSize: '大小: {{size}}',
|
||||
confirmInstall: '確認安裝',
|
||||
installFromGithubDesc: '從 GitHub Release 安裝插件',
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: '搜尋插件...',
|
||||
@@ -315,83 +270,6 @@ const zhHant = {
|
||||
markAsReadSuccess: '已標記為已讀',
|
||||
markAsReadFailed: '標記為已讀失敗',
|
||||
},
|
||||
mcp: {
|
||||
title: 'MCP',
|
||||
createServer: '新增MCP伺服器',
|
||||
editServer: '編輯MCP伺服器',
|
||||
deleteServer: '刪除MCP伺服器',
|
||||
confirmDeleteServer: '您確定要刪除此MCP伺服器嗎?',
|
||||
confirmDeleteTitle: '刪除MCP伺服器',
|
||||
getServerListError: '取得MCP伺服器清單失敗:',
|
||||
serverName: '伺服器名稱',
|
||||
serverMode: '連接模式',
|
||||
stdio: 'Stdio模式',
|
||||
sse: 'SSE模式',
|
||||
noServerInstalled: '暫未設定任何MCP伺服器',
|
||||
serverNameRequired: '伺服器名稱不能為空',
|
||||
commandRequired: '命令不能為空',
|
||||
urlRequired: 'URL不能為空',
|
||||
timeoutMustBePositive: '逾時時間必須是正數',
|
||||
command: '命令',
|
||||
args: '參數',
|
||||
env: '環境變數',
|
||||
url: 'URL位址',
|
||||
headers: '請求標頭',
|
||||
timeout: '逾時時間',
|
||||
addArgument: '新增參數',
|
||||
addEnvVar: '新增環境變數',
|
||||
addHeader: '新增請求標頭',
|
||||
keyName: '鍵名',
|
||||
value: '值',
|
||||
testing: '測試中...',
|
||||
connecting: '連接中...',
|
||||
testSuccess: '測試成功',
|
||||
testFailed: '刷新失敗:',
|
||||
testError: '刷新出錯',
|
||||
refreshSuccess: '刷新成功',
|
||||
refreshFailed: '刷新失敗:',
|
||||
connectionSuccess: '連接成功',
|
||||
connectionFailed: '連接失敗,請檢查URL',
|
||||
connectionFailedStatus: '連接失敗',
|
||||
toolsFound: '個工具',
|
||||
unknownError: '未知錯誤',
|
||||
noToolsFound: '未找到任何工具',
|
||||
parseResultFailed: '解析測試結果失敗',
|
||||
noResultReturned: '測試未返回結果',
|
||||
getTaskFailed: '獲取任務狀態失敗',
|
||||
noTaskId: '未獲取到任務ID',
|
||||
deleteSuccess: '刪除成功',
|
||||
deleteFailed: '刪除失敗:',
|
||||
deleteError: '刪除失敗:',
|
||||
saveSuccess: '儲存成功',
|
||||
saveError: '儲存失敗:',
|
||||
createSuccess: '建立成功',
|
||||
createFailed: '建立失敗:',
|
||||
createError: '建立失敗:',
|
||||
loadFailed: '載入失敗',
|
||||
modifyFailed: '修改失敗:',
|
||||
toolCount: '工具:{{count}}',
|
||||
statusConnected: '已開啟',
|
||||
statusDisconnected: '未開啟',
|
||||
statusError: '連接錯誤',
|
||||
statusDisabled: '已停用',
|
||||
loading: '載入中...',
|
||||
starCount: '星標:{{count}}',
|
||||
install: '安裝',
|
||||
installFromGithub: '從Github安裝MCP伺服器',
|
||||
add: '新增',
|
||||
name: '名稱',
|
||||
nameRequired: '名稱不能為空',
|
||||
sseTimeout: 'SSE逾時時間',
|
||||
sseTimeoutDescription: '用於建立SSE連接的逾時時間',
|
||||
extraParametersDescription: '額外參數,用於設定MCP伺服器的特定行為',
|
||||
timeoutMustBeNumber: '逾時時間必須是數字',
|
||||
timeoutNonNegative: '逾時時間不能為負數',
|
||||
sseTimeoutMustBeNumber: 'SSE逾時時間必須是數字',
|
||||
sseTimeoutNonNegative: 'SSE逾時時間不能為負數',
|
||||
updateSuccess: '更新成功',
|
||||
updateFailed: '更新失敗:',
|
||||
},
|
||||
pipelines: {
|
||||
title: '流程線',
|
||||
description: '流程線定義了對訊息事件的處理流程,用於綁定到機器人',
|
||||
@@ -426,23 +304,6 @@ const zhHant = {
|
||||
defaultPipelineCannotDelete: '預設流程線不可刪除',
|
||||
deleteSuccess: '刪除成功',
|
||||
deleteError: '刪除失敗:',
|
||||
extensions: {
|
||||
title: '擴展集成',
|
||||
loadError: '載入插件清單失敗',
|
||||
saveSuccess: '儲存成功',
|
||||
saveError: '儲存失敗',
|
||||
noPluginsAvailable: '暫無可用插件',
|
||||
disabled: '已停用',
|
||||
noPluginsSelected: '未選擇任何插件',
|
||||
addPlugin: '新增插件',
|
||||
selectPlugins: '選擇插件',
|
||||
pluginsTitle: '插件',
|
||||
mcpServersTitle: 'MCP 伺服器',
|
||||
noMCPServersSelected: '未選擇任何 MCP 伺服器',
|
||||
addMCPServer: '新增 MCP 伺服器',
|
||||
selectMCPServers: '選擇 MCP 伺服器',
|
||||
toolCount: '{{count}} 個工具',
|
||||
},
|
||||
debugDialog: {
|
||||
title: '流程線對話',
|
||||
selectPipeline: '選擇流程線',
|
||||
@@ -502,7 +363,6 @@ const zhHant = {
|
||||
uploadSuccess: '文檔上傳成功!',
|
||||
uploadError: '文檔上傳失敗,請重試',
|
||||
uploadingFile: '上傳文檔中...',
|
||||
fileSizeExceeded: '檔案大小超過 10MB 限制,請分割成較小的檔案後上傳',
|
||||
actions: '操作',
|
||||
delete: '刪除文檔',
|
||||
fileDeleteSuccess: '文檔刪除成功',
|
||||
|
||||
Reference in New Issue
Block a user