Compare commits

...

47 Commits

Author SHA1 Message Date
Junyan Qin
8cd50fbdb4 chore: bump version 4.5.0 2025-11-10 22:50:10 +08:00
Copilot
42421d171e feat: Add webhook push functionality for bot message events (#1768)
* Initial plan

* Backend: Add webhook persistence model, service, API endpoints and message push functionality

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

* Frontend: Rename API Keys to API Integration, add webhook management UI with tabs

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

* Fix frontend linting issues and formatting

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

* chore: perf ui in api integration dialog

* perf: webhook data pack structure

---------

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-10 22:41:25 +08:00
fdc310
32215e9a3f fix:Fixed the issue where the rich text processing in the DingTalk AP… (#1759)
* fix:Fixed the issue where the rich text processing in the DingTalk API did not account for multiple texts and images, as well as the presence of default line breaks. Also resolved the error in Dify caused by sending only images, which resulted in an empty query.

* fix:Considering the various possible scenarios, there are cases where plan_text is empty when there is file content, and there is no file (the message could not be parsed) and the content is empty.

* fix:Add the default modifiable prompt input for didify in the ai.yaml file to ensure that the error of query being empty occurs when receiving data.

* add: The config migration of Dify

* fix:Migration issue

* perf: minor fix

* chore: minor fix

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-10 21:42:09 +08:00
Junyan Qin
dd1c7ffc39 chore: bump langbot-plugin to 0.1.10 2025-11-10 17:11:38 +08:00
Junyan Qin
b59bf62da5 perf: add rounded style for card icons 2025-11-10 11:07:37 +08:00
Junyan Qin
f4c32f7b30 chore: add comments 2025-11-10 00:27:56 +08:00
Junyan Qin
8844a5304d revert: default thinking param for testing model 2025-11-10 00:22:38 +08:00
Junyan Qin
922ddd47f4 feat: add jiekou.ai requester 2025-11-10 00:22:10 +08:00
Junyan Qin
8c8702c6c9 chore: only start runtime process once on Windows 2025-11-09 21:47:20 +08:00
Junyan Qin
70147fcf5e perf: i18n for pipeline extensions 2025-11-09 12:40:19 +08:00
Junyan Qin
b3ee16e876 chore: bump langbot-plugin to 0.1.9 2025-11-08 22:51:46 +08:00
Copilot
8d7976190d Add pipeline copy button to duplicate existing configurations (#1767)
* Initial plan

* Add copy button to pipeline configuration page

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

* Add i18n support for copy suffix and address code review feedback

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

* Show new pipeline name in copy toast and close dialog after copy

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

* perf: tool list style in extension tab

---------

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-08 14:03:41 +08:00
Copilot
3edae3e678 feat: Support multiple knowledge base binding in pipelines (#1766)
* Initial plan

* Add multi-knowledge base support to pipelines

- Created database migration dbm010 to convert knowledge-base field from string to array
- Updated default pipeline config to use knowledge-bases array
- Updated pipeline metadata to use knowledge-base-multi-selector type
- Modified localagent.py to retrieve from multiple knowledge bases and concatenate results
- Added KNOWLEDGE_BASE_MULTI_SELECTOR type to frontend form entities
- Implemented multi-selector UI component with dialog for selecting multiple knowledge bases

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

* Add i18n translations for multi-knowledge base selector

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

* Fix prettier formatting errors in DynamicFormItemComponent

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

* Add accessibility attributes to knowledge base selector checkbox

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

* fix: minor fix

---------

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-08 13:45:09 +08:00
Junyan Qin
dd2254203c revert: test image first wait time 2025-11-07 19:31:45 +08:00
Matthew_Astral
f8658e2d77 fix: get_llm_models: model_service is a module, not an attribute (#1762) 2025-11-07 19:23:49 +08:00
Junyan Qin
021c3bbb94 perf: show help link in api key mgm dialog 2025-11-07 18:48:49 +08:00
Junyan Qin
0a64a96f65 ci: update 15s for image testing 2025-11-07 18:43:51 +08:00
Copilot
48576dc46d ci: Add automated health check workflow for dev image on master branch (#1761)
* Initial plan

* Add test-dev-image workflow for master branch

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

* Improve API health check to show response body for debugging

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

* Add connection timeout handling for curl health checks

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

* Add explicit permissions to workflow for security

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 18:38:33 +08:00
Junyan Qin
12de0343b4 chore: remove legacy plugin files 2025-11-07 18:21:09 +08:00
Junyan Qin
fcd34a9ff3 perf: no longer resp enabled platform count in /info 2025-11-07 18:19:09 +08:00
Junyan Qin
0dcf904d81 ci: no longer update 'latest' tag when publish a pre-release 2025-11-07 18:08:11 +08:00
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
103 changed files with 6543 additions and 204 deletions

View File

@@ -41,5 +41,9 @@ jobs:
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
- name: Create Buildx
run: docker buildx create --name mybuilder --use
- name: Build # image name: rockchin/langbot:<VERSION>
- name: Build for Release # only relase, exlude pre-release
if: ${{ github.event.release.prerelease == false }}
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
- name: Build for Pre-release # no update for latest tag
if: ${{ github.event.release.prerelease == true }}
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push

108
.github/workflows/test-dev-image.yaml vendored Normal file
View File

@@ -0,0 +1,108 @@
name: Test Dev Image
on:
workflow_run:
workflows: ["Build Dev Image"]
types:
- completed
branches:
- master
jobs:
test-dev-image:
runs-on: ubuntu-latest
# Only run if the build workflow succeeded
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Update Docker Compose to use master tag
working-directory: ./docker
run: |
# Replace 'latest' with 'master' tag for testing the dev image
sed -i 's/rockchin\/langbot:latest/rockchin\/langbot:master/g' docker-compose.yaml
echo "Updated docker-compose.yaml to use master tag:"
cat docker-compose.yaml
- name: Start Docker Compose
working-directory: ./docker
run: docker compose up -d
- name: Wait and Test API
run: |
# Function to test API endpoint
test_api() {
echo "Testing API endpoint..."
response=$(curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" http://localhost:5300/api/v1/system/info 2>&1)
curl_exit_code=$?
if [ $curl_exit_code -ne 0 ]; then
echo "Curl failed with exit code: $curl_exit_code"
echo "Error: $response"
return 1
fi
http_code=$(echo "$response" | tail -n 1)
response_body=$(echo "$response" | head -n -1)
if [ "$http_code" = "200" ]; then
echo "API is healthy! Response code: $http_code"
echo "Response: $response_body"
return 0
else
echo "API returned non-200 response: $http_code"
echo "Response body: $response_body"
return 1
fi
}
# Wait 30 seconds before first attempt
echo "Waiting 30 seconds for services to start..."
sleep 30
# Try up to 3 times with 30-second intervals
max_attempts=3
attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts"
if test_api; then
echo "Success! API is responding correctly."
exit 0
fi
if [ $attempt -lt $max_attempts ]; then
echo "Retrying in 30 seconds..."
sleep 30
fi
attempt=$((attempt + 1))
done
# All attempts failed
echo "Failed to get healthy response after $max_attempts attempts"
exit 1
- name: Show Container Logs on Failure
if: failure()
working-directory: ./docker
run: |
echo "=== Docker Compose Status ==="
docker compose ps
echo ""
echo "=== LangBot Logs ==="
docker compose logs langbot
echo ""
echo "=== Plugin Runtime Logs ==="
docker compose logs langbot_plugin_runtime
- name: Cleanup
if: always()
working-directory: ./docker
run: docker compose down

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.
-->

View File

@@ -16,7 +16,3 @@ spec:
LLMAPIRequester:
fromDirs:
- path: pkg/provider/modelmgr/requesters/
Plugin:
fromDirs:
- path: plugins/
maxDepth: 2

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

@@ -5,6 +5,8 @@ import typing
import json
from .errors import DifyAPIError
from pathlib import Path
import os
class AsyncDifyServiceClient:
@@ -109,7 +111,23 @@ class AsyncDifyServiceClient:
user: str,
timeout: float = 30.0,
) -> str:
"""上传文件"""
# 处理 Path 对象
if isinstance(file, Path):
if not file.exists():
raise ValueError(f'File not found: {file}')
with open(file, 'rb') as f:
file = f.read()
# 处理文件路径字符串
elif isinstance(file, str):
if not os.path.isfile(file):
raise ValueError(f'File not found: {file}')
with open(file, 'rb') as f:
file = f.read()
# 处理文件对象
elif hasattr(file, 'read'):
file = file.read()
async with httpx.AsyncClient(
base_url=self.base_url,
trust_env=True,
@@ -121,6 +139,8 @@ class AsyncDifyServiceClient:
headers={'Authorization': f'Bearer {self.api_key}'},
files={
'file': file,
},
data={
'user': (None, user),
},
)

View File

@@ -188,12 +188,80 @@ class DingTalkClient:
if incoming_message.message_type == 'richText':
data = incoming_message.rich_text_content.to_dict()
# 使用统一的结构化数据格式,保持顺序
rich_content = {
'Type': 'richText',
'Elements': [], # 按顺序存储所有元素
'SimpleContent': '', # 兼容字段:纯文本内容
'SimplePicture': '' # 兼容字段:第一张图片
}
# 先收集所有文本和图片占位符
text_elements = []
image_placeholders = []
# 解析富文本内容,保持原始顺序
for item in data['richText']:
if 'text' in item:
message_data['Content'] = item['text']
if incoming_message.get_image_list()[0]:
message_data['Picture'] = await self.download_image(incoming_message.get_image_list()[0])
message_data['Type'] = 'text'
# 处理文本内容
if 'text' in item and item['text'] != "\n":
element = {
'Type': 'text',
'Content': item['text']
}
rich_content['Elements'].append(element)
text_elements.append(item['text'])
# 检查是否是图片元素 - 根据钉钉API的实际结构调整
# 钉钉富文本中的图片通常有特定标识,可能需要根据实际返回调整
elif item.get("type") == "picture":
# 创建图片占位符
element = {
'Type': 'image_placeholder',
}
rich_content['Elements'].append(element)
# 获取并下载所有图片
image_list = incoming_message.get_image_list()
if image_list:
new_elements = []
image_index = 0
for element in rich_content['Elements']:
if element['Type'] == 'image_placeholder':
if image_index < len(image_list) and image_list[image_index]:
image_url = await self.download_image(image_list[image_index])
new_elements.append({
'Type': 'image',
'Picture': image_url
})
image_index += 1
else:
# 如果没有对应的图片,保留占位符或跳过
continue
else:
new_elements.append(element)
rich_content['Elements'] = new_elements
# 设置兼容字段
all_texts = [elem['Content'] for elem in rich_content['Elements'] if elem.get('Type') == 'text']
rich_content['SimpleContent'] = '\n'.join(all_texts) if all_texts else ''
all_images = [elem['Picture'] for elem in rich_content['Elements'] if elem.get('Type') == 'image']
if all_images:
rich_content['SimplePicture'] = all_images[0]
rich_content['AllImages'] = all_images # 所有图片的列表
# 设置原始的 content 和 picture 字段以保持兼容
message_data['Content'] = rich_content['SimpleContent']
message_data['Rich_Content'] = rich_content
if all_images:
message_data['Picture'] = all_images[0]
elif incoming_message.message_type == 'text':
message_data['Content'] = incoming_message.get_text_list()[0]

View File

@@ -15,6 +15,10 @@ class DingTalkEvent(dict):
def content(self):
return self.get('Content', '')
@property
def rich_content(self):
return self.get('Rich_Content', '')
@property
def incoming_message(self) -> Optional['dingtalk_stream.chatbot.ChatbotMessage']:
return self.get('IncomingMessage')

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

@@ -13,7 +13,6 @@ class SystemRouterGroup(group.RouterGroup):
data={
'version': constants.semantic_version,
'debug': constants.debug_mode,
'enabled_platform_count': len(self.ap.platform_mgr.get_running_adapters()),
'enable_marketplace': self.ap.instance_config.data.get('plugin', {}).get(
'enable_marketplace', True
),

View File

@@ -0,0 +1,49 @@
import quart
from .. import group
@group.group_class('webhooks', '/api/v1/webhooks')
class WebhooksRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'])
async def _() -> str:
if quart.request.method == 'GET':
webhooks = await self.ap.webhook_service.get_webhooks()
return self.success(data={'webhooks': webhooks})
elif quart.request.method == 'POST':
json_data = await quart.request.json
name = json_data.get('name', '')
url = json_data.get('url', '')
description = json_data.get('description', '')
enabled = json_data.get('enabled', True)
if not name:
return self.http_status(400, -1, 'Name is required')
if not url:
return self.http_status(400, -1, 'URL is required')
webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
return self.success(data={'webhook': webhook})
@self.route('/<int:webhook_id>', methods=['GET', 'PUT', 'DELETE'])
async def _(webhook_id: int) -> str:
if quart.request.method == 'GET':
webhook = await self.ap.webhook_service.get_webhook(webhook_id)
if webhook is None:
return self.http_status(404, -1, 'Webhook not found')
return self.success(data={'webhook': webhook})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
name = json_data.get('name')
url = json_data.get('url')
description = json_data.get('description')
enabled = json_data.get('enabled')
await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.webhook_service.delete_webhook(webhook_id)
return self.success()

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,18 @@ class LLMModelsService:
else:
runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data)
# Mon Nov 10 2025: Commented for some providers may not support thinking parameter
# # 有些模型厂商默认开启了思考功能,测试容易延迟
# 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

@@ -0,0 +1,81 @@
from __future__ import annotations
import sqlalchemy
from ....core import app
from ....entity.persistence import webhook
class WebhookService:
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_webhooks(self) -> list[dict]:
"""Get all webhooks"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(webhook.Webhook))
webhooks = result.all()
return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks]
async def create_webhook(self, name: str, url: str, description: str = '', enabled: bool = True) -> dict:
"""Create a new webhook"""
webhook_data = {'name': name, 'url': url, 'description': description, 'enabled': enabled}
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(webhook.Webhook).values(**webhook_data))
# Retrieve the created webhook
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.url == url).order_by(webhook.Webhook.id.desc())
)
created_webhook = result.first()
return self.ap.persistence_mgr.serialize_model(webhook.Webhook, created_webhook)
async def get_webhook(self, webhook_id: int) -> dict | None:
"""Get a specific webhook by ID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id)
)
wh = result.first()
if wh is None:
return None
return self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh)
async def update_webhook(
self, webhook_id: int, name: str = None, url: str = None, description: str = None, enabled: bool = None
) -> None:
"""Update a webhook's metadata"""
update_data = {}
if name is not None:
update_data['name'] = name
if url is not None:
update_data['url'] = url
if description is not None:
update_data['description'] = description
if enabled is not None:
update_data['enabled'] = enabled
if update_data:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data)
)
async def delete_webhook(self, webhook_id: int) -> None:
"""Delete a webhook"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id)
)
async def get_enabled_webhooks(self) -> list[dict]:
"""Get all enabled webhooks"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True)
)
webhooks = result.all()
return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks]

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

@@ -6,6 +6,7 @@ import traceback
import os
from ..platform import botmgr as im_mgr
from ..platform.webhook_pusher import WebhookPusher
from ..provider.session import sessionmgr as llm_session_mgr
from ..provider.modelmgr import modelmgr as llm_model_mgr
from ..provider.tools import toolmgr as llm_tool_mgr
@@ -23,6 +24,8 @@ from ..api.http.service import pipeline as pipeline_service
from ..api.http.service import bot as bot_service
from ..api.http.service import knowledge as knowledge_service
from ..api.http.service import mcp as mcp_service
from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
from ..utils import logcache
@@ -44,6 +47,8 @@ class Application:
platform_mgr: im_mgr.PlatformManager = None
webhook_pusher: WebhookPusher = None
cmd_mgr: cmdmgr.CommandManager = None
sess_mgr: llm_session_mgr.SessionManager = None
@@ -122,6 +127,10 @@ class Application:
mcp_service: mcp_service.MCPService = None
apikey_service: apikey_service.ApiKeyService = None
webhook_service: webhook_service.WebhookService = None
def __init__(self):
pass

View File

@@ -5,7 +5,6 @@ import shutil
required_files = {
'plugins/__init__.py': 'templates/__init__.py',
'data/config.yaml': 'templates/config.yaml',
}
@@ -15,7 +14,6 @@ required_paths = [
'data/metadata',
'data/logs',
'data/labels',
'plugins',
]

View File

@@ -12,6 +12,7 @@ from ...provider.modelmgr import modelmgr as llm_model_mgr
from ...provider.tools import toolmgr as llm_tool_mgr
from ...rag.knowledge import kbmgr as rag_mgr
from ...platform import botmgr as im_mgr
from ...platform.webhook_pusher import WebhookPusher
from ...persistence import mgr as persistencemgr
from ...api.http.controller import main as http_controller
from ...api.http.service import user as user_service
@@ -20,6 +21,8 @@ from ...api.http.service import pipeline as pipeline_service
from ...api.http.service import bot as bot_service
from ...api.http.service import knowledge as knowledge_service
from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
@@ -92,6 +95,10 @@ class BuildAppStage(stage.BootingStage):
await im_mgr_inst.initialize()
ap.platform_mgr = im_mgr_inst
# Initialize webhook pusher
webhook_pusher_inst = WebhookPusher(ap)
ap.webhook_pusher = webhook_pusher_inst
pipeline_mgr = pipelinemgr.PipelineManager(ap)
await pipeline_mgr.initialize()
ap.pipeline_mgr = pipeline_mgr
@@ -130,5 +137,11 @@ 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
webhook_service_inst = webhook_service.WebhookService(ap)
ap.webhook_service = webhook_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

@@ -0,0 +1,22 @@
import sqlalchemy
from .base import Base
class Webhook(Base):
"""Webhook for pushing bot events to external systems"""
__tablename__ = 'webhooks'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
url = sqlalchemy.Column(sqlalchemy.String(1024), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
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

@@ -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

@@ -0,0 +1,88 @@
from .. import migration
import sqlalchemy
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(10)
class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
"""Pipeline support multiple knowledge base binding"""
async def upgrade(self):
"""Upgrade"""
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config']
# Convert knowledge-base from string to array
if 'local-agent' in config['ai']:
current_kb = config['ai']['local-agent'].get('knowledge-base', '')
# If it's already a list, skip
if isinstance(current_kb, list):
continue
# Convert string to list
if current_kb and current_kb != '__none__':
config['ai']['local-agent']['knowledge-bases'] = [current_kb]
else:
config['ai']['local-agent']['knowledge-bases'] = []
# Remove old field
if 'knowledge-base' in config['ai']['local-agent']:
del config['ai']['local-agent']['knowledge-base']
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""Downgrade"""
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config']
# Convert knowledge-bases from array back to string
if 'local-agent' in config['ai']:
current_kbs = config['ai']['local-agent'].get('knowledge-bases', [])
# If it's already a string, skip
if isinstance(current_kbs, str):
continue
# Convert list to string (take first one or empty)
if current_kbs and len(current_kbs) > 0:
config['ai']['local-agent']['knowledge-base'] = current_kbs[0]
else:
config['ai']['local-agent']['knowledge-base'] = ''
# Remove new field
if 'knowledge-bases' in config['ai']['local-agent']:
del config['ai']['local-agent']['knowledge-bases']
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)

View File

@@ -0,0 +1,40 @@
from .. import migration
import sqlalchemy
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(11)
class DBMigrateDifyApiConfig(migration.DBMigration):
"""Langflow API config"""
async def upgrade(self):
"""Upgrade"""
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config']
if 'base-prompt' not in config['ai']['dify-service-api']:
config['ai']['dify-service-api']['base-prompt'] = (
'When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.',
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""Downgrade"""
pass

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

@@ -13,6 +13,7 @@ from ..entity.persistence import bot as persistence_bot
from ..entity.errors import platform as platform_errors
from .logger import EventLogger
from .webhook_pusher import WebhookPusher
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.platform.events as platform_events
@@ -66,6 +67,14 @@ class RuntimeBot:
message_session_id=f'person_{event.sender.id}',
)
# Push to webhooks
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
asyncio.create_task(
self.ap.webhook_pusher.push_person_message(
event, self.bot_entity.uuid, adapter.__class__.__name__
)
)
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.PERSON,
@@ -91,6 +100,14 @@ class RuntimeBot:
message_session_id=f'group_{event.group.id}',
)
# Push to webhooks
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
asyncio.create_task(
self.ap.webhook_pusher.push_group_message(
event, self.bot_entity.uuid, adapter.__class__.__name__
)
)
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.GROUP,
@@ -157,6 +174,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

@@ -1,3 +1,4 @@
import traceback
import typing
from libs.dingtalk_api.dingtalkevent import DingTalkEvent
@@ -36,16 +37,31 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if atUser.dingtalk_id == event.incoming_message.chatbot_user_id:
yiri_msg_list.append(platform_message.At(target=bot_name))
if event.rich_content:
elements = event.rich_content.get("Elements")
for element in elements:
if element.get('Type') == 'text':
text = element.get('Content', '').replace('@' + bot_name, '')
if text.strip():
yiri_msg_list.append(platform_message.Plain(text=text))
elif element.get('Type') == 'image' and element.get('Picture'):
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
else:
# 回退到原有简单逻辑
if event.content:
text_content = event.content.replace('@' + bot_name, '')
yiri_msg_list.append(platform_message.Plain(text=text_content))
if event.picture:
yiri_msg_list.append(platform_message.Image(base64=event.picture))
# 处理其他类型消息(文件、音频等)
if event.file:
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
if event.audio:
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
chain = platform_message.MessageChain(yiri_msg_list)
return chain

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

@@ -0,0 +1,106 @@
from __future__ import annotations
import asyncio
import logging
import aiohttp
import uuid
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..core import app
import langbot_plugin.api.entities.builtin.platform.events as platform_events
class WebhookPusher:
"""Push bot events to configured webhooks"""
ap: app.Application
logger: logging.Logger
def __init__(self, ap: app.Application):
self.ap = ap
self.logger = self.ap.logger
async def push_person_message(self, event: platform_events.FriendMessage, bot_uuid: str, adapter_name: str) -> None:
"""Push person message event to webhooks"""
try:
webhooks = await self.ap.webhook_service.get_enabled_webhooks()
if not webhooks:
return
# Build payload
payload = {
'uuid': str(uuid.uuid4()), # unique id for the event
'event_type': 'bot.person_message',
'data': {
'bot_uuid': bot_uuid,
'adapter_name': adapter_name,
'sender': {
'id': str(event.sender.id),
'name': getattr(event.sender, 'name', ''),
},
'message': event.message_chain.model_dump(),
'timestamp': event.time if hasattr(event, 'time') else None,
},
}
# Push to all webhooks asynchronously
tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks]
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
self.logger.error(f'Failed to push person message to webhooks: {e}')
async def push_group_message(self, event: platform_events.GroupMessage, bot_uuid: str, adapter_name: str) -> None:
"""Push group message event to webhooks"""
try:
webhooks = await self.ap.webhook_service.get_enabled_webhooks()
if not webhooks:
return
# Build payload
payload = {
'uuid': str(uuid.uuid4()), # unique id for the event
'event_type': 'bot.group_message',
'data': {
'bot_uuid': bot_uuid,
'adapter_name': adapter_name,
'group': {
'id': str(event.group.id),
'name': getattr(event.group, 'name', ''),
},
'sender': {
'id': str(event.sender.id),
'name': getattr(event.sender, 'name', ''),
},
'message': event.message_chain.model_dump(),
'timestamp': event.time if hasattr(event, 'time') else None,
},
}
# Push to all webhooks asynchronously
tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks]
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
self.logger.error(f'Failed to push group message to webhooks: {e}')
async def _push_to_webhook(self, url: str, payload: dict) -> None:
"""Push payload to a single webhook URL"""
try:
async with aiohttp.ClientSession() as session:
async with session.post(
url,
json=payload,
headers={'Content-Type': 'application/json'},
timeout=aiohttp.ClientTimeout(total=15),
) as response:
if response.status >= 400:
self.logger.warning(f'Webhook {url} returned status {response.status}')
else:
self.logger.debug(f'Successfully pushed to webhook {url}')
except asyncio.TimeoutError:
self.logger.warning(f'Timeout pushing to webhook {url}')
except Exception as e:
self.logger.warning(f'Error pushing to webhook {url}: {e}')

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,42 @@ 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')
if self.runtime_subprocess_on_windows is None: # only launch once
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 +289,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 +352,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

@@ -298,7 +298,7 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
"""Get llm models"""
llm_models = await self.ap.model_service.get_llm_models(include_secret=False)
llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
return handler.ActionResponse.success(
data={
'llm_models': llm_models,
@@ -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,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,208 @@
from __future__ import annotations
import openai
import typing
from . import chatcmpl
from .. import requester
import openai.types.chat.chat_completion as chat_completion
import re
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
class JieKouAIChatCompletions(chatcmpl.OpenAIChatCompletions):
"""接口 AI ChatCompletion API 请求器"""
client: openai.AsyncClient
default_config: dict[str, typing.Any] = {
'base_url': 'https://api.jiekou.ai/openai',
'timeout': 120,
}
is_think: bool = False
async def _make_msg(
self,
chat_completion: chat_completion.ChatCompletion,
remove_think: bool,
) -> provider_message.Message:
chatcmpl_message = chat_completion.choices[0].message.model_dump()
# print(chatcmpl_message.keys(), chatcmpl_message.values())
# 确保 role 字段存在且不为 None
if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None:
chatcmpl_message['role'] = 'assistant'
reasoning_content = chatcmpl_message['reasoning_content'] if 'reasoning_content' in chatcmpl_message else None
# deepseek的reasoner模型
chatcmpl_message['content'] = await self._process_thinking_content(
chatcmpl_message['content'], reasoning_content, remove_think
)
# 移除 reasoning_content 字段,避免传递给 Message
if 'reasoning_content' in chatcmpl_message:
del chatcmpl_message['reasoning_content']
message = provider_message.Message(**chatcmpl_message)
return message
async def _process_thinking_content(
self,
content: str,
reasoning_content: str = None,
remove_think: bool = False,
) -> tuple[str, str]:
"""处理思维链内容
Args:
content: 原始内容
reasoning_content: reasoning_content 字段内容
remove_think: 是否移除思维链
Returns:
处理后的内容
"""
if remove_think:
content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)
else:
if reasoning_content is not None:
content = '<think>\n' + reasoning_content + '\n</think>\n' + content
return content
async def _make_msg_chunk(
self,
delta: dict[str, typing.Any],
idx: int,
) -> provider_message.MessageChunk:
# 处理流式chunk和完整响应的差异
# print(chat_completion.choices[0])
# 确保 role 字段存在且不为 None
if 'role' not in delta or delta['role'] is None:
delta['role'] = 'assistant'
reasoning_content = delta['reasoning_content'] if 'reasoning_content' in delta else None
delta['content'] = '' if delta['content'] is None else delta['content']
# print(reasoning_content)
# deepseek的reasoner模型
if reasoning_content is not None:
delta['content'] += reasoning_content
message = provider_message.MessageChunk(**delta)
return message
async def _closure_stream(
self,
query: pipeline_query.Query,
req_messages: list[dict],
use_model: requester.RuntimeLLMModel,
use_funcs: list[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]:
self.client.api_key = use_model.token_mgr.get_token()
args = {}
args['model'] = use_model.model_entity.name
if use_funcs:
tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)
if tools:
args['tools'] = tools
# 设置此次请求中的messages
messages = req_messages.copy()
# 检查vision
for msg in messages:
if 'content' in msg and isinstance(msg['content'], list):
for me in msg['content']:
if me['type'] == 'image_base64':
me['image_url'] = {'url': me['image_base64']}
me['type'] = 'image_url'
del me['image_base64']
args['messages'] = messages
args['stream'] = True
# tool_calls_map: dict[str, provider_message.ToolCall] = {}
chunk_idx = 0
thinking_started = False
thinking_ended = False
role = 'assistant' # 默认角色
async for chunk in self._req_stream(args, extra_body=extra_args):
# 解析 chunk 数据
if hasattr(chunk, 'choices') and chunk.choices:
choice = chunk.choices[0]
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
finish_reason = getattr(choice, 'finish_reason', None)
else:
delta = {}
finish_reason = None
# 从第一个 chunk 获取 role后续使用这个 role
if 'role' in delta and delta['role']:
role = delta['role']
# 获取增量内容
delta_content = delta.get('content', '')
# reasoning_content = delta.get('reasoning_content', '')
if remove_think:
if delta['content'] is not None:
if '<think>' in delta['content'] and not thinking_started and not thinking_ended:
thinking_started = True
continue
elif delta['content'] == r'</think>' and not thinking_ended:
thinking_ended = True
continue
elif thinking_ended and delta['content'] == '\n\n' and thinking_started:
thinking_started = False
continue
elif thinking_started and not thinking_ended:
continue
# delta_tool_calls = None
if delta.get('tool_calls'):
for tool_call in delta['tool_calls']:
if tool_call['id'] and tool_call['function']['name']:
tool_id = tool_call['id']
tool_name = tool_call['function']['name']
if tool_call['id'] is None:
tool_call['id'] = tool_id
if tool_call['function']['name'] is None:
tool_call['function']['name'] = tool_name
if tool_call['function']['arguments'] is None:
tool_call['function']['arguments'] = ''
if tool_call['type'] is None:
tool_call['type'] = 'function'
# 跳过空的第一个 chunk只有 role 没有内容)
if chunk_idx == 0 and not delta_content and not delta.get('tool_calls'):
chunk_idx += 1
continue
# 构建 MessageChunk - 只包含增量内容
chunk_data = {
'role': role,
'content': delta_content if delta_content else None,
'tool_calls': delta.get('tool_calls'),
'is_final': bool(finish_reason),
}
# 移除 None 值
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
yield provider_message.MessageChunk(**chunk_data)
chunk_idx += 1

View File

@@ -0,0 +1,38 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: jiekouai-chat-completions
label:
en_US: JieKou AI
zh_Hans: 接口 AI
icon: jiekouai.png
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.jiekou.ai/openai"
- name: args
label:
en_US: Args
zh_Hans: 附加参数
type: object
required: true
default: {}
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: int
required: true
default: 120
support_type:
- llm
- text-embedding
execution:
python:
path: ./jiekouaichatcmpl.py
attr: JieKouAIChatCompletions

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

@@ -14,6 +14,7 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from libs.dify_service_api.v1 import client, errors
@runner.runner_class('dify-service-api')
class DifyServiceAPIRunner(runner.RequestRunner):
"""Dify Service API 对话请求器"""
@@ -77,7 +78,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
tuple[str, list[str]]: 纯文本和图片的 Dify 服务图片 ID
"""
plain_text = ''
image_ids = []
file_ids = []
if isinstance(query.user_message.content, list):
for ce in query.user_message.content:
@@ -92,11 +93,24 @@ class DifyServiceAPIRunner(runner.RequestRunner):
f'{query.session.launcher_type.value}_{query.session.launcher_id}',
)
image_id = file_upload_resp['id']
image_ids.append(image_id)
file_ids.append(image_id)
# elif ce.type == "file_url":
# file_bytes = base64.b64decode(ce.file_url)
# file_upload_resp = await self.dify_client.upload_file(
# file_bytes,
# f'{query.session.launcher_type.value}_{query.session.launcher_id}',
# )
# file_id = file_upload_resp['id']
# file_ids.append(file_id)
elif isinstance(query.user_message.content, str):
plain_text = query.user_message.content
# plain_text = "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image." if file_ids and not plain_text else plain_text
# plain_text = "The user message type cannot be parsed." if not file_ids and not plain_text else plain_text
# plain_text = plain_text if plain_text else "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image."
# print(self.pipeline_config['ai'])
plain_text = plain_text if plain_text else self.pipeline_config['ai']['dify-service-api']['base-prompt']
return plain_text, image_ids
return plain_text, file_ids
async def _chat_messages(
self, query: pipeline_query.Query
@@ -110,7 +124,6 @@ class DifyServiceAPIRunner(runner.RequestRunner):
files = [
{
'type': 'image',
'transfer_method': 'local_file',
'upload_file_id': image_id,
}
for image_id in image_ids

View File

@@ -40,10 +40,14 @@ class LocalAgentRunner(runner.RequestRunner):
"""运行请求"""
pending_tool_calls = []
kb_uuid = query.pipeline_config['ai']['local-agent']['knowledge-base']
# Get knowledge bases list (new field)
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
if kb_uuid == '__none__':
kb_uuid = None
# Fallback to old field for backward compatibility
if not kb_uuids:
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
if old_kb_uuid and old_kb_uuid != '__none__':
kb_uuids = [old_kb_uuid]
user_message = copy.deepcopy(query.user_message)
@@ -57,21 +61,28 @@ class LocalAgentRunner(runner.RequestRunner):
user_message_text += ce.text
break
if kb_uuid and user_message_text:
if kb_uuids and user_message_text:
# only support text for now
all_results = []
# Retrieve from each knowledge base
for kb_uuid in kb_uuids:
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
if not kb:
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found')
raise ValueError(f'Knowledge base {kb_uuid} not found')
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
continue
result = await kb.retrieve(user_message_text, kb.knowledge_base_entity.top_k)
if result:
all_results.extend(result)
final_user_message_text = ''
if result:
if all_results:
rag_context = '\n\n'.join(
f'[{i + 1}] {entry.metadata.get("text", "")}' for i, entry in enumerate(result)
f'[{i + 1}] {entry.metadata.get("text", "")}' for i, entry in enumerate(all_results)
)
final_user_message_text = rag_combined_prompt_template.format(
rag_context=rag_context, user_message=user_message_text

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.5.0'
required_database_version = 8
required_database_version = 11
"""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.5.0"
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.10",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

@@ -45,7 +45,7 @@
"content": "You are a helpful assistant."
}
],
"knowledge-base": ""
"knowledge-bases": []
},
"dify-service-api": {
"base-url": "https://api.dify.ai/v1",

View File

@@ -80,16 +80,16 @@ stages:
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
type: prompt-editor
required: true
- name: knowledge-base
- name: knowledge-bases
label:
en_US: Knowledge Base
en_US: Knowledge Bases
zh_Hans: 知识库
description:
en_US: Configure the knowledge base to use for the agent, if not selected, the agent will directly use the LLM to reply
en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply
zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
type: knowledge-base-selector
type: knowledge-base-multi-selector
required: false
default: ''
default: []
- name: tbox-app-api
label:
en_US: Tbox App API
@@ -124,6 +124,16 @@ stages:
zh_Hans: 基础 URL
type: string
required: true
- name: base-prompt
label:
en_US: Base PROMPT
zh_Hans: 基础提示词
description:
en_US: When Dify receives a message with empty input (only images), it will pass this default prompt into it.
zh_Hans: 当 Dify 接收到输入文字为空(仅图片)的消息时,传入该默认提示词
type: string
required: true
default: "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image."
- name: app-type
label:
en_US: App Type

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

@@ -35,7 +35,7 @@
width: 4rem;
height: 4rem;
margin: 0.2rem;
/* border-radius: 50%; */
border-radius: 8%;
}
.basicInfoContainer {

View File

@@ -491,7 +491,7 @@ export default function BotForm({
<img
src={adapterIconList[form.watch('adapter')]}
alt="adapter icon"
className="w-12 h-12"
className="w-12 h-12 rounded-[8%]"
/>
<div className="flex flex-col gap-1">
<div className="font-medium">

View File

@@ -0,0 +1,678 @@
'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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
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 Webhook {
id: number;
name: string;
url: string;
description: string;
enabled: boolean;
created_at: string;
}
interface ApiIntegrationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function ApiIntegrationDialog({
open,
onOpenChange,
}: ApiIntegrationDialogProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('apikeys');
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
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);
// Webhook state
const [showCreateWebhookDialog, setShowCreateWebhookDialog] = useState(false);
const [newWebhookName, setNewWebhookName] = useState('');
const [newWebhookUrl, setNewWebhookUrl] = useState('');
const [newWebhookDescription, setNewWebhookDescription] = useState('');
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
// 清理 body 样式,防止对话框关闭后页面无法交互
useEffect(() => {
if (!deleteKeyId && !deleteWebhookId) {
const cleanup = () => {
document.body.style.removeProperty('pointer-events');
};
cleanup();
const timer = setTimeout(cleanup, 100);
return () => clearTimeout(timer);
}
}, [deleteKeyId, deleteWebhookId]);
useEffect(() => {
if (open) {
loadApiKeys();
loadWebhooks();
}
}, [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)}`;
};
// Webhook methods
const loadWebhooks = async () => {
setLoading(true);
try {
const response = (await backendClient.get('/api/v1/webhooks')) as {
webhooks: Webhook[];
};
setWebhooks(response.webhooks || []);
} catch (error) {
toast.error(`Failed to load webhooks: ${error}`);
} finally {
setLoading(false);
}
};
const handleCreateWebhook = async () => {
if (!newWebhookName.trim()) {
toast.error(t('common.webhookNameRequired'));
return;
}
if (!newWebhookUrl.trim()) {
toast.error(t('common.webhookUrlRequired'));
return;
}
try {
await backendClient.post('/api/v1/webhooks', {
name: newWebhookName,
url: newWebhookUrl,
description: newWebhookDescription,
enabled: newWebhookEnabled,
});
toast.success(t('common.webhookCreated'));
setNewWebhookName('');
setNewWebhookUrl('');
setNewWebhookDescription('');
setNewWebhookEnabled(true);
setShowCreateWebhookDialog(false);
loadWebhooks();
} catch (error) {
toast.error(`Failed to create webhook: ${error}`);
}
};
const handleDeleteWebhook = async (webhookId: number) => {
try {
await backendClient.delete(`/api/v1/webhooks/${webhookId}`);
toast.success(t('common.webhookDeleted'));
loadWebhooks();
setDeleteWebhookId(null);
} catch (error) {
toast.error(`Failed to delete webhook: ${error}`);
}
};
const handleToggleWebhook = async (webhook: Webhook) => {
try {
await backendClient.put(`/api/v1/webhooks/${webhook.id}`, {
enabled: !webhook.enabled,
});
loadWebhooks();
} catch (error) {
toast.error(`Failed to update webhook: ${error}`);
}
};
return (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
// 如果删除确认框是打开的,不允许关闭主对话框
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
return;
}
onOpenChange(newOpen);
}}
>
<DialogContent className="sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
{t('common.apiKeys')}
</TabsTrigger>
<TabsTrigger
className="px-5 py-4 cursor-pointer"
value="webhooks"
>
{t('common.webhooks')}
</TabsTrigger>
</TabsList>
{/* API Keys Tab */}
<TabsContent value="apikeys" className="space-y-4">
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.apiKeyHint')}
</div>
<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>
)}
</TabsContent>
{/* Webhooks Tab */}
<TabsContent value="webhooks" className="space-y-4">
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.webhookHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.webhookUrl')}</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell>
<div>
<div className="font-medium">{webhook.name}</div>
{webhook.description && (
<div className="text-sm text-muted-foreground">
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded break-all">
{webhook.url}
</code>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() =>
handleToggleWebhook(webhook)
}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
<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>
{/* Create Webhook Dialog */}
<Dialog
open={showCreateWebhookDialog}
onOpenChange={setShowCreateWebhookDialog}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.createWebhook')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">{t('common.name')}</label>
<Input
value={newWebhookName}
onChange={(e) => setNewWebhookName(e.target.value)}
placeholder={t('common.webhookName')}
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('common.webhookUrl')}
</label>
<Input
value={newWebhookUrl}
onChange={(e) => setNewWebhookUrl(e.target.value)}
placeholder="https://example.com/webhook"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('common.description')}
</label>
<Input
value={newWebhookDescription}
onChange={(e) => setNewWebhookDescription(e.target.value)}
placeholder={t('common.description')}
className="mt-1"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={newWebhookEnabled}
onCheckedChange={setNewWebhookEnabled}
/>
<label className="text-sm font-medium">
{t('common.webhookEnabled')}
</label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateWebhookDialog(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleCreateWebhook}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete API Key Confirmation 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>
{/* Delete Webhook Confirmation Dialog */}
<AlertDialog open={!!deleteWebhookId}>
<AlertDialogPortal>
<AlertDialogOverlay
className="z-[60]"
onClick={() => setDeleteWebhookId(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={() => setDeleteWebhookId(null)}
>
<AlertDialogHeader>
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('common.webhookDeleteConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteWebhookId(null)}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
deleteWebhookId && handleDeleteWebhook(deleteWebhookId)
}
>
{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,12 @@ export default function DynamicFormComponent({
case 'knowledge-base-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-multi-selector':
fieldSchema = z.array(z.string());
break;
case 'bot-selector':
fieldSchema = z.string();
break;
case 'prompt-editor':
fieldSchema = z.array(
z.object({
@@ -97,9 +108,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 +138,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 +177,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,65 @@ import {
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X } from 'lucide-react';
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 [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
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,20 +95,36 @@ 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]);
useEffect(() => {
if (config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR) {
if (
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
config.type === DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR
) {
httpClient
.getKnowledgeBases()
.then((resp) => {
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 +143,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 +350,146 @@ export default function DynamicFormItemComponent({
</Select>
);
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
return (
<>
<div className="space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
{field.value.map((kbId: string) => {
const kb = knowledgeBases.find((base) => base.uuid === kbId);
if (!kb) return null;
return (
<div
key={kbId}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex-1">
<div className="font-medium">{kb.name}</div>
{kb.description && (
<div className="text-sm text-muted-foreground">
{kb.description}
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newValue = field.value.filter(
(id: string) => id !== kbId,
);
field.onChange(newValue);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
) : (
<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('knowledge.noKnowledgeBaseSelected')}
</p>
</div>
)}
</div>
<Button
type="button"
onClick={() => {
setTempSelectedKBIds(field.value || []);
setKbDialogOpen(true);
}}
variant="outline"
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
{t('knowledge.addKnowledgeBase')}
</Button>
{/* Knowledge Base Selection Dialog */}
<Dialog open={kbDialogOpen} onOpenChange={setKbDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('knowledge.selectKnowledgeBases')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{knowledgeBases.map((base) => {
const isSelected = tempSelectedKBIds.includes(
base.uuid ?? '',
);
return (
<div
key={base.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
const kbId = base.uuid ?? '';
setTempSelectedKBIds((prev) =>
prev.includes(kbId)
? prev.filter((id) => id !== kbId)
: [...prev, kbId],
);
}}
>
<Checkbox
checked={isSelected}
aria-label={`Select ${base.name}`}
/>
<div className="flex-1">
<div className="font-medium">{base.name}</div>
{base.description && (
<div className="text-sm text-muted-foreground">
{base.description}
</div>
)}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setKbDialogOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
field.onChange(tempSelectedKBIds);
setKbDialogOpen(false);
}}
>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
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 +572,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 ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
// 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.apiIntegration')}
/>
<Popover
open={popoverOpen}
onOpenChange={(open) => {
@@ -326,6 +345,10 @@ export default function HomeSidebar({
open={passwordChangeOpen}
onOpenChange={setPasswordChangeOpen}
/>
<ApiIntegrationDialog
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

@@ -35,7 +35,7 @@
width: 3.8rem;
height: 3.8rem;
margin: 0.2rem;
border-radius: 50%;
border-radius: 8%;
}
.basicInfoContainer {

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

@@ -36,7 +36,7 @@
width: 3.8rem;
height: 3.8rem;
margin: 0.2rem;
border-radius: 50%;
border-radius: 8%;
}
.basicInfoContainer {

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,453 @@
'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-black dark:text-white" />
<span className="text-xs text-black dark: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' && (
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark: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>
);
})}
</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

@@ -346,6 +346,32 @@ export default function PipelineFormComponent({
}
};
const handleCopy = () => {
if (pipelineId) {
let newPipelineName = '';
httpClient
.getPipeline(pipelineId)
.then((resp) => {
const originalPipeline = resp.pipeline;
newPipelineName = `${originalPipeline.name}${t('pipelines.copySuffix')}`;
const newPipeline: Pipeline = {
name: newPipelineName,
description: originalPipeline.description,
config: originalPipeline.config,
};
return httpClient.createPipeline(newPipeline);
})
.then(() => {
onFinish();
toast.success(`${t('common.copySuccess')}: ${newPipelineName}`);
onCancel();
})
.catch((err) => {
toast.error(t('pipelines.createError') + err.message);
});
}
};
return (
<>
<div className="!max-w-[70vw] max-w-6xl h-full p-0 flex flex-col bg-white dark:bg-black">
@@ -478,6 +504,18 @@ export default function PipelineFormComponent({
{t('pipelines.defaultPipelineCannotDelete')}
</div>
)}
{isEditMode && (
<Button
type="button"
variant="default"
onClick={handleCopy}
className="bg-green-600 hover:bg-green-700 text-white"
>
{t('common.copy')}
</Button>
)}
<Button type="submit" form="pipeline-form">
{isEditMode ? t('common.save') : t('common.submit')}
</Button>

View File

@@ -46,7 +46,7 @@ export default function PluginCardComponent({
<img
src={httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
alt="plugin icon"
className="w-16 h-16"
className="w-16 h-16 rounded-[8%]"
/>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">

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 rounded-[8%]"
/>
<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,23 @@ 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',
KNOWLEDGE_BASE_MULTI_SELECTOR = 'knowledge-base-multi-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

@@ -39,6 +39,7 @@ const enUS = {
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
addRound: 'Add Round',
copy: 'Copy',
copySuccess: 'Copy Successfully',
test: 'Test',
forgotPassword: 'Forgot Password?',
@@ -58,6 +59,39 @@ const enUS = {
changePasswordSuccess: 'Password changed successfully',
changePasswordFailed:
'Failed to change password, please check your current password',
apiIntegration: 'API Integration',
apiKeys: 'API Keys',
manageApiIntegration: 'Manage API Integration',
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',
webhooks: 'Webhooks',
createWebhook: 'Create Webhook',
webhookName: 'Webhook Name',
webhookUrl: 'Webhook URL',
webhookDescription: 'Webhook Description',
webhookEnabled: 'Enabled',
webhookCreated: 'Webhook created successfully',
webhookDeleted: 'Webhook deleted successfully',
webhookDeleteConfirm: 'Are you sure you want to delete this webhook?',
webhookNameRequired: 'Webhook name is required',
webhookUrlRequired: 'Webhook URL is required',
noWebhooks: 'No webhooks configured',
webhookHint:
'Webhooks allow LangBot to push person and group message events to external systems',
actions: 'Actions',
apiKeyCreatedMessage: 'Please copy this API key.',
},
notFound: {
title: 'Page not found',
@@ -139,6 +173,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 +188,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 +277,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',
@@ -414,11 +457,29 @@ const enUS = {
createError: 'Creation failed: ',
saveSuccess: 'Saved successfully',
saveError: 'Save failed: ',
copySuffix: ' Copy',
deleteConfirmation:
'Are you sure you want to delete this pipeline? Bots bound to this pipeline will not work.',
defaultPipelineCannotDelete: 'Default pipeline cannot be deleted',
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
extensions: {
title: 'Extensions',
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',
@@ -445,6 +506,9 @@ const enUS = {
createKnowledgeBase: 'Create Knowledge Base',
editKnowledgeBase: 'Edit Knowledge Base',
selectKnowledgeBase: 'Select Knowledge Base',
selectKnowledgeBases: 'Select Knowledge Bases',
addKnowledgeBase: 'Add Knowledge Base',
noKnowledgeBaseSelected: 'No knowledge bases selected',
empty: 'Empty',
editDocument: 'Documents',
description: 'Configuring knowledge bases for improved LLM responses',
@@ -483,6 +547,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',

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