Compare commits

...

26 Commits

Author SHA1 Message Date
Junyan Qin (Chin)
4fe92d8ece Feat/plugin on windows (#1760)
* feat: communicate with runtime via ws

* chore: bump langbot-plugin 0.1.9b2

* chore: comment on shutdown on windows
2025-11-07 17:26:42 +08:00
fdc310
c893ffc177 fix:coze-studio api done return data is none and event done char not … (#1758)
* fix:coze-studio api done return data is none and event done char not is "done"

* Update pkg/provider/runners/cozeapi.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Junyan Qin (Chin) <rockchinq@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-07 15:17:00 +08:00
Copilot
a076ce5756 feat: Add API key authentication system for external service access (#1757)
* Initial plan

* feat: Add API key authentication system backend

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* feat: Add API key management UI in frontend sidebar

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* fix: Correct import paths in API controller groups

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* fix: Address code review feedback - add i18n and validation

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* refactor: Enable API key auth on existing endpoints instead of creating separate service API

- Added USER_TOKEN_OR_API_KEY auth type that accepts both authentication methods
- Removed separate /api/service/v1/models endpoints
- Updated existing endpoints (models, bots, pipelines) to accept API keys
- External services can now use API keys to access all existing LangBot APIs
- Updated documentation to reflect unified API approach

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* docs: Add OpenAPI specification for API key authenticated endpoints

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* chore: rename openapi spec

* perf: ui and i18n

* fix: ui bug

* chore: tidy docs

* chore: fix linter errors

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-07 14:08:11 +08:00
Junyan Qin
af82227dff chore: update README 2025-11-06 21:37:31 +08:00
Junyan Qin
8f2b177145 chore: add guidance for code agents 2025-11-06 21:34:02 +08:00
Copilot
9a997fbcb0 feat: Make API Key optional for custom model providers (#1756)
* Initial plan

* Make API Key optional for custom model providers

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Fix TypeScript type errors in test functions

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* perf: ui

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-06 20:59:34 +08:00
Junyan Qin
17070471f7 feat: delete all bot log images at startup (#1650) 2025-11-06 20:02:07 +08:00
Copilot
cb48221ed3 feat: add MCP server selection to pipeline extensions (#1754)
* Initial plan

* Backend: Add MCP server selection support to pipeline extensions

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Frontend: Add MCP server selection UI to pipeline extensions

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* perf: ui

* perf: ui

* perf: desc for extension page

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-06 19:38:12 +08:00
Copilot
68eb0290e0 Fix: Enforce 10MB upload limit for knowledge base with clear error handling (#1755)
* Initial plan

* Set MAX_CONTENT_LENGTH to 10MB and add file size validation

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add custom error handler for 413 RequestEntityTooLarge

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Refactor: Extract MAX_FILE_SIZE constant to avoid duplication

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Fix file name extraction and add missing file validation

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Apply file size validation to all upload endpoints consistently

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add frontend file size validation for knowledge base and plugin uploads

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Remove file size validation from plugin uploads, keep only for knowledge base

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* perf: ui

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-06 18:50:29 +08:00
Junyan Qin
61bc6a1dc2 feat: add supports for bot-selector config field 2025-11-06 15:36:43 +08:00
Junyan Qin (Chin)
4a84bf2355 Feat/pipeline specified plugins (#1752)
* feat: add persistence field

* feat: add basic extension page in pipeline config

* Merge pull request #1751 from langbot-app/copilot/add-plugin-extension-tab

Implement pipeline-scoped plugin binding system

* fix: i18n keys

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2025-11-06 12:51:33 +08:00
Junyan Qin
2c2a89d9db chore: bump version 4.4.1 2025-11-06 00:09:35 +08:00
Junyan Qin (Chin)
c91e2f0efe feat: add file array[file] and text type plugin config fields (#1750)
* feat: add   and  type plugin config fields

* chore: add hant and jp i18n

* feat: plugin config file auto clean

* chore: bump langbot-plugin to 0.1.8

* chore: fix linter errors
2025-11-06 00:07:57 +08:00
Junyan Qin
411d082d2a chore: fix linter errors 2025-11-06 00:07:43 +08:00
Junyan Qin
d4e08a1765 chore: bump langbot-plugin to 0.1.8 2025-11-06 00:05:03 +08:00
Junyan Qin
b529d07479 feat: plugin config file auto clean 2025-11-06 00:02:25 +08:00
Junyan Qin
d44df75e5c chore: add hant and jp i18n 2025-11-05 23:54:34 +08:00
Junyan Qin
b74e07b608 feat: add and type plugin config fields 2025-11-05 23:48:59 +08:00
Junyan Qin
4a868afecd fix: plugin mgm page mistakely refreshed when open acc option menu 2025-11-05 18:59:40 +08:00
Junyan Qin
1cb9560663 perf: only check connecting mcp server when it's enabled 2025-11-05 18:53:17 +08:00
Junyan Qin
8f878673ae feat: add supports for showing image in plugin readme 2025-11-05 18:42:14 +08:00
Junyan Qin
74a5e37892 perf: plugin market layout 2025-11-05 18:34:40 +08:00
Copilot
76a69ecc7e Add environment variable override support for config.yaml (#1748)
* Initial plan

* Add environment variable override support for config.yaml

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Refactor env override code based on review feedback

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add test for template completion with env overrides

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Move env override logic to load_config.py as requested

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* perf: add print log

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-05 18:15:15 +08:00
Alfons
f06e3d3efa fix: disabling potential thinking param for model testing (#1733)
* fix: 禁用模型默认思考功能以减少测试延迟

- 调整导入语句顺序
- 为没有显式设置 thinking 参数的模型添加禁用配置
- 避免某些模型厂商默认开启思考功能导致的测试延迟

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: 确保 extra_args 为空时也禁用思考功能

修复条件判断逻辑,当 extra_args 为空字典时也会添加思考功能禁用配置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* perf(fe): increase default timeout

* perf: llm model testing prompt

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-05 15:52:17 +08:00
Guanchao Wang
973e7bae42 fix: wecombot id (#1747) 2025-11-05 12:14:01 +08:00
Junyan Qin
94aa175c1a chore: bump langbot-plugin to 0.1.7 2025-11-05 12:11:46 +08:00
75 changed files with 4994 additions and 156 deletions

81
AGENTS.md Normal file
View File

@@ -0,0 +1,81 @@
# 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
- 八荣八耻
以瞎猜接口为耻,以认真查询为荣。
以模糊执行为耻,以寻求确认为荣。
以臆想业务为耻,以人类确认为荣。
以创造接口为耻,以复用现有为荣。
以跳过验证为耻,以主动测试为荣。
以破坏架构为耻,以遵循规范为荣。
以假装理解为耻,以诚实无知为荣。
以盲目修改为耻,以谨慎重构为荣。

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -147,3 +147,9 @@ 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.
-->

291
docs/API_KEY_AUTH.md Normal file
View File

@@ -0,0 +1,291 @@
# 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

View File

@@ -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)}
yield {"event": chunk_type, "data": json.loads(chunk_data) if chunk_data else {}} # 处理本地部署时接口返回的data为空值
except asyncio.TimeoutError:
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)")

View File

@@ -463,7 +463,17 @@ class WecomBotClient:
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
message_data['picurl'] = base64 # 只保留第一个 image
message_data['userid'] = msg_json.get('from', {}).get('userid', '')
# 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', '')
message_data['msgid'] = msg_json.get('msgid', '')
if msg_json.get('aibotid'):

View File

@@ -22,7 +22,21 @@ class WecomBotEvent(dict):
"""
用户id
"""
return self.get('from', {}).get('userid', '')
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)
@property
def content(self) -> str:

View File

@@ -9,6 +9,9 @@ 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"""
@@ -31,6 +34,8 @@ 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):
@@ -84,6 +89,63 @@ 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)

View File

@@ -0,0 +1,43 @@
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()

View File

@@ -31,19 +31,41 @@ 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'
file = (await request.files)['file']
files = await request.files
if 'file' not in files:
return self.fail(400, 'No file provided in request')
file = files['file']
assert isinstance(file, quart.datastructures.FileStorage)
file_bytes = await asyncio.to_thread(file.stream.read)
extension = file.filename.split('.')[-1]
file_name = file.filename.split('.')[0]
# 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 = ''
# 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] + '.' + extension
file_key = file_name + '_' + str(uuid.uuid4())[:8]
if extension:
file_key += '.' + extension
# save file to storage
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
return self.success(

View File

@@ -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'])
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
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'])
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()})
@self.route('/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'])
@self.route('/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(pipeline_uuid: str) -> str:
if quart.request.method == 'GET':
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
@@ -46,3 +46,34 @@ 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()

View File

@@ -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'])
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
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'])
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
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'])
@self.route('/<bot_uuid>/logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
json_data = await quart.request.json
from_index = json_data.get('from_index', -1)

View File

@@ -4,6 +4,8 @@ import base64
import quart
import re
import httpx
import uuid
import os
from .....core import taskmgr
from .. import group
@@ -269,3 +271,39 @@ 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)}')

View File

@@ -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'])
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
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'])
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
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'])
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
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'])
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
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'])
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
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'])
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
json_data = await quart.request.json

View File

@@ -5,6 +5,7 @@ import os
import quart
import quart_cors
from werkzeug.exceptions import RequestEntityTooLarge
from ....core import app, entities as core_entities
from ....utils import importutil
@@ -35,7 +36,20 @@ 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:

View File

@@ -0,0 +1,79 @@
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)
)

View File

@@ -1,13 +1,14 @@
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:
@@ -104,12 +105,17 @@ 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!')],
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
funcs=[],
extra_args=model_data.get('extra_args', {}),
extra_args=extra_args,
)

View File

@@ -136,3 +136,33 @@ 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)

View File

@@ -59,14 +59,15 @@ 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()
command_list = await self.ap.plugin_connector.list_commands(bound_plugins)
for command in command_list:
if command.metadata.name == context.command:
async for ret in self.ap.plugin_connector.execute_command(context):
async for ret in self.ap.plugin_connector.execute_command(context, bound_plugins):
yield ret
break
else:
@@ -102,5 +103,8 @@ class CommandManager:
ctx.shift()
async for ret in self._execute(ctx, self.cmd_list):
# 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):
yield ret

View File

@@ -23,6 +23,7 @@ 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
@@ -122,6 +123,8 @@ class Application:
mcp_service: mcp_service.MCPService = None
apikey_service: apikey_service.ApiKeyService = None
def __init__(self):
pass

View File

@@ -20,6 +20,7 @@ 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
@@ -130,5 +131,8 @@ class BuildAppStage(stage.BootingStage):
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

View File

@@ -1,11 +1,93 @@
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"""
@@ -54,6 +136,10 @@ 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(

View File

@@ -0,0 +1,21 @@
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(),
)

View File

@@ -1,12 +1,13 @@
import sqlalchemy
from .base import Base
from ...utils import constants
initial_metadata = [
{
'key': 'database_version',
'value': '0',
'value': str(constants.required_database_version),
},
]

View File

@@ -22,6 +22,7 @@ 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):

View File

@@ -78,6 +78,8 @@ 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:
@@ -98,6 +100,7 @@ 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
@@ -115,6 +118,7 @@ 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))

View File

@@ -0,0 +1,20 @@
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)

View File

@@ -69,6 +69,12 @@ class RuntimePipeline:
stage_containers: list[StageInstContainer]
"""阶段实例容器"""
bound_plugins: list[str]
"""绑定到此流水线的插件列表格式author/plugin_name"""
bound_mcp_servers: list[str]
"""绑定到此流水线的MCP服务器列表格式uuid"""
def __init__(
self,
ap: app.Application,
@@ -79,8 +85,19 @@ class RuntimePipeline:
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):
@@ -188,6 +205,9 @@ 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
@@ -203,7 +223,7 @@ class RuntimePipeline:
message_chain=query.message_chain,
)
event_ctx = await self.ap.plugin_connector.emit_event(event_obj)
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
if event_ctx.is_prevented_default():
return

View File

@@ -65,7 +65,14 @@ class PreProcessor(stage.PipelineStage):
query.use_llm_model_uuid = llm_model.model_entity.uuid
if llm_model.model_entity.abilities.__contains__('func_call'):
query.use_funcs = await self.ap.tool_mgr.get_all_tools()
# 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}')
variables = {
'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
@@ -130,7 +137,9 @@ class PreProcessor(stage.PipelineStage):
query=query,
)
event_ctx = await self.ap.plugin_connector.emit_event(event)
# 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)
query.prompt.messages = event_ctx.event.default_prompt
query.messages = event_ctx.event.prompt

View File

@@ -43,7 +43,9 @@ class ChatMessageHandler(handler.MessageHandler):
query=query,
)
event_ctx = await self.ap.plugin_connector.emit_event(event)
# 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)
is_create_card = False # 判断下是否需要创建流式卡片

View File

@@ -45,7 +45,9 @@ class CommandHandler(handler.MessageHandler):
query=query,
)
event_ctx = await self.ap.plugin_connector.emit_event(event)
# 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)
if event_ctx.is_prevented_default():
if event_ctx.event.reply_message_chain is not None:

View File

@@ -72,7 +72,9 @@ class ResponseWrapper(stage.PipelineStage):
query=query,
)
event_ctx = await self.ap.plugin_connector.emit_event(event)
# 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)
if event_ctx.is_prevented_default():
yield entities.StageProcessResult(
@@ -115,7 +117,9 @@ class ResponseWrapper(stage.PipelineStage):
query=query,
)
event_ctx = await self.ap.plugin_connector.emit_event(event)
# 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)
if event_ctx.is_prevented_default():
yield entities.StageProcessResult(

View File

@@ -157,6 +157,9 @@ 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:

View File

@@ -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'{message_session_id}-{uuid.uuid4()}{extension}'
image_key = f'bot_log_images/{message_session_id}-{uuid.uuid4()}{extension}'
await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes)
image_keys.append(image_key)

View File

@@ -49,7 +49,7 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event.userid,
nickname='',
nickname=event.username,
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.userid,
member_name=event.username,
group=platform_entities.Group(
id=str(event.chatid),
name='',
name=event.chatname,
permission=platform_entities.Permission.Member,
),
special_title='',

View File

@@ -43,6 +43,10 @@ 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]
]
@@ -119,6 +123,41 @@ 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
@@ -249,47 +288,62 @@ 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
event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=False))
# 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 = context.EventContext.model_validate(event_ctx_result['event_context'])
return event_ctx
async def list_tools(self) -> list[ComponentManifest]:
async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin:
return []
list_tools_data = await self.handler.list_tools()
# Pass include_plugins to runtime for filtering
list_tools_data = await self.handler.list_tools(include_plugins=bound_plugins)
return [ComponentManifest.model_validate(tool) for tool in list_tools_data]
tools = [ComponentManifest.model_validate(tool) for tool in list_tools_data]
async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]:
return tools
async def call_tool(
self, tool_name: str, parameters: dict[str, Any], bound_plugins: list[str] | None = None
) -> dict[str, Any]:
if not self.is_enable_plugin:
return {'error': 'Tool not found: plugin system is disabled'}
return await self.handler.call_tool(tool_name, parameters)
# Pass include_plugins to runtime for validation
return await self.handler.call_tool(tool_name, parameters, include_plugins=bound_plugins)
async def list_commands(self) -> list[ComponentManifest]:
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin:
return []
list_commands_data = await self.handler.list_commands()
# Pass include_plugins to runtime for filtering
list_commands_data = await self.handler.list_commands(include_plugins=bound_plugins)
return [ComponentManifest.model_validate(command) for command in list_commands_data]
commands = [ComponentManifest.model_validate(command) for command in list_commands_data]
return commands
async def execute_command(
self, command_ctx: command_context.ExecuteContext
self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
if not self.is_enable_plugin:
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
return
gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True))
# Pass include_plugins to runtime for validation
gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True), include_plugins=bound_plugins)
async for ret in gen:
cmd_ret = command_context.CommandReturn.model_validate(ret)
@@ -297,6 +351,9 @@ 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()

View File

@@ -436,6 +436,25 @@ 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(
@@ -535,23 +554,27 @@ 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) -> list[dict[str, Any]]:
async def list_tools(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]:
"""List tools"""
result = await self.call_action(
LangBotToRuntimeAction.LIST_TOOLS,
{},
{
'include_plugins': include_plugins,
},
timeout=20,
)
@@ -596,34 +619,42 @@ class RuntimeConnectionHandler(handler.Handler):
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]:
async def call_tool(
self, tool_name: str, parameters: dict[str, Any], include_plugins: list[str] | None = None
) -> 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) -> list[dict[str, Any]]:
async def list_commands(self, include_plugins: list[str] | None = None) -> 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]) -> typing.AsyncGenerator[dict[str, Any], None]:
async def execute_command(
self, command_context: dict[str, Any], include_plugins: list[str] | None = None
) -> 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,
)

View File

@@ -152,6 +152,7 @@ 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':
# 收集内容
@@ -162,7 +163,7 @@ class CozeAPIRunner(runner.RequestRunner):
if 'reasoning_content' in data:
full_reasoning += data.get('reasoning_content', '')
elif event_type == 'done':
elif event_type.split(".")[-1] == 'done' : # 本地部署coze时结束event不为done
# 保存会话ID
if 'conversation_id' in data:
conversation_id = data.get('conversation_id')
@@ -258,7 +259,7 @@ class CozeAPIRunner(runner.RequestRunner):
stop_reasoning = True
elif event_type == 'done':
elif event_type.split(".")[-1] == 'done' : # 本地部署coze时结束event不为done
# 保存会话ID
if 'conversation_id' in data:
conversation_id = data.get('conversation_id')

View File

@@ -35,7 +35,7 @@ class ToolLoader(abc.ABC):
pass
@abc.abstractmethod
async def get_tools(self) -> list[resource_tool.LLMTool]:
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
"""获取所有工具"""
pass

View File

@@ -30,6 +30,8 @@ class RuntimeMCPSession:
server_name: str
server_uuid: str
server_config: dict
session: ClientSession
@@ -43,7 +45,6 @@ class RuntimeMCPSession:
# connected: bool
status: MCPSessionStatus
_lifecycle_task: asyncio.Task | None
_shutdown_event: asyncio.Event
@@ -52,6 +53,7 @@ class RuntimeMCPSession:
def __init__(self, server_name: str, server_config: dict, enable: bool, 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
@@ -286,12 +288,14 @@ class MCPLoader(loader.ToolLoader):
"""
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,
@@ -301,10 +305,16 @@ class MCPLoader(loader.ToolLoader):
return session
async def get_tools(self) -> list[resource_tool.LLMTool]:
async def get_tools(self, bound_mcp_servers: list[str] | None = None) -> 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())
self._last_listed_functions = all_functions

View File

@@ -14,11 +14,11 @@ class PluginToolLoader(loader.ToolLoader):
本加载器中不存储工具信息,仅负责从插件系统中获取工具信息。
"""
async def get_tools(self) -> list[resource_tool.LLMTool]:
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
# 从插件系统获取工具(内容函数)
all_functions: list[resource_tool.LLMTool] = []
for tool in await self.ap.plugin_connector.list_tools():
for tool in await self.ap.plugin_connector.list_tools(bound_plugins):
tool_obj = resource_tool.LLMTool(
name=tool.metadata.name,
human_desc=tool.metadata.description.en_US,

View File

@@ -28,12 +28,12 @@ class ToolManager:
self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)
await self.mcp_tool_loader.initialize()
async def get_all_tools(self) -> list[resource_tool.LLMTool]:
async def get_all_tools(self, bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None) -> list[resource_tool.LLMTool]:
"""获取所有函数"""
all_functions: list[resource_tool.LLMTool] = []
all_functions.extend(await self.plugin_tool_loader.get_tools())
all_functions.extend(await self.mcp_tool_loader.get_tools())
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))
return all_functions

View File

@@ -42,3 +42,10 @@ class StorageProvider(abc.ABC):
key: str,
):
pass
@abc.abstractmethod
async def delete_dir_recursive(
self,
dir_path: str,
):
pass

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import os
import aiofiles
import shutil
from ...core import app
@@ -22,6 +23,8 @@ 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)
@@ -43,3 +46,11 @@ 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))

View File

@@ -1,6 +1,6 @@
semantic_version = 'v4.4.0'
semantic_version = 'v4.4.1'
required_database_version = 8
required_database_version = 9
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.4.0"
version = "4.4.1"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
requires-python = ">=3.10.1,<4.0"
@@ -63,7 +63,7 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.1.6",
"langbot-plugin==0.1.9b2",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

@@ -0,0 +1 @@
# Config unit tests

View File

@@ -0,0 +1,332 @@
"""
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'])

View File

@@ -5,7 +5,6 @@ PipelineManager unit tests
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
import sqlalchemy
def get_pipelinemgr_module():
@@ -54,6 +53,7 @@ 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,6 +77,7 @@ 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)
@@ -106,6 +107,7 @@ 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
@@ -134,6 +136,7 @@ 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
@@ -147,6 +150,7 @@ 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])

View File

@@ -0,0 +1,353 @@
'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>
</>
);
}

View File

@@ -11,18 +11,23 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { useEffect } from 'react';
import { useEffect, useRef } 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(
@@ -53,6 +58,9 @@ 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({
@@ -97,9 +105,24 @@ export default function DynamicFormComponent({
});
// 当 initialValues 变化时更新表单值
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
useEffect(() => {
console.log('initialValues', initialValues);
if (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) {
// 合并默认值和初始值
const mergedValues = itemConfigList.reduce(
(acc, item) => {
@@ -112,6 +135,8 @@ export default function DynamicFormComponent({
Object.entries(mergedValues).forEach(([key, value]) => {
form.setValue(key as keyof FormValues, value);
});
previousInitialValues.current = initialValues;
}
}, [initialValues, form, itemConfigList]);
@@ -149,7 +174,11 @@ export default function DynamicFormComponent({
{config.required && <span className="text-red-500">*</span>}
</FormLabel>
<FormControl>
<DynamicFormItemComponent config={config} field={field} />
<DynamicFormItemComponent
config={config}
field={field}
onFileUploaded={onFileUploaded}
/>
</FormControl>
{config.description && (
<p className="text-sm text-muted-foreground">

View File

@@ -1,6 +1,7 @@
import {
DynamicFormItemType,
IDynamicFormItemSchema,
IFileConfig,
} from '@/app/infra/entities/form/dynamic';
import { Input } from '@/components/ui/input';
import {
@@ -16,7 +17,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 } from '@/app/infra/entities/api';
import { LLMModel, Bot } from '@/app/infra/entities/api';
import { KnowledgeBase } from '@/app/infra/entities/api';
import { toast } from 'sonner';
import {
@@ -27,19 +28,54 @@ 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
@@ -48,7 +84,7 @@ export default function DynamicFormItemComponent({
setLlmModels(resp.models);
})
.catch((err) => {
toast.error('获取 LLM 模型列表失败:' + err.message);
toast.error('Failed to get LLM model list: ' + err.message);
});
}
}, [config.type]);
@@ -61,7 +97,20 @@ export default function DynamicFormItemComponent({
setKnowledgeBases(resp.bases);
})
.catch((err) => {
toast.error('获取知识库列表失败:' + err.message);
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);
});
}
}, [config.type]);
@@ -80,6 +129,9 @@ 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} />;
@@ -284,6 +336,24 @@ 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">
@@ -366,6 +436,185 @@ 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} />;
}

View File

@@ -25,6 +25,7 @@ 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({
@@ -45,6 +46,7 @@ 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);
@@ -220,6 +222,23 @@ 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) => {
@@ -326,6 +345,10 @@ export default function HomeSidebar({
open={passwordChangeOpen}
onOpenChange={setPasswordChangeOpen}
/>
<ApiKeyManagementDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
/>
</div>
);
}

View File

@@ -23,6 +23,13 @@ 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'));
@@ -46,7 +53,7 @@ export default function FileUploadZone({
setIsUploading(false);
}
},
[kbId, isUploading, onUploadSuccess, onUploadError],
[kbId, isUploading, onUploadSuccess, onUploadError, t],
);
const handleDragOver = useCallback((e: React.DragEvent) => {

View File

@@ -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 } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import { I18nObject } from '@/app/infra/entities/common';
@@ -18,11 +18,15 @@ export default function HomeLayout({
en_US: '',
zh_Hans: '',
});
const onSelectedChangeAction = (child: SidebarChildVO) => {
const onSelectedChangeAction = useCallback((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}>
@@ -33,7 +37,7 @@ export default function HomeLayout({
<div className={styles.main}>
<HomeTitleBar title={title} subtitle={subtitle} helpLink={helpLink} />
<main className={styles.mainContent}>{children}</main>
<main className={styles.mainContent}>{mainContent}</main>
</div>
</div>
);

View File

@@ -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().min(1, { message: t('models.apiKeyRequired') }),
api_key: z.string().optional(),
extra_args: z.array(getExtraArgSchema(t)).optional(),
});
@@ -101,7 +101,7 @@ export default function EmbeddingForm({
name: '',
model_provider: '',
url: '',
api_key: 'sk-xxxxx',
api_key: '',
extra_args: [],
},
});
@@ -245,7 +245,7 @@ export default function EmbeddingForm({
timeout: 120,
},
extra_args: extraArgsObj,
api_keys: [value.api_key],
api_keys: value.api_key ? [value.api_key] : [],
};
if (editMode) {
@@ -310,6 +310,7 @@ export default function EmbeddingForm({
extraArgsObj[arg.key] = arg.value;
}
});
const apiKey = form.getValues('api_key');
httpClient
.testEmbeddingModel('_', {
uuid: '',
@@ -320,7 +321,7 @@ export default function EmbeddingForm({
base_url: form.getValues('url'),
timeout: 120,
},
api_keys: [form.getValues('api_key')],
api_keys: apiKey ? [apiKey] : [],
extra_args: extraArgsObj,
})
.then((res) => {
@@ -461,10 +462,7 @@ export default function EmbeddingForm({
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.apiKey')}
<span className="text-red-500">*</span>
</FormLabel>
<FormLabel>{t('models.apiKey')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>

View File

@@ -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().min(1, { message: t('models.apiKeyRequired') }),
api_key: z.string().optional(),
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: 'sk-xxxxx',
api_key: '',
abilities: [],
extra_args: [],
},
@@ -261,7 +261,7 @@ export default function LLMForm({
timeout: 120,
},
extra_args: extraArgsObj,
api_keys: [value.api_key],
api_keys: value.api_key ? [value.api_key] : [],
abilities: value.abilities,
};
@@ -324,6 +324,7 @@ export default function LLMForm({
extraArgsObj[arg.key] = arg.value;
}
});
const apiKey = form.getValues('api_key');
httpClient
.testLLMModel('_', {
uuid: '',
@@ -334,7 +335,7 @@ export default function LLMForm({
base_url: form.getValues('url'),
timeout: 120,
},
api_keys: [form.getValues('api_key')],
api_keys: apiKey ? [apiKey] : [],
abilities: form.getValues('abilities'),
extra_args: extraArgsObj,
})
@@ -478,10 +479,7 @@ export default function LLMForm({
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.apiKey')}
<span className="text-red-500">*</span>
</FormLabel>
<FormLabel>{t('models.apiKey')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>

View File

@@ -18,6 +18,7 @@ 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;
@@ -31,7 +32,7 @@ interface PipelineDialogProps {
onCancel: () => void;
}
type DialogMode = 'config' | 'debug';
type DialogMode = 'config' | 'debug' | 'extensions';
export default function PipelineDialog({
open,
@@ -81,6 +82,19 @@ 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'),
@@ -102,6 +116,9 @@ export default function PipelineDialog({
? t('pipelines.editPipeline')
: t('pipelines.createPipeline');
}
if (currentMode === 'extensions') {
return t('pipelines.extensions.title');
}
return t('pipelines.debugDialog.title');
};
@@ -193,6 +210,11 @@ export default function PipelineDialog({
}}
/>
)}
{currentMode === 'extensions' && pipelineId && (
<PipelineExtension pipelineId={pipelineId} />
)}
{currentMode === 'debug' && pipelineId && (
<DebugDialog
open={true}

View File

@@ -0,0 +1,450 @@
'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>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -24,6 +24,9 @@ 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(() => {
// 获取插件信息
@@ -33,28 +36,103 @@ 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 (values: object) => {
const handleSubmit = async () => {
setIsLoading(true);
const isDebugPlugin = pluginInfo?.debug;
httpClient
.updatePluginConfig(pluginAuthor, pluginName, values)
.then(() => {
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);
}
});
// 初始有但最终没有的文件(被删除的)
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.message);
})
.finally(() => {
} catch (error) {
toast.error(t('plugins.saveConfigError') + (error as Error).message);
} finally {
setIsLoading(false);
});
}
};
if (!pluginInfo || !pluginConfig) {
@@ -95,14 +173,12 @@ export default function PluginForm({
itemConfigList={pluginInfo.manifest.manifest.spec.config}
initialValues={pluginConfig.config as Record<string, object>}
onSubmit={(values) => {
let config = pluginConfig.config;
config = {
...config,
...values,
};
setPluginConfig({
config: config,
});
// 只保存表单值的引用,不触发状态更新
currentFormValues.current = values;
}}
onFileUploaded={(fileKey) => {
// 追踪上传的文件
uploadedFileKeys.current.add(fileKey);
}}
/>
)}
@@ -117,7 +193,7 @@ export default function PluginForm({
<div className="flex justify-end gap-2">
<Button
type="submit"
onClick={() => handleSubmit(pluginConfig.config)}
onClick={() => handleSubmit()}
disabled={isSaving}
>
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}

View File

@@ -283,7 +283,7 @@ function MarketPageContent({
// };
return (
<div className="container mx-auto px-4 py-6 space-y-6">
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6">
{/* 搜索框 */}
<div className="flex items-center justify-center">
<div className="relative w-full max-w-2xl">
@@ -301,19 +301,19 @@ function MarketPageContent({
handleSearch(searchQuery);
}
}}
className="pl-10 pr-4"
className="pl-10 pr-4 text-sm sm:text-base"
/>
</div>
</div>
{/* 排序下拉框 */}
<div className="flex items-center justify-center">
<div className="w-full max-w-2xl flex items-center gap-3">
<span className="text-sm text-muted-foreground whitespace-nowrap">
<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">
{t('market.sortBy')}:
</span>
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-48">
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -329,7 +329,7 @@ function MarketPageContent({
{/* 搜索结果统计 */}
{total > 0 && (
<div className="text-center text-muted-foreground">
<div className="text-center text-muted-foreground text-sm">
{searchQuery
? t('market.searchResults', { count: total })
: t('market.totalPlugins', { count: total })}

View File

@@ -228,6 +228,30 @@ 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} />
),
@@ -274,6 +298,57 @@ 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}

View File

@@ -15,35 +15,37 @@ export default function PluginMarketCardComponent({
return (
<div
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]"
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]"
onClick={handleCardClick}
>
<div className="w-full h-full flex flex-col justify-between">
<div className="w-full h-full flex flex-col justify-between gap-2">
{/* 上部分:插件信息 */}
<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 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-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]">
<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">
{cardVO.pluginId}
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0]">
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate w-full">
{cardVO.label}
</div>
</div>
</div>
<div className="text-[0.8rem] text-[#666] dark:text-[#999] line-clamp-2">
<div className="text-[0.7rem] sm:text-[0.8rem] text-[#666] dark:text-[#999] line-clamp-2 overflow-hidden">
{cardVO.description}
</div>
</div>
<div className="flex h-full flex-row items-start justify-center gap-[0.4rem]">
<div className="flex flex-row items-start justify-center gap-[0.4rem] flex-shrink-0">
{cardVO.githubURL && (
<svg
className="w-[1.4rem] h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0]"
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"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
@@ -59,9 +61,9 @@ export default function PluginMarketCardComponent({
</div>
{/* 下部分:下载量 */}
<div className="w-full flex flex-row items-center justify-start gap-[0.4rem] px-[0.4rem]">
<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">
<svg
className="w-[1.2rem] h-[1.2rem] text-[#2563eb]"
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -72,7 +74,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-sm text-[#2563eb] font-medium">
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
{cardVO.installCount.toLocaleString()}
</div>
</div>

View File

@@ -30,10 +30,11 @@ export default function MCPComponent({
};
}, []);
// Check if any server is connecting and start/stop polling accordingly
// Check if any enabled server is connecting and start/stop polling accordingly
useEffect(() => {
const hasConnecting = installedServers.some(
(server) => server.status === MCPSessionStatus.CONNECTING,
(server) =>
server.enable && server.status === MCPSessionStatus.CONNECTING,
);
if (hasConnecting && !pollingIntervalRef.current) {
@@ -42,7 +43,7 @@ export default function MCPComponent({
fetchInstalledServers();
}, 3000);
} else if (!hasConnecting && pollingIntervalRef.current) {
// Stop polling when no server is connecting
// Stop polling when no enabled server is connecting
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}

View File

@@ -121,7 +121,8 @@ export default function PluginConfigPage() {
};
fetchPluginSystemStatus();
}, [t]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';

View File

@@ -9,6 +9,10 @@ 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 {
@@ -16,12 +20,22 @@ 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 {

View File

@@ -37,6 +37,7 @@ import {
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';
@@ -169,6 +170,26 @@ 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 ============
@@ -442,6 +463,26 @@ 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;

View File

@@ -38,7 +38,7 @@ export abstract class BaseHttpClient {
this.instance = axios.create({
baseURL: baseURL,
timeout: 15000,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},

View File

@@ -69,6 +69,14 @@ 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}`;
}

View File

@@ -58,6 +58,23 @@ 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',
@@ -139,6 +156,7 @@ const enUS = {
adapterConfig: 'Adapter Configuration',
bindPipeline: 'Bind Pipeline',
selectPipeline: 'Select Pipeline',
selectBot: 'Select Bot',
botLogTitle: 'Bot Log',
enableAutoRefresh: 'Enable Auto Refresh',
session: 'Session',
@@ -153,7 +171,7 @@ const enUS = {
plugins: {
title: 'Extensions',
description:
'Install and configure plugins to extend LangBot functionality',
'Install and configure plugins to extend functionality, please select them in the pipeline configuration',
createPlugin: 'Create Plugin',
editPlugin: 'Edit Plugin',
installed: 'Installed',
@@ -242,6 +260,14 @@ 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',
@@ -419,6 +445,23 @@ 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',
@@ -483,6 +526,8 @@ 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',

View File

@@ -59,6 +59,23 @@ 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: 'ページが見つかりません',
@@ -141,6 +158,7 @@ const jaJP = {
adapterConfig: 'アダプター設定',
bindPipeline: 'パイプラインを紐付け',
selectPipeline: 'パイプラインを選択',
selectBot: 'ボットを選択してください',
botLogTitle: 'ボットログ',
enableAutoRefresh: '自動更新を有効にする',
session: 'セッション',
@@ -154,7 +172,8 @@ const jaJP = {
},
plugins: {
title: '拡張機能',
description: 'LangBotの機能を拡張するプラグインをインストール・設定',
description:
'LangBotの機能を拡張するプラグインをインストール・設定。流水線設定で使用します',
createPlugin: 'プラグインを作成',
editPlugin: 'プラグインを編集',
installed: 'インストール済み',
@@ -242,6 +261,14 @@ const jaJP = {
saveConfigSuccessDebugPlugin:
'設定を保存しました。手動でプラグインを再起動してください',
saveConfigError: '設定の保存に失敗しました:',
fileUpload: {
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
success: 'ファイルのアップロードに成功しました',
failed: 'ファイルのアップロードに失敗しました',
uploading: 'アップロード中...',
chooseFile: 'ファイルを選択',
addFile: 'ファイルを追加',
},
installFromGithub: 'GitHubから',
enterRepoUrl: 'GitHubリポジトリのURLを入力してください',
repoUrlPlaceholder: '例: https://github.com/owner/repo',
@@ -421,6 +448,23 @@ 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: 'パイプラインを選択',
@@ -486,6 +530,8 @@ const jaJP = {
uploadSuccess: 'ファイルのアップロードに成功しました!',
uploadError: 'ファイルのアップロードに失敗しました。再度お試しください',
uploadingFile: 'ファイルをアップロード中...',
fileSizeExceeded:
'ファイルサイズが10MBの制限を超えています。より小さいファイルに分割してください。',
actions: 'アクション',
delete: 'ドキュメントを削除',
fileDeleteSuccess: 'ドキュメントの削除に成功しました',

View File

@@ -57,6 +57,22 @@ 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: '页面不存在',
@@ -136,6 +152,7 @@ const zhHans = {
adapterConfig: '适配器配置',
bindPipeline: '绑定流水线',
selectPipeline: '选择流水线',
selectBot: '请选择机器人',
botLogTitle: '机器人日志',
enableAutoRefresh: '开启自动刷新',
session: '会话',
@@ -149,7 +166,7 @@ const zhHans = {
},
plugins: {
title: '插件扩展',
description: '安装和配置用于扩展 LangBot 功能的插件',
description: '安装和配置用于扩展功能的插件,请在流水线配置中选用',
createPlugin: '创建插件',
editPlugin: '编辑插件',
installed: '已安装',
@@ -230,6 +247,14 @@ const zhHans = {
saveConfigSuccessNormal: '保存配置成功',
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
saveConfigError: '保存配置失败:',
fileUpload: {
tooLarge: '文件大小超过 10MB 限制',
success: '文件上传成功',
failed: '文件上传失败',
uploading: '上传中...',
chooseFile: '选择文件',
addFile: '添加文件',
},
installFromGithub: '来自 GitHub',
enterRepoUrl: '请输入 GitHub 仓库地址',
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
@@ -403,6 +428,23 @@ 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: '选择流水线',
@@ -463,6 +505,7 @@ const zhHans = {
uploadSuccess: '文件上传成功!',
uploadError: '文件上传失败,请重试',
uploadingFile: '上传文件中...',
fileSizeExceeded: '文件大小超过 10MB 限制,请分割成较小的文件后上传',
actions: '操作',
delete: '删除文件',
fileDeleteSuccess: '文件删除成功',

View File

@@ -57,6 +57,22 @@ 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: '頁面不存在',
@@ -136,6 +152,7 @@ const zhHant = {
adapterConfig: '適配器設定',
bindPipeline: '綁定流程線',
selectPipeline: '選擇流程線',
selectBot: '請選擇機器人',
botLogTitle: '機器人日誌',
enableAutoRefresh: '開啟自動重新整理',
session: '對話',
@@ -149,7 +166,7 @@ const zhHant = {
},
plugins: {
title: '外掛擴展',
description: '安裝和設定用於擴展 LangBot 功能的外掛',
description: '安裝和設定用於擴展功能的外掛,請在流程線配置中選用',
createPlugin: '建立外掛',
editPlugin: '編輯外掛',
installed: '已安裝',
@@ -229,6 +246,14 @@ const zhHant = {
saveConfigSuccessNormal: '儲存配置成功',
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
saveConfigError: '儲存配置失敗:',
fileUpload: {
tooLarge: '檔案大小超過 10MB 限制',
success: '檔案上傳成功',
failed: '檔案上傳失敗',
uploading: '上傳中...',
chooseFile: '選擇檔案',
addFile: '新增檔案',
},
enterRepoUrl: '請輸入 GitHub 倉庫地址',
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
fetchingReleases: '正在獲取 Release 列表...',
@@ -401,6 +426,23 @@ 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: '選擇流程線',
@@ -460,6 +502,7 @@ const zhHant = {
uploadSuccess: '文檔上傳成功!',
uploadError: '文檔上傳失敗,請重試',
uploadingFile: '上傳文檔中...',
fileSizeExceeded: '檔案大小超過 10MB 限制,請分割成較小的檔案後上傳',
actions: '操作',
delete: '刪除文檔',
fileDeleteSuccess: '文檔刪除成功',