mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-08 14:56:03 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef24d8c4b | ||
|
|
7a10dfdac1 | ||
|
|
02892e57bb | ||
|
|
524c56a12b | ||
|
|
0e0d7cc7b8 | ||
|
|
1f877e2b8e | ||
|
|
8cd50fbdb4 | ||
|
|
42421d171e | ||
|
|
32215e9a3f | ||
|
|
dd1c7ffc39 | ||
|
|
b59bf62da5 | ||
|
|
f4c32f7b30 | ||
|
|
8844a5304d | ||
|
|
922ddd47f4 | ||
|
|
8c8702c6c9 | ||
|
|
70147fcf5e | ||
|
|
b3ee16e876 | ||
|
|
8d7976190d | ||
|
|
3edae3e678 | ||
|
|
dd2254203c | ||
|
|
f8658e2d77 | ||
|
|
021c3bbb94 | ||
|
|
0a64a96f65 | ||
|
|
48576dc46d | ||
|
|
12de0343b4 | ||
|
|
fcd34a9ff3 | ||
|
|
0dcf904d81 | ||
|
|
4fe92d8ece | ||
|
|
c893ffc177 | ||
|
|
a076ce5756 | ||
|
|
af82227dff | ||
|
|
8f2b177145 | ||
|
|
9a997fbcb0 | ||
|
|
17070471f7 | ||
|
|
cb48221ed3 | ||
|
|
68eb0290e0 | ||
|
|
61bc6a1dc2 | ||
|
|
4a84bf2355 | ||
|
|
2c2a89d9db | ||
|
|
c91e2f0efe | ||
|
|
411d082d2a | ||
|
|
d4e08a1765 | ||
|
|
b529d07479 | ||
|
|
d44df75e5c | ||
|
|
b74e07b608 | ||
|
|
4a868afecd | ||
|
|
1cb9560663 | ||
|
|
8f878673ae | ||
|
|
74a5e37892 | ||
|
|
76a69ecc7e | ||
|
|
f06e3d3efa | ||
|
|
973e7bae42 | ||
|
|
94aa175c1a |
92
.github/workflows/build-docker-image.yml
vendored
92
.github/workflows/build-docker-image.yml
vendored
@@ -1,15 +1,17 @@
|
|||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
on:
|
on:
|
||||||
#防止fork乱用action设置只能手动触发构建
|
|
||||||
workflow_dispatch:
|
|
||||||
## 发布release的时候会自动构建
|
## 发布release的时候会自动构建
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
jobs:
|
jobs:
|
||||||
publish-docker-image:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Build image
|
name: Prepare build metadata
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.check_version.outputs.version }}
|
||||||
|
is_prerelease: ${{ github.event.release.prerelease }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -37,9 +39,81 @@ jobs:
|
|||||||
echo $GITHUB_REF
|
echo $GITHUB_REF
|
||||||
echo ::set-output name=version::${GITHUB_REF}
|
echo ::set-output name=version::${GITHUB_REF}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
build-images:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: prepare
|
||||||
|
name: Build ${{ matrix.platform }} image
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
platform: [linux/amd64, linux/arm64]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set platform tag
|
||||||
|
id: platform_tag
|
||||||
|
run: |
|
||||||
|
# Convert platform to tag suffix (e.g., linux/amd64 -> amd64)
|
||||||
|
PLATFORM_TAG=$(echo ${{ matrix.platform }} | sed 's/linux\///g')
|
||||||
|
echo ::set-output name=tag::${PLATFORM_TAG}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: Login to Registry
|
- name: Login to Registry
|
||||||
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
|
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Create Buildx
|
|
||||||
run: docker buildx create --name mybuilder --use
|
- name: Build and cache
|
||||||
- name: Build # image name: rockchin/langbot:<VERSION>
|
run: |
|
||||||
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
|
docker buildx build \
|
||||||
|
--platform ${{ matrix.platform }} \
|
||||||
|
--cache-to type=registry,ref=rockchin/langbot:cache-${{ steps.platform_tag.outputs.tag }},mode=max \
|
||||||
|
--cache-from type=registry,ref=rockchin/langbot:cache-${{ steps.platform_tag.outputs.tag }} \
|
||||||
|
-t rockchin/langbot:${{ needs.prepare.outputs.version }} \
|
||||||
|
.
|
||||||
|
|
||||||
|
push-multiarch:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [prepare, build-images]
|
||||||
|
name: Build and push multi-arch images
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to Registry
|
||||||
|
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push for Release
|
||||||
|
if: ${{ needs.prepare.outputs.is_prerelease == 'false' }}
|
||||||
|
run: |
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--cache-from type=registry,ref=rockchin/langbot:cache-amd64 \
|
||||||
|
--cache-from type=registry,ref=rockchin/langbot:cache-arm64 \
|
||||||
|
-t rockchin/langbot:${{ needs.prepare.outputs.version }} \
|
||||||
|
-t rockchin/langbot:latest \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Build and push for Pre-release
|
||||||
|
if: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||||
|
run: |
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--cache-from type=registry,ref=rockchin/langbot:cache-amd64 \
|
||||||
|
--cache-from type=registry,ref=rockchin/langbot:cache-arm64 \
|
||||||
|
-t rockchin/langbot:${{ needs.prepare.outputs.version }} \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|||||||
108
.github/workflows/test-dev-image.yaml
vendored
Normal file
108
.github/workflows/test-dev-image.yaml
vendored
Normal 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
|
||||||
86
AGENTS.md
Normal file
86
AGENTS.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 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.
|
||||||
|
- If you were asked to make a commit, please follow the commit message format:
|
||||||
|
- format: <type>(<scope>): <subject>
|
||||||
|
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
|
||||||
|
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
|
||||||
|
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
|
||||||
|
|
||||||
|
## Some Principles
|
||||||
|
|
||||||
|
- Keep it simple, stupid.
|
||||||
|
- Entities should not be multiplied unnecessarily
|
||||||
|
- 八荣八耻
|
||||||
|
|
||||||
|
以瞎猜接口为耻,以认真查询为荣。
|
||||||
|
以模糊执行为耻,以寻求确认为荣。
|
||||||
|
以臆想业务为耻,以人类确认为荣。
|
||||||
|
以创造接口为耻,以复用现有为荣。
|
||||||
|
以跳过验证为耻,以主动测试为荣。
|
||||||
|
以破坏架构为耻,以遵循规范为荣。
|
||||||
|
以假装理解为耻,以诚实无知为荣。
|
||||||
|
以盲目修改为耻,以谨慎重构为荣。
|
||||||
@@ -147,3 +147,9 @@ docker compose up -d
|
|||||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||||
</a>
|
</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.
|
||||||
|
-->
|
||||||
|
|||||||
@@ -16,7 +16,3 @@ spec:
|
|||||||
LLMAPIRequester:
|
LLMAPIRequester:
|
||||||
fromDirs:
|
fromDirs:
|
||||||
- path: pkg/provider/modelmgr/requesters/
|
- path: pkg/provider/modelmgr/requesters/
|
||||||
Plugin:
|
|
||||||
fromDirs:
|
|
||||||
- path: plugins/
|
|
||||||
maxDepth: 2
|
|
||||||
|
|||||||
291
docs/API_KEY_AUTH.md
Normal file
291
docs/API_KEY_AUTH.md
Normal 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
|
||||||
|
|
||||||
1944
docs/service-api-openapi.json
Normal file
1944
docs/service-api-openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -178,7 +178,7 @@ class AsyncCozeAPIClient:
|
|||||||
elif chunk.startswith("data:"):
|
elif chunk.startswith("data:"):
|
||||||
chunk_data = chunk.replace("data:", "", 1).strip()
|
chunk_data = chunk.replace("data:", "", 1).strip()
|
||||||
else:
|
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:
|
except asyncio.TimeoutError:
|
||||||
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)")
|
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import typing
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from .errors import DifyAPIError
|
from .errors import DifyAPIError
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
class AsyncDifyServiceClient:
|
class AsyncDifyServiceClient:
|
||||||
@@ -109,7 +111,23 @@ class AsyncDifyServiceClient:
|
|||||||
user: str,
|
user: str,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
) -> str:
|
) -> 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(
|
async with httpx.AsyncClient(
|
||||||
base_url=self.base_url,
|
base_url=self.base_url,
|
||||||
trust_env=True,
|
trust_env=True,
|
||||||
@@ -121,6 +139,8 @@ class AsyncDifyServiceClient:
|
|||||||
headers={'Authorization': f'Bearer {self.api_key}'},
|
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||||
files={
|
files={
|
||||||
'file': file,
|
'file': file,
|
||||||
|
},
|
||||||
|
data={
|
||||||
'user': (None, user),
|
'user': (None, user),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -188,12 +188,80 @@ class DingTalkClient:
|
|||||||
|
|
||||||
if incoming_message.message_type == 'richText':
|
if incoming_message.message_type == 'richText':
|
||||||
data = incoming_message.rich_text_content.to_dict()
|
data = incoming_message.rich_text_content.to_dict()
|
||||||
|
|
||||||
|
# 使用统一的结构化数据格式,保持顺序
|
||||||
|
rich_content = {
|
||||||
|
'Type': 'richText',
|
||||||
|
'Elements': [], # 按顺序存储所有元素
|
||||||
|
'SimpleContent': '', # 兼容字段:纯文本内容
|
||||||
|
'SimplePicture': '' # 兼容字段:第一张图片
|
||||||
|
}
|
||||||
|
|
||||||
|
# 先收集所有文本和图片占位符
|
||||||
|
text_elements = []
|
||||||
|
image_placeholders = []
|
||||||
|
|
||||||
|
# 解析富文本内容,保持原始顺序
|
||||||
for item in data['richText']:
|
for item in data['richText']:
|
||||||
if 'text' in item:
|
|
||||||
message_data['Content'] = item['text']
|
# 处理文本内容
|
||||||
if incoming_message.get_image_list()[0]:
|
if 'text' in item and item['text'] != "\n":
|
||||||
message_data['Picture'] = await self.download_image(incoming_message.get_image_list()[0])
|
element = {
|
||||||
message_data['Type'] = 'text'
|
'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':
|
elif incoming_message.message_type == 'text':
|
||||||
message_data['Content'] = incoming_message.get_text_list()[0]
|
message_data['Content'] = incoming_message.get_text_list()[0]
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ class DingTalkEvent(dict):
|
|||||||
def content(self):
|
def content(self):
|
||||||
return self.get('Content', '')
|
return self.get('Content', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rich_content(self):
|
||||||
|
return self.get('Rich_Content', '')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def incoming_message(self) -> Optional['dingtalk_stream.chatbot.ChatbotMessage']:
|
def incoming_message(self) -> Optional['dingtalk_stream.chatbot.ChatbotMessage']:
|
||||||
return self.get('IncomingMessage')
|
return self.get('IncomingMessage')
|
||||||
|
|||||||
@@ -463,7 +463,17 @@ class WecomBotClient:
|
|||||||
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
||||||
message_data['picurl'] = base64 # 只保留第一个 image
|
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', '')
|
message_data['msgid'] = msg_json.get('msgid', '')
|
||||||
|
|
||||||
if msg_json.get('aibotid'):
|
if msg_json.get('aibotid'):
|
||||||
|
|||||||
@@ -22,7 +22,21 @@ class WecomBotEvent(dict):
|
|||||||
"""
|
"""
|
||||||
用户id
|
用户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
|
@property
|
||||||
def content(self) -> str:
|
def content(self) -> str:
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ from quart.typing import RouteCallable
|
|||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
|
|
||||||
|
# Maximum file upload size limit (10MB)
|
||||||
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||||
|
|
||||||
|
|
||||||
preregistered_groups: list[type[RouterGroup]] = []
|
preregistered_groups: list[type[RouterGroup]] = []
|
||||||
"""Pre-registered list of RouterGroup"""
|
"""Pre-registered list of RouterGroup"""
|
||||||
@@ -31,6 +34,8 @@ class AuthType(enum.Enum):
|
|||||||
|
|
||||||
NONE = 'none'
|
NONE = 'none'
|
||||||
USER_TOKEN = 'user-token'
|
USER_TOKEN = 'user-token'
|
||||||
|
API_KEY = 'api-key'
|
||||||
|
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
|
||||||
|
|
||||||
|
|
||||||
class RouterGroup(abc.ABC):
|
class RouterGroup(abc.ABC):
|
||||||
@@ -84,6 +89,63 @@ class RouterGroup(abc.ABC):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.http_status(401, -1, str(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:
|
try:
|
||||||
return await f(*args, **kwargs)
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
43
pkg/api/http/controller/groups/apikeys.py
Normal file
43
pkg/api/http/controller/groups/apikeys.py
Normal 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()
|
||||||
@@ -31,19 +31,41 @@ class FilesRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> quart.Response:
|
async def _() -> quart.Response:
|
||||||
request = quart.request
|
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'
|
# 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)
|
assert isinstance(file, quart.datastructures.FileStorage)
|
||||||
|
|
||||||
file_bytes = await asyncio.to_thread(file.stream.read)
|
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 '\'
|
# check if file name contains '/' or '\'
|
||||||
if '/' in file_name or '\\' in file_name:
|
if '/' in file_name or '\\' in file_name:
|
||||||
return self.fail(400, 'File name contains invalid characters')
|
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
|
# save file to storage
|
||||||
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||||
return self.success(
|
return self.success(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from ... import group
|
|||||||
@group.group_class('pipelines', '/api/v1/pipelines')
|
@group.group_class('pipelines', '/api/v1/pipelines')
|
||||||
class PipelinesRouterGroup(group.RouterGroup):
|
class PipelinesRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
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:
|
async def _() -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
sort_by = quart.request.args.get('sort_by', 'created_at')
|
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})
|
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:
|
async def _() -> str:
|
||||||
return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()})
|
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:
|
async def _(pipeline_uuid: str) -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
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)
|
await self.ap.pipeline_service.delete_pipeline(pipeline_uuid)
|
||||||
|
|
||||||
return self.success()
|
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()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from ... import group
|
|||||||
@group.group_class('bots', '/api/v1/platform/bots')
|
@group.group_class('bots', '/api/v1/platform/bots')
|
||||||
class BotsRouterGroup(group.RouterGroup):
|
class BotsRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
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:
|
async def _() -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
return self.success(data={'bots': await self.ap.bot_service.get_bots()})
|
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)
|
bot_uuid = await self.ap.bot_service.create_bot(json_data)
|
||||||
return self.success(data={'uuid': bot_uuid})
|
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:
|
async def _(bot_uuid: str) -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
bot = await self.ap.bot_service.get_bot(bot_uuid)
|
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)
|
await self.ap.bot_service.delete_bot(bot_uuid)
|
||||||
return self.success()
|
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:
|
async def _(bot_uuid: str) -> str:
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
from_index = json_data.get('from_index', -1)
|
from_index = json_data.get('from_index', -1)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import base64
|
|||||||
import quart
|
import quart
|
||||||
import re
|
import re
|
||||||
import httpx
|
import httpx
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
|
||||||
from .....core import taskmgr
|
from .....core import taskmgr
|
||||||
from .. import group
|
from .. import group
|
||||||
@@ -269,3 +271,39 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return self.success(data={'task_id': wrapper.id})
|
return self.success(data={'task_id': wrapper.id})
|
||||||
|
|
||||||
|
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""Upload a file for plugin configuration"""
|
||||||
|
file = (await quart.request.files).get('file')
|
||||||
|
if file is None:
|
||||||
|
return self.http_status(400, -1, 'file is required')
|
||||||
|
|
||||||
|
# Check file size (10MB limit)
|
||||||
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||||
|
file_bytes = file.read()
|
||||||
|
if len(file_bytes) > MAX_FILE_SIZE:
|
||||||
|
return self.http_status(400, -1, 'file size exceeds 10MB limit')
|
||||||
|
|
||||||
|
# Generate unique file key with original extension
|
||||||
|
original_filename = file.filename
|
||||||
|
_, ext = os.path.splitext(original_filename)
|
||||||
|
file_key = f'plugin_config_{uuid.uuid4().hex}{ext}'
|
||||||
|
|
||||||
|
# Save file using storage manager
|
||||||
|
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||||
|
|
||||||
|
return self.success(data={'file_key': file_key})
|
||||||
|
|
||||||
|
@self.route('/config-files/<file_key>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(file_key: str) -> str:
|
||||||
|
"""Delete a plugin configuration file"""
|
||||||
|
# Only allow deletion of files with plugin_config_ prefix for security
|
||||||
|
if not file_key.startswith('plugin_config_'):
|
||||||
|
return self.http_status(400, -1, 'invalid file key')
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.storage_mgr.storage_provider.delete(file_key)
|
||||||
|
return self.success(data={'deleted': True})
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'failed to delete file: {str(e)}')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from ... import group
|
|||||||
@group.group_class('models/llm', '/api/v1/provider/models/llm')
|
@group.group_class('models/llm', '/api/v1/provider/models/llm')
|
||||||
class LLMModelsRouterGroup(group.RouterGroup):
|
class LLMModelsRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
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:
|
async def _() -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})
|
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})
|
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:
|
async def _(model_uuid: str) -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
model = await self.ap.llm_model_service.get_llm_model(model_uuid)
|
model = await self.ap.llm_model_service.get_llm_model(model_uuid)
|
||||||
@@ -37,7 +37,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success()
|
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:
|
async def _(model_uuid: str) -> str:
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
|||||||
@group.group_class('models/embedding', '/api/v1/provider/models/embedding')
|
@group.group_class('models/embedding', '/api/v1/provider/models/embedding')
|
||||||
class EmbeddingModelsRouterGroup(group.RouterGroup):
|
class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
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:
|
async def _() -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})
|
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})
|
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:
|
async def _(model_uuid: str) -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
model = await self.ap.embedding_models_service.get_embedding_model(model_uuid)
|
model = await self.ap.embedding_models_service.get_embedding_model(model_uuid)
|
||||||
@@ -80,7 +80,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success()
|
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:
|
async def _(model_uuid: str) -> str:
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
data={
|
data={
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'debug': constants.debug_mode,
|
'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': self.ap.instance_config.data.get('plugin', {}).get(
|
||||||
'enable_marketplace', True
|
'enable_marketplace', True
|
||||||
),
|
),
|
||||||
|
|||||||
49
pkg/api/http/controller/groups/webhooks.py
Normal file
49
pkg/api/http/controller/groups/webhooks.py
Normal 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()
|
||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
|
|
||||||
import quart
|
import quart
|
||||||
import quart_cors
|
import quart_cors
|
||||||
|
from werkzeug.exceptions import RequestEntityTooLarge
|
||||||
|
|
||||||
from ....core import app, entities as core_entities
|
from ....core import app, entities as core_entities
|
||||||
from ....utils import importutil
|
from ....utils import importutil
|
||||||
@@ -35,7 +36,20 @@ class HTTPController:
|
|||||||
self.quart_app = quart.Quart(__name__)
|
self.quart_app = quart.Quart(__name__)
|
||||||
quart_cors.cors(self.quart_app, allow_origin='*')
|
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:
|
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()
|
await self.register_routes()
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
|
|||||||
79
pkg/api/http/service/apikey.py
Normal file
79
pkg/api/http/service/apikey.py
Normal 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)
|
||||||
|
)
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
from ....provider.modelmgr import requester as model_requester
|
from ....provider.modelmgr import requester as model_requester
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
|
||||||
|
|
||||||
|
|
||||||
class LLMModelsService:
|
class LLMModelsService:
|
||||||
@@ -104,12 +105,18 @@ class LLMModelsService:
|
|||||||
else:
|
else:
|
||||||
runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data)
|
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(
|
await runtime_llm_model.requester.invoke_llm(
|
||||||
query=None,
|
query=None,
|
||||||
model=runtime_llm_model,
|
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=[],
|
funcs=[],
|
||||||
extra_args=model_data.get('extra_args', {}),
|
# extra_args=extra_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -136,3 +136,33 @@ class PipelineService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
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)
|
||||||
|
|||||||
81
pkg/api/http/service/webhook.py
Normal file
81
pkg/api/http/service/webhook.py
Normal 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]
|
||||||
@@ -59,14 +59,15 @@ class CommandManager:
|
|||||||
context: command_context.ExecuteContext,
|
context: command_context.ExecuteContext,
|
||||||
operator_list: list[operator.CommandOperator],
|
operator_list: list[operator.CommandOperator],
|
||||||
operator: operator.CommandOperator = None,
|
operator: operator.CommandOperator = None,
|
||||||
|
bound_plugins: list[str] | None = None,
|
||||||
) -> typing.AsyncGenerator[command_context.CommandReturn, 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:
|
for command in command_list:
|
||||||
if command.metadata.name == context.command:
|
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
|
yield ret
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -102,5 +103,8 @@ class CommandManager:
|
|||||||
|
|
||||||
ctx.shift()
|
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
|
yield ret
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import traceback
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from ..platform import botmgr as im_mgr
|
from ..platform import botmgr as im_mgr
|
||||||
|
from ..platform.webhook_pusher import WebhookPusher
|
||||||
from ..provider.session import sessionmgr as llm_session_mgr
|
from ..provider.session import sessionmgr as llm_session_mgr
|
||||||
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
||||||
from ..provider.tools import toolmgr as llm_tool_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 bot as bot_service
|
||||||
from ..api.http.service import knowledge as knowledge_service
|
from ..api.http.service import knowledge as knowledge_service
|
||||||
from ..api.http.service import mcp as mcp_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 ..discover import engine as discover_engine
|
||||||
from ..storage import mgr as storagemgr
|
from ..storage import mgr as storagemgr
|
||||||
from ..utils import logcache
|
from ..utils import logcache
|
||||||
@@ -44,6 +47,8 @@ class Application:
|
|||||||
|
|
||||||
platform_mgr: im_mgr.PlatformManager = None
|
platform_mgr: im_mgr.PlatformManager = None
|
||||||
|
|
||||||
|
webhook_pusher: WebhookPusher = None
|
||||||
|
|
||||||
cmd_mgr: cmdmgr.CommandManager = None
|
cmd_mgr: cmdmgr.CommandManager = None
|
||||||
|
|
||||||
sess_mgr: llm_session_mgr.SessionManager = None
|
sess_mgr: llm_session_mgr.SessionManager = None
|
||||||
@@ -122,6 +127,10 @@ class Application:
|
|||||||
|
|
||||||
mcp_service: mcp_service.MCPService = None
|
mcp_service: mcp_service.MCPService = None
|
||||||
|
|
||||||
|
apikey_service: apikey_service.ApiKeyService = None
|
||||||
|
|
||||||
|
webhook_service: webhook_service.WebhookService = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import shutil
|
|||||||
|
|
||||||
|
|
||||||
required_files = {
|
required_files = {
|
||||||
'plugins/__init__.py': 'templates/__init__.py',
|
|
||||||
'data/config.yaml': 'templates/config.yaml',
|
'data/config.yaml': 'templates/config.yaml',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ required_paths = [
|
|||||||
'data/metadata',
|
'data/metadata',
|
||||||
'data/logs',
|
'data/logs',
|
||||||
'data/labels',
|
'data/labels',
|
||||||
'plugins',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ...provider.modelmgr import modelmgr as llm_model_mgr
|
|||||||
from ...provider.tools import toolmgr as llm_tool_mgr
|
from ...provider.tools import toolmgr as llm_tool_mgr
|
||||||
from ...rag.knowledge import kbmgr as rag_mgr
|
from ...rag.knowledge import kbmgr as rag_mgr
|
||||||
from ...platform import botmgr as im_mgr
|
from ...platform import botmgr as im_mgr
|
||||||
|
from ...platform.webhook_pusher import WebhookPusher
|
||||||
from ...persistence import mgr as persistencemgr
|
from ...persistence import mgr as persistencemgr
|
||||||
from ...api.http.controller import main as http_controller
|
from ...api.http.controller import main as http_controller
|
||||||
from ...api.http.service import user as user_service
|
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 bot as bot_service
|
||||||
from ...api.http.service import knowledge as knowledge_service
|
from ...api.http.service import knowledge as knowledge_service
|
||||||
from ...api.http.service import mcp as mcp_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 ...discover import engine as discover_engine
|
||||||
from ...storage import mgr as storagemgr
|
from ...storage import mgr as storagemgr
|
||||||
from ...utils import logcache
|
from ...utils import logcache
|
||||||
@@ -92,6 +95,10 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
await im_mgr_inst.initialize()
|
await im_mgr_inst.initialize()
|
||||||
ap.platform_mgr = im_mgr_inst
|
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)
|
pipeline_mgr = pipelinemgr.PipelineManager(ap)
|
||||||
await pipeline_mgr.initialize()
|
await pipeline_mgr.initialize()
|
||||||
ap.pipeline_mgr = pipeline_mgr
|
ap.pipeline_mgr = pipeline_mgr
|
||||||
@@ -130,5 +137,11 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
mcp_service_inst = mcp_service.MCPService(ap)
|
mcp_service_inst = mcp_service.MCPService(ap)
|
||||||
ap.mcp_service = mcp_service_inst
|
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)
|
ctrl = controller.Controller(ap)
|
||||||
ap.ctrl = ctrl
|
ap.ctrl = ctrl
|
||||||
|
|||||||
@@ -1,11 +1,93 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .. import stage, app
|
from .. import stage, app
|
||||||
from ..bootutils import config
|
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')
|
@stage.stage_class('LoadConfigStage')
|
||||||
class LoadConfigStage(stage.BootingStage):
|
class LoadConfigStage(stage.BootingStage):
|
||||||
"""Load config file stage"""
|
"""Load config file stage"""
|
||||||
@@ -54,6 +136,10 @@ class LoadConfigStage(stage.BootingStage):
|
|||||||
ap.instance_config = await config.load_yaml_config(
|
ap.instance_config = await config.load_yaml_config(
|
||||||
'data/config.yaml', 'templates/config.yaml', completion=False
|
'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()
|
await ap.instance_config.dump_config()
|
||||||
|
|
||||||
ap.sensitive_meta = await config.load_json_config(
|
ap.sensitive_meta = await config.load_json_config(
|
||||||
|
|||||||
21
pkg/entity/persistence/apikey.py
Normal file
21
pkg/entity/persistence/apikey.py
Normal 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(),
|
||||||
|
)
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
|
from ...utils import constants
|
||||||
|
|
||||||
|
|
||||||
initial_metadata = [
|
initial_metadata = [
|
||||||
{
|
{
|
||||||
'key': 'database_version',
|
'key': 'database_version',
|
||||||
'value': '0',
|
'value': str(constants.required_database_version),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class LegacyPipeline(Base):
|
|||||||
is_default = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
is_default = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||||
stages = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
stages = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||||
config = 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):
|
class PipelineRunRecord(Base):
|
||||||
|
|||||||
22
pkg/entity/persistence/webhook.py
Normal file
22
pkg/entity/persistence/webhook.py
Normal 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(),
|
||||||
|
)
|
||||||
@@ -78,6 +78,8 @@ class PersistenceManager:
|
|||||||
|
|
||||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||||
|
|
||||||
|
await self.write_default_pipeline()
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
# create tables
|
# create tables
|
||||||
async with self.get_db_engine().connect() as conn:
|
async with self.get_db_engine().connect() as conn:
|
||||||
@@ -98,6 +100,7 @@ class PersistenceManager:
|
|||||||
if row is None:
|
if row is None:
|
||||||
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
|
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
|
||||||
|
|
||||||
|
async def write_default_pipeline(self):
|
||||||
# write default pipeline
|
# write default pipeline
|
||||||
result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))
|
result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))
|
||||||
default_pipeline_uuid = None
|
default_pipeline_uuid = None
|
||||||
@@ -115,6 +118,7 @@ class PersistenceManager:
|
|||||||
'name': 'ChatPipeline',
|
'name': 'ChatPipeline',
|
||||||
'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线',
|
'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线',
|
||||||
'config': pipeline_config,
|
'config': pipeline_config,
|
||||||
|
'extensions_preferences': {},
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
|
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
40
pkg/persistence/migrations/dbm011_dify_base_prompt_config.py
Normal file
40
pkg/persistence/migrations/dbm011_dify_base_prompt_config.py
Normal 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
|
||||||
@@ -68,6 +68,12 @@ class RuntimePipeline:
|
|||||||
|
|
||||||
stage_containers: list[StageInstContainer]
|
stage_containers: list[StageInstContainer]
|
||||||
"""阶段实例容器"""
|
"""阶段实例容器"""
|
||||||
|
|
||||||
|
bound_plugins: list[str]
|
||||||
|
"""绑定到此流水线的插件列表(格式:author/plugin_name)"""
|
||||||
|
|
||||||
|
bound_mcp_servers: list[str]
|
||||||
|
"""绑定到此流水线的MCP服务器列表(格式:uuid)"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -78,9 +84,20 @@ class RuntimePipeline:
|
|||||||
self.ap = ap
|
self.ap = ap
|
||||||
self.pipeline_entity = pipeline_entity
|
self.pipeline_entity = pipeline_entity
|
||||||
self.stage_containers = stage_containers
|
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):
|
async def run(self, query: pipeline_query.Query):
|
||||||
query.pipeline_config = self.pipeline_entity.config
|
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)
|
await self.process_query(query)
|
||||||
|
|
||||||
async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult):
|
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):
|
async def process_query(self, query: pipeline_query.Query):
|
||||||
"""处理请求"""
|
"""处理请求"""
|
||||||
try:
|
try:
|
||||||
|
# Get bound plugins for this pipeline
|
||||||
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
|
|
||||||
# ======== 触发 MessageReceived 事件 ========
|
# ======== 触发 MessageReceived 事件 ========
|
||||||
event_type = (
|
event_type = (
|
||||||
events.PersonMessageReceived
|
events.PersonMessageReceived
|
||||||
@@ -203,7 +223,7 @@ class RuntimePipeline:
|
|||||||
message_chain=query.message_chain,
|
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():
|
if event_ctx.is_prevented_default():
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,7 +65,14 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||||
|
|
||||||
if llm_model.model_entity.abilities.__contains__('func_call'):
|
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 = {
|
variables = {
|
||||||
'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||||
@@ -130,7 +137,9 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query=query,
|
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.prompt.messages = event_ctx.event.default_prompt
|
||||||
query.messages = event_ctx.event.prompt
|
query.messages = event_ctx.event.prompt
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
query=query,
|
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 # 判断下是否需要创建流式卡片
|
is_create_card = False # 判断下是否需要创建流式卡片
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ class CommandHandler(handler.MessageHandler):
|
|||||||
query=query,
|
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.is_prevented_default():
|
||||||
if event_ctx.event.reply_message_chain is not None:
|
if event_ctx.event.reply_message_chain is not None:
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ class ResponseWrapper(stage.PipelineStage):
|
|||||||
query=query,
|
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.is_prevented_default():
|
||||||
yield entities.StageProcessResult(
|
yield entities.StageProcessResult(
|
||||||
@@ -115,7 +117,9 @@ class ResponseWrapper(stage.PipelineStage):
|
|||||||
query=query,
|
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.is_prevented_default():
|
||||||
yield entities.StageProcessResult(
|
yield entities.StageProcessResult(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from ..entity.persistence import bot as persistence_bot
|
|||||||
from ..entity.errors import platform as platform_errors
|
from ..entity.errors import platform as platform_errors
|
||||||
|
|
||||||
from .logger import EventLogger
|
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.provider.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
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}',
|
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(
|
await self.ap.query_pool.add_query(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||||
@@ -91,6 +100,14 @@ class RuntimeBot:
|
|||||||
message_session_id=f'group_{event.group.id}',
|
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(
|
await self.ap.query_pool.add_query(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||||
@@ -157,6 +174,9 @@ class PlatformManager:
|
|||||||
self.adapter_dict = {}
|
self.adapter_dict = {}
|
||||||
|
|
||||||
async def initialize(self):
|
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')
|
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||||||
for component in self.adapter_components:
|
for component in self.adapter_components:
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
|||||||
extension = mimetypes.guess_extension(mime_type)
|
extension = mimetypes.guess_extension(mime_type)
|
||||||
if extension is None:
|
if extension is None:
|
||||||
extension = '.jpg'
|
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)
|
await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes)
|
||||||
image_keys.append(image_key)
|
image_keys.append(image_key)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
from libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
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:
|
if atUser.dingtalk_id == event.incoming_message.chatbot_user_id:
|
||||||
yiri_msg_list.append(platform_message.At(target=bot_name))
|
yiri_msg_list.append(platform_message.At(target=bot_name))
|
||||||
|
|
||||||
if event.content:
|
if event.rich_content:
|
||||||
text_content = event.content.replace('@' + bot_name, '')
|
elements = event.rich_content.get("Elements")
|
||||||
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
for element in elements:
|
||||||
if event.picture:
|
if element.get('Type') == 'text':
|
||||||
yiri_msg_list.append(platform_message.Image(base64=event.picture))
|
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:
|
if event.file:
|
||||||
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
||||||
if event.audio:
|
if event.audio:
|
||||||
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|
||||||
return chain
|
return chain
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
id=event.userid,
|
id=event.userid,
|
||||||
nickname='',
|
nickname=event.username,
|
||||||
remark='',
|
remark='',
|
||||||
),
|
),
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
@@ -61,10 +61,10 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
sender = platform_entities.GroupMember(
|
sender = platform_entities.GroupMember(
|
||||||
id=event.userid,
|
id=event.userid,
|
||||||
permission='MEMBER',
|
permission='MEMBER',
|
||||||
member_name=event.userid,
|
member_name=event.username,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=str(event.chatid),
|
id=str(event.chatid),
|
||||||
name='',
|
name=event.chatname,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
special_title='',
|
special_title='',
|
||||||
|
|||||||
106
pkg/platform/webhook_pusher.py
Normal file
106
pkg/platform/webhook_pusher.py
Normal 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}')
|
||||||
@@ -43,6 +43,10 @@ class PluginRuntimeConnector:
|
|||||||
|
|
||||||
ctrl: stdio_client_controller.StdioClientController | ws_client_controller.WebSocketClientController
|
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[
|
runtime_disconnect_callback: typing.Callable[
|
||||||
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
|
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
|
||||||
]
|
]
|
||||||
@@ -119,6 +123,42 @@ class PluginRuntimeConnector:
|
|||||||
make_connection_failed_callback=make_connection_failed_callback,
|
make_connection_failed_callback=make_connection_failed_callback,
|
||||||
)
|
)
|
||||||
task = self.ctrl.run(new_connection_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
|
else: # stdio
|
||||||
self.ap.logger.info('use stdio to connect to plugin runtime')
|
self.ap.logger.info('use stdio to connect to plugin runtime')
|
||||||
# cmd: lbp rt -s
|
# cmd: lbp rt -s
|
||||||
@@ -249,47 +289,62 @@ class PluginRuntimeConnector:
|
|||||||
async def emit_event(
|
async def emit_event(
|
||||||
self,
|
self,
|
||||||
event: events.BaseEventModel,
|
event: events.BaseEventModel,
|
||||||
|
bound_plugins: list[str] | None = None,
|
||||||
) -> context.EventContext:
|
) -> context.EventContext:
|
||||||
event_ctx = context.EventContext.from_event(event)
|
event_ctx = context.EventContext.from_event(event)
|
||||||
|
|
||||||
if not self.is_enable_plugin:
|
if not self.is_enable_plugin:
|
||||||
return event_ctx
|
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'])
|
event_ctx = context.EventContext.model_validate(event_ctx_result['event_context'])
|
||||||
|
|
||||||
return event_ctx
|
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:
|
if not self.is_enable_plugin:
|
||||||
return []
|
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:
|
if not self.is_enable_plugin:
|
||||||
return {'error': 'Tool not found: plugin system is disabled'}
|
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:
|
if not self.is_enable_plugin:
|
||||||
return []
|
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(
|
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]:
|
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
|
||||||
if not self.is_enable_plugin:
|
if not self.is_enable_plugin:
|
||||||
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
|
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:
|
async for ret in gen:
|
||||||
cmd_ret = command_context.CommandReturn.model_validate(ret)
|
cmd_ret = command_context.CommandReturn.model_validate(ret)
|
||||||
@@ -297,6 +352,9 @@ class PluginRuntimeConnector:
|
|||||||
yield cmd_ret
|
yield cmd_ret
|
||||||
|
|
||||||
def dispose(self):
|
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):
|
if self.is_enable_plugin and isinstance(self.ctrl, stdio_client_controller.StdioClientController):
|
||||||
self.ap.logger.info('Terminating plugin runtime process...')
|
self.ap.logger.info('Terminating plugin runtime process...')
|
||||||
self.ctrl.process.terminate()
|
self.ctrl.process.terminate()
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
|
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
|
||||||
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
|
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""Get llm models"""
|
"""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(
|
return handler.ActionResponse.success(
|
||||||
data={
|
data={
|
||||||
'llm_models': llm_models,
|
'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]:
|
async def ping(self) -> dict[str, Any]:
|
||||||
"""Ping the runtime"""
|
"""Ping the runtime"""
|
||||||
return await self.call_action(
|
return await self.call_action(
|
||||||
@@ -535,23 +554,27 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
async def emit_event(
|
async def emit_event(
|
||||||
self,
|
self,
|
||||||
event_context: dict[str, Any],
|
event_context: dict[str, Any],
|
||||||
|
include_plugins: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Emit event"""
|
"""Emit event"""
|
||||||
result = await self.call_action(
|
result = await self.call_action(
|
||||||
LangBotToRuntimeAction.EMIT_EVENT,
|
LangBotToRuntimeAction.EMIT_EVENT,
|
||||||
{
|
{
|
||||||
'event_context': event_context,
|
'event_context': event_context,
|
||||||
|
'include_plugins': include_plugins,
|
||||||
},
|
},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
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"""
|
"""List tools"""
|
||||||
result = await self.call_action(
|
result = await self.call_action(
|
||||||
LangBotToRuntimeAction.LIST_TOOLS,
|
LangBotToRuntimeAction.LIST_TOOLS,
|
||||||
{},
|
{
|
||||||
|
'include_plugins': include_plugins,
|
||||||
|
},
|
||||||
timeout=20,
|
timeout=20,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -596,34 +619,42 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
.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"""
|
"""Call tool"""
|
||||||
result = await self.call_action(
|
result = await self.call_action(
|
||||||
LangBotToRuntimeAction.CALL_TOOL,
|
LangBotToRuntimeAction.CALL_TOOL,
|
||||||
{
|
{
|
||||||
'tool_name': tool_name,
|
'tool_name': tool_name,
|
||||||
'tool_parameters': parameters,
|
'tool_parameters': parameters,
|
||||||
|
'include_plugins': include_plugins,
|
||||||
},
|
},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
|
|
||||||
return result['tool_response']
|
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"""
|
"""List commands"""
|
||||||
result = await self.call_action(
|
result = await self.call_action(
|
||||||
LangBotToRuntimeAction.LIST_COMMANDS,
|
LangBotToRuntimeAction.LIST_COMMANDS,
|
||||||
{},
|
{
|
||||||
|
'include_plugins': include_plugins,
|
||||||
|
},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
return result['commands']
|
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"""
|
"""Execute command"""
|
||||||
gen = self.call_action_generator(
|
gen = self.call_action_generator(
|
||||||
LangBotToRuntimeAction.EXECUTE_COMMAND,
|
LangBotToRuntimeAction.EXECUTE_COMMAND,
|
||||||
{
|
{
|
||||||
'command_context': command_context,
|
'command_context': command_context,
|
||||||
|
'include_plugins': include_plugins,
|
||||||
},
|
},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,24 +8,25 @@ metadata:
|
|||||||
icon: 302ai.png
|
icon: 302ai.png
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.302.ai/v1"
|
default: https://api.302.ai/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./302aichatcmpl.py
|
path: ./302aichatcmpl.py
|
||||||
attr: AI302ChatCompletions
|
attr: AI302ChatCompletions
|
||||||
|
|||||||
@@ -8,22 +8,23 @@ metadata:
|
|||||||
icon: anthropic.svg
|
icon: anthropic.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.anthropic.com"
|
default: https://api.anthropic.com
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./anthropicmsgs.py
|
path: ./anthropicmsgs.py
|
||||||
|
|||||||
@@ -8,22 +8,23 @@ metadata:
|
|||||||
icon: bailian.png
|
icon: bailian.png
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
default: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./bailianchatcmpl.py
|
path: ./bailianchatcmpl.py
|
||||||
|
|||||||
@@ -8,24 +8,25 @@ metadata:
|
|||||||
icon: openai.svg
|
icon: openai.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.openai.com/v1"
|
default: https://api.openai.com/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./chatcmpl.py
|
path: ./chatcmpl.py
|
||||||
attr: OpenAIChatCompletions
|
attr: OpenAIChatCompletions
|
||||||
|
|||||||
@@ -8,23 +8,24 @@ metadata:
|
|||||||
icon: compshare.png
|
icon: compshare.png
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.modelverse.cn/v1"
|
default: https://api.modelverse.cn/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./compsharechatcmpl.py
|
path: ./compsharechatcmpl.py
|
||||||
attr: CompShareChatCompletions
|
attr: CompShareChatCompletions
|
||||||
|
|||||||
@@ -8,23 +8,24 @@ metadata:
|
|||||||
icon: deepseek.svg
|
icon: deepseek.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.deepseek.com"
|
default: https://api.deepseek.com
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./deepseekchatcmpl.py
|
path: ./deepseekchatcmpl.py
|
||||||
attr: DeepseekChatCompletions
|
attr: DeepseekChatCompletions
|
||||||
|
|||||||
@@ -8,22 +8,23 @@ metadata:
|
|||||||
icon: gemini.svg
|
icon: gemini.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://generativelanguage.googleapis.com/v1beta/openai"
|
default: https://generativelanguage.googleapis.com/v1beta/openai
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./geminichatcmpl.py
|
path: ./geminichatcmpl.py
|
||||||
|
|||||||
@@ -8,24 +8,25 @@ metadata:
|
|||||||
icon: giteeai.svg
|
icon: giteeai.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://ai.gitee.com/v1"
|
default: https://ai.gitee.com/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./giteeaichatcmpl.py
|
path: ./giteeaichatcmpl.py
|
||||||
attr: GiteeAIChatCompletions
|
attr: GiteeAIChatCompletions
|
||||||
|
|||||||
BIN
pkg/provider/modelmgr/requesters/jiekouai.png
Normal file
BIN
pkg/provider/modelmgr/requesters/jiekouai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
208
pkg/provider/modelmgr/requesters/jiekouaichatcmpl.py
Normal file
208
pkg/provider/modelmgr/requesters/jiekouaichatcmpl.py
Normal 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
|
||||||
39
pkg/provider/modelmgr/requesters/jiekouaichatcmpl.yaml
Normal file
39
pkg/provider/modelmgr/requesters/jiekouaichatcmpl.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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
|
||||||
|
provider_category: maas
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: ./jiekouaichatcmpl.py
|
||||||
|
attr: JieKouAIChatCompletions
|
||||||
@@ -8,23 +8,24 @@ metadata:
|
|||||||
icon: lmstudio.webp
|
icon: lmstudio.webp
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "http://127.0.0.1:1234/v1"
|
default: http://127.0.0.1:1234/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: self-hosted
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./lmstudiochatcmpl.py
|
path: ./lmstudiochatcmpl.py
|
||||||
|
|||||||
@@ -8,29 +8,30 @@ metadata:
|
|||||||
icon: modelscope.svg
|
icon: modelscope.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api-inference.modelscope.cn/v1"
|
default: https://api-inference.modelscope.cn/v1
|
||||||
- name: args
|
- name: args
|
||||||
label:
|
label:
|
||||||
en_US: Args
|
en_US: Args
|
||||||
zh_Hans: 附加参数
|
zh_Hans: 附加参数
|
||||||
type: object
|
type: object
|
||||||
required: true
|
required: true
|
||||||
default: {}
|
default: {}
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: int
|
type: int
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./modelscopechatcmpl.py
|
path: ./modelscopechatcmpl.py
|
||||||
|
|||||||
@@ -8,22 +8,23 @@ metadata:
|
|||||||
icon: moonshot.png
|
icon: moonshot.png
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.moonshot.ai/v1"
|
default: https://api.moonshot.ai/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./moonshotchatcmpl.py
|
path: ./moonshotchatcmpl.py
|
||||||
|
|||||||
@@ -8,24 +8,25 @@ metadata:
|
|||||||
icon: newapi.png
|
icon: newapi.png
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "http://localhost:3000/v1"
|
default: http://localhost:3000/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./newapichatcmpl.py
|
path: ./newapichatcmpl.py
|
||||||
attr: NewAPIChatCompletions
|
attr: NewAPIChatCompletions
|
||||||
|
|||||||
@@ -8,23 +8,24 @@ metadata:
|
|||||||
icon: ollama.svg
|
icon: ollama.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "http://127.0.0.1:11434"
|
default: http://127.0.0.1:11434
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: self-hosted
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./ollamachat.py
|
path: ./ollamachat.py
|
||||||
|
|||||||
@@ -8,23 +8,24 @@ metadata:
|
|||||||
icon: openrouter.svg
|
icon: openrouter.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://openrouter.ai/api/v1"
|
default: https://openrouter.ai/api/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./openrouterchatcmpl.py
|
path: ./openrouterchatcmpl.py
|
||||||
|
|||||||
@@ -3,36 +3,37 @@ kind: LLMAPIRequester
|
|||||||
metadata:
|
metadata:
|
||||||
name: ppio-chat-completions
|
name: ppio-chat-completions
|
||||||
label:
|
label:
|
||||||
en_US: ppio
|
en_US: ppio
|
||||||
zh_Hans: 派欧云
|
zh_Hans: 派欧云
|
||||||
icon: ppio.svg
|
icon: ppio.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.ppinfra.com/v3/openai"
|
default: https://api.ppinfra.com/v3/openai
|
||||||
- name: args
|
- name: args
|
||||||
label:
|
label:
|
||||||
en_US: Args
|
en_US: Args
|
||||||
zh_Hans: 附加参数
|
zh_Hans: 附加参数
|
||||||
type: object
|
type: object
|
||||||
required: true
|
required: true
|
||||||
default: {}
|
default: {}
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: int
|
type: int
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./ppiochatcmpl.py
|
path: ./ppiochatcmpl.py
|
||||||
attr: PPIOChatCompletions
|
attr: PPIOChatCompletions
|
||||||
|
|||||||
@@ -8,31 +8,32 @@ metadata:
|
|||||||
icon: qhaigc.png
|
icon: qhaigc.png
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.qhaigc.net/v1"
|
default: https://api.qhaigc.net/v1
|
||||||
- name: args
|
- name: args
|
||||||
label:
|
label:
|
||||||
en_US: Args
|
en_US: Args
|
||||||
zh_Hans: 附加参数
|
zh_Hans: 附加参数
|
||||||
type: object
|
type: object
|
||||||
required: true
|
required: true
|
||||||
default: {}
|
default: {}
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: int
|
type: int
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./qhaigcchatcmpl.py
|
path: ./qhaigcchatcmpl.py
|
||||||
attr: QHAIGCChatCompletions
|
attr: QHAIGCChatCompletions
|
||||||
|
|||||||
@@ -8,31 +8,32 @@ metadata:
|
|||||||
icon: shengsuanyun.svg
|
icon: shengsuanyun.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://router.shengsuanyun.com/api/v1"
|
default: https://router.shengsuanyun.com/api/v1
|
||||||
- name: args
|
- name: args
|
||||||
label:
|
label:
|
||||||
en_US: Args
|
en_US: Args
|
||||||
zh_Hans: 附加参数
|
zh_Hans: 附加参数
|
||||||
type: object
|
type: object
|
||||||
required: true
|
required: true
|
||||||
default: {}
|
default: {}
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: int
|
type: int
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./shengsuanyun.py
|
path: ./shengsuanyun.py
|
||||||
attr: ShengSuanYunChatCompletions
|
attr: ShengSuanYunChatCompletions
|
||||||
|
|||||||
@@ -8,23 +8,24 @@ metadata:
|
|||||||
icon: siliconflow.svg
|
icon: siliconflow.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.siliconflow.cn/v1"
|
default: https://api.siliconflow.cn/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./siliconflowchatcmpl.py
|
path: ./siliconflowchatcmpl.py
|
||||||
|
|||||||
@@ -8,24 +8,25 @@ metadata:
|
|||||||
icon: tokenpony.svg
|
icon: tokenpony.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.tokenpony.cn/v1"
|
default: https://api.tokenpony.cn/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./tokenponychatcmpl.py
|
path: ./tokenponychatcmpl.py
|
||||||
attr: TokenPonyChatCompletions
|
attr: TokenPonyChatCompletions
|
||||||
|
|||||||
@@ -8,22 +8,23 @@ metadata:
|
|||||||
icon: volcark.svg
|
icon: volcark.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://ark.cn-beijing.volces.com/api/v3"
|
default: https://ark.cn-beijing.volces.com/api/v3
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./volcarkchatcmpl.py
|
path: ./volcarkchatcmpl.py
|
||||||
|
|||||||
@@ -8,22 +8,23 @@ metadata:
|
|||||||
icon: xai.svg
|
icon: xai.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://api.x.ai/v1"
|
default: https://api.x.ai/v1
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./xaichatcmpl.py
|
path: ./xaichatcmpl.py
|
||||||
|
|||||||
@@ -8,22 +8,23 @@ metadata:
|
|||||||
icon: zhipuai.svg
|
icon: zhipuai.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: Base URL
|
en_US: Base URL
|
||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://open.bigmodel.cn/api/paas/v4"
|
default: https://open.bigmodel.cn/api/paas/v4
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
zh_Hans: 超时时间
|
zh_Hans: 超时时间
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./zhipuaichatcmpl.py
|
path: ./zhipuaichatcmpl.py
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class CozeAPIRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
event_type = chunk.get('event')
|
event_type = chunk.get('event')
|
||||||
data = chunk.get('data', {})
|
data = chunk.get('data', {})
|
||||||
|
# Removed debug print statement to avoid cluttering logs in production
|
||||||
|
|
||||||
if event_type == 'conversation.message.delta':
|
if event_type == 'conversation.message.delta':
|
||||||
# 收集内容
|
# 收集内容
|
||||||
@@ -162,7 +163,7 @@ class CozeAPIRunner(runner.RequestRunner):
|
|||||||
if 'reasoning_content' in data:
|
if 'reasoning_content' in data:
|
||||||
full_reasoning += data.get('reasoning_content', '')
|
full_reasoning += data.get('reasoning_content', '')
|
||||||
|
|
||||||
elif event_type == 'done':
|
elif event_type.split(".")[-1] == 'done' : # 本地部署coze时,结束event不为done
|
||||||
# 保存会话ID
|
# 保存会话ID
|
||||||
if 'conversation_id' in data:
|
if 'conversation_id' in data:
|
||||||
conversation_id = data.get('conversation_id')
|
conversation_id = data.get('conversation_id')
|
||||||
@@ -258,7 +259,7 @@ class CozeAPIRunner(runner.RequestRunner):
|
|||||||
stop_reasoning = True
|
stop_reasoning = True
|
||||||
|
|
||||||
|
|
||||||
elif event_type == 'done':
|
elif event_type.split(".")[-1] == 'done' : # 本地部署coze时,结束event不为done
|
||||||
# 保存会话ID
|
# 保存会话ID
|
||||||
if 'conversation_id' in data:
|
if 'conversation_id' in data:
|
||||||
conversation_id = data.get('conversation_id')
|
conversation_id = data.get('conversation_id')
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|||||||
from libs.dify_service_api.v1 import client, errors
|
from libs.dify_service_api.v1 import client, errors
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@runner.runner_class('dify-service-api')
|
@runner.runner_class('dify-service-api')
|
||||||
class DifyServiceAPIRunner(runner.RequestRunner):
|
class DifyServiceAPIRunner(runner.RequestRunner):
|
||||||
"""Dify Service API 对话请求器"""
|
"""Dify Service API 对话请求器"""
|
||||||
@@ -77,7 +78,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
tuple[str, list[str]]: 纯文本和图片的 Dify 服务图片 ID
|
tuple[str, list[str]]: 纯文本和图片的 Dify 服务图片 ID
|
||||||
"""
|
"""
|
||||||
plain_text = ''
|
plain_text = ''
|
||||||
image_ids = []
|
file_ids = []
|
||||||
|
|
||||||
if isinstance(query.user_message.content, list):
|
if isinstance(query.user_message.content, list):
|
||||||
for ce in query.user_message.content:
|
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}',
|
f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||||
)
|
)
|
||||||
image_id = file_upload_resp['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):
|
elif isinstance(query.user_message.content, str):
|
||||||
plain_text = query.user_message.content
|
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(
|
async def _chat_messages(
|
||||||
self, query: pipeline_query.Query
|
self, query: pipeline_query.Query
|
||||||
@@ -110,7 +124,6 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
files = [
|
files = [
|
||||||
{
|
{
|
||||||
'type': 'image',
|
'type': 'image',
|
||||||
'transfer_method': 'local_file',
|
|
||||||
'upload_file_id': image_id,
|
'upload_file_id': image_id,
|
||||||
}
|
}
|
||||||
for image_id in image_ids
|
for image_id in image_ids
|
||||||
|
|||||||
@@ -40,10 +40,14 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
"""运行请求"""
|
"""运行请求"""
|
||||||
pending_tool_calls = []
|
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)
|
user_message = copy.deepcopy(query.user_message)
|
||||||
|
|
||||||
@@ -57,21 +61,28 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
user_message_text += ce.text
|
user_message_text += ce.text
|
||||||
break
|
break
|
||||||
|
|
||||||
if kb_uuid and user_message_text:
|
if kb_uuids and user_message_text:
|
||||||
# only support text for now
|
# only support text for now
|
||||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
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:
|
if not kb:
|
||||||
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found')
|
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
||||||
raise ValueError(f'Knowledge base {kb_uuid} not found')
|
continue
|
||||||
|
|
||||||
result = await kb.retrieve(user_message_text, kb.knowledge_base_entity.top_k)
|
result = await kb.retrieve(user_message_text, kb.knowledge_base_entity.top_k)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
all_results.extend(result)
|
||||||
|
|
||||||
final_user_message_text = ''
|
final_user_message_text = ''
|
||||||
|
|
||||||
if result:
|
if all_results:
|
||||||
rag_context = '\n\n'.join(
|
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(
|
final_user_message_text = rag_combined_prompt_template.format(
|
||||||
rag_context=rag_context, user_message=user_message_text
|
rag_context=rag_context, user_message=user_message_text
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ToolLoader(abc.ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@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
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class RuntimeMCPSession:
|
|||||||
|
|
||||||
server_name: str
|
server_name: str
|
||||||
|
|
||||||
|
server_uuid: str
|
||||||
|
|
||||||
server_config: dict
|
server_config: dict
|
||||||
|
|
||||||
session: ClientSession
|
session: ClientSession
|
||||||
@@ -43,7 +45,6 @@ class RuntimeMCPSession:
|
|||||||
# connected: bool
|
# connected: bool
|
||||||
status: MCPSessionStatus
|
status: MCPSessionStatus
|
||||||
|
|
||||||
|
|
||||||
_lifecycle_task: asyncio.Task | None
|
_lifecycle_task: asyncio.Task | None
|
||||||
|
|
||||||
_shutdown_event: asyncio.Event
|
_shutdown_event: asyncio.Event
|
||||||
@@ -52,6 +53,7 @@ class RuntimeMCPSession:
|
|||||||
|
|
||||||
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
|
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
|
||||||
self.server_name = server_name
|
self.server_name = server_name
|
||||||
|
self.server_uuid = server_config.get('uuid', '')
|
||||||
self.server_config = server_config
|
self.server_config = server_config
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
self.enable = enable
|
self.enable = enable
|
||||||
@@ -286,12 +288,14 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
name = server_config['name']
|
name = server_config['name']
|
||||||
|
uuid = server_config['uuid']
|
||||||
mode = server_config['mode']
|
mode = server_config['mode']
|
||||||
enable = server_config['enable']
|
enable = server_config['enable']
|
||||||
extra_args = server_config.get('extra_args', {})
|
extra_args = server_config.get('extra_args', {})
|
||||||
|
|
||||||
mixed_config = {
|
mixed_config = {
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'uuid': uuid,
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
'enable': enable,
|
'enable': enable,
|
||||||
**extra_args,
|
**extra_args,
|
||||||
@@ -301,11 +305,17 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
|
|
||||||
return session
|
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 = []
|
all_functions = []
|
||||||
|
|
||||||
for session in self.sessions.values():
|
for session in self.sessions.values():
|
||||||
all_functions.extend(session.get_tools())
|
# 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
|
self._last_listed_functions = all_functions
|
||||||
|
|
||||||
|
|||||||
@@ -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] = []
|
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(
|
tool_obj = resource_tool.LLMTool(
|
||||||
name=tool.metadata.name,
|
name=tool.metadata.name,
|
||||||
human_desc=tool.metadata.description.en_US,
|
human_desc=tool.metadata.description.en_US,
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ class ToolManager:
|
|||||||
self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)
|
self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)
|
||||||
await self.mcp_tool_loader.initialize()
|
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: list[resource_tool.LLMTool] = []
|
||||||
|
|
||||||
all_functions.extend(await self.plugin_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())
|
all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers))
|
||||||
|
|
||||||
return all_functions
|
return all_functions
|
||||||
|
|
||||||
|
|||||||
@@ -42,3 +42,10 @@ class StorageProvider(abc.ABC):
|
|||||||
key: str,
|
key: str,
|
||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def delete_dir_recursive(
|
||||||
|
self,
|
||||||
|
dir_path: str,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
import shutil
|
||||||
|
|
||||||
from ...core import app
|
from ...core import app
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ class LocalStorageProvider(provider.StorageProvider):
|
|||||||
key: str,
|
key: str,
|
||||||
value: bytes,
|
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:
|
async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'wb') as f:
|
||||||
await f.write(value)
|
await f.write(value)
|
||||||
|
|
||||||
@@ -43,3 +46,11 @@ class LocalStorageProvider(provider.StorageProvider):
|
|||||||
key: str,
|
key: str,
|
||||||
):
|
):
|
||||||
os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
|
os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
|
||||||
|
|
||||||
|
async def delete_dir_recursive(
|
||||||
|
self,
|
||||||
|
dir_path: str,
|
||||||
|
):
|
||||||
|
# 直接删除整个目录
|
||||||
|
if os.path.exists(os.path.join(LOCAL_STORAGE_PATH, dir_path)):
|
||||||
|
shutil.rmtree(os.path.join(LOCAL_STORAGE_PATH, dir_path))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
semantic_version = 'v4.4.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"""
|
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||||
|
|
||||||
debug_mode = False
|
debug_mode = False
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.4.0"
|
version = "4.5.0"
|
||||||
description = "Easy-to-use global IM bot platform designed for LLM era"
|
description = "Easy-to-use global IM bot platform designed for LLM era"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10.1,<4.0"
|
requires-python = ">=3.10.1,<4.0"
|
||||||
@@ -63,7 +63,7 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=0.0.1",
|
"langchain-text-splitters>=0.0.1",
|
||||||
"chromadb>=0.4.24",
|
"chromadb>=0.4.24",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"langbot-plugin==0.1.6",
|
"langbot-plugin==0.1.10",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"content": "You are a helpful assistant."
|
"content": "You are a helpful assistant."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"knowledge-base": ""
|
"knowledge-bases": []
|
||||||
},
|
},
|
||||||
"dify-service-api": {
|
"dify-service-api": {
|
||||||
"base-url": "https://api.dify.ai/v1",
|
"base-url": "https://api.dify.ai/v1",
|
||||||
|
|||||||
@@ -80,16 +80,16 @@ stages:
|
|||||||
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
|
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
|
||||||
type: prompt-editor
|
type: prompt-editor
|
||||||
required: true
|
required: true
|
||||||
- name: knowledge-base
|
- name: knowledge-bases
|
||||||
label:
|
label:
|
||||||
en_US: Knowledge Base
|
en_US: Knowledge Bases
|
||||||
zh_Hans: 知识库
|
zh_Hans: 知识库
|
||||||
description:
|
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: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
|
zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
|
||||||
type: knowledge-base-selector
|
type: knowledge-base-multi-selector
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: []
|
||||||
- name: tbox-app-api
|
- name: tbox-app-api
|
||||||
label:
|
label:
|
||||||
en_US: Tbox App API
|
en_US: Tbox App API
|
||||||
@@ -124,6 +124,16 @@ stages:
|
|||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
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
|
- name: app-type
|
||||||
label:
|
label:
|
||||||
en_US: App Type
|
en_US: App Type
|
||||||
|
|||||||
1
tests/unit_tests/config/__init__.py
Normal file
1
tests/unit_tests/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Config unit tests
|
||||||
332
tests/unit_tests/config/test_env_override.py
Normal file
332
tests/unit_tests/config/test_env_override.py
Normal 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'])
|
||||||
@@ -5,7 +5,6 @@ PipelineManager unit tests
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
import sqlalchemy
|
|
||||||
|
|
||||||
|
|
||||||
def get_pipelinemgr_module():
|
def get_pipelinemgr_module():
|
||||||
@@ -54,6 +53,7 @@ async def test_load_pipeline(mock_app):
|
|||||||
pipeline_entity.uuid = 'test-uuid'
|
pipeline_entity.uuid = 'test-uuid'
|
||||||
pipeline_entity.stages = []
|
pipeline_entity.stages = []
|
||||||
pipeline_entity.config = {'test': 'config'}
|
pipeline_entity.config = {'test': 'config'}
|
||||||
|
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||||
|
|
||||||
await manager.load_pipeline(pipeline_entity)
|
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.uuid = 'test-uuid'
|
||||||
pipeline_entity.stages = []
|
pipeline_entity.stages = []
|
||||||
pipeline_entity.config = {}
|
pipeline_entity.config = {}
|
||||||
|
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||||
|
|
||||||
await manager.load_pipeline(pipeline_entity)
|
await manager.load_pipeline(pipeline_entity)
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ async def test_remove_pipeline(mock_app):
|
|||||||
pipeline_entity.uuid = 'test-uuid'
|
pipeline_entity.uuid = 'test-uuid'
|
||||||
pipeline_entity.stages = []
|
pipeline_entity.stages = []
|
||||||
pipeline_entity.config = {}
|
pipeline_entity.config = {}
|
||||||
|
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||||
|
|
||||||
await manager.load_pipeline(pipeline_entity)
|
await manager.load_pipeline(pipeline_entity)
|
||||||
assert len(manager.pipelines) == 1
|
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
|
# Make it look like ResultType.CONTINUE
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
CONTINUE = MagicMock()
|
CONTINUE = MagicMock()
|
||||||
CONTINUE.__eq__ = lambda self, other: True # Always equal for comparison
|
CONTINUE.__eq__ = lambda self, other: True # Always equal for comparison
|
||||||
mock_result.result_type = CONTINUE
|
mock_result.result_type = CONTINUE
|
||||||
@@ -147,6 +150,7 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
|
|||||||
# Create pipeline entity
|
# Create pipeline entity
|
||||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||||
pipeline_entity.config = sample_query.pipeline_config
|
pipeline_entity.config = sample_query.pipeline_config
|
||||||
|
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||||
|
|
||||||
# Create runtime pipeline
|
# Create runtime pipeline
|
||||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [stage_container])
|
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [stage_container])
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
width: 4rem;
|
width: 4rem;
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
margin: 0.2rem;
|
margin: 0.2rem;
|
||||||
/* border-radius: 50%; */
|
border-radius: 8%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.basicInfoContainer {
|
.basicInfoContainer {
|
||||||
|
|||||||
@@ -491,7 +491,7 @@ export default function BotForm({
|
|||||||
<img
|
<img
|
||||||
src={adapterIconList[form.watch('adapter')]}
|
src={adapterIconList[form.watch('adapter')]}
|
||||||
alt="adapter icon"
|
alt="adapter icon"
|
||||||
className="w-12 h-12"
|
className="w-12 h-12 rounded-[8%]"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,18 +11,23 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
|
|
||||||
export default function DynamicFormComponent({
|
export default function DynamicFormComponent({
|
||||||
itemConfigList,
|
itemConfigList,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
onFileUploaded,
|
||||||
}: {
|
}: {
|
||||||
itemConfigList: IDynamicFormItemSchema[];
|
itemConfigList: IDynamicFormItemSchema[];
|
||||||
onSubmit?: (val: object) => unknown;
|
onSubmit?: (val: object) => unknown;
|
||||||
initialValues?: Record<string, object>;
|
initialValues?: Record<string, object>;
|
||||||
|
onFileUploaded?: (fileKey: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
const previousInitialValues = useRef(initialValues);
|
||||||
|
|
||||||
// 根据 itemConfigList 动态生成 zod schema
|
// 根据 itemConfigList 动态生成 zod schema
|
||||||
const formSchema = z.object(
|
const formSchema = z.object(
|
||||||
itemConfigList.reduce(
|
itemConfigList.reduce(
|
||||||
@@ -53,6 +58,12 @@ export default function DynamicFormComponent({
|
|||||||
case 'knowledge-base-selector':
|
case 'knowledge-base-selector':
|
||||||
fieldSchema = z.string();
|
fieldSchema = z.string();
|
||||||
break;
|
break;
|
||||||
|
case 'knowledge-base-multi-selector':
|
||||||
|
fieldSchema = z.array(z.string());
|
||||||
|
break;
|
||||||
|
case 'bot-selector':
|
||||||
|
fieldSchema = z.string();
|
||||||
|
break;
|
||||||
case 'prompt-editor':
|
case 'prompt-editor':
|
||||||
fieldSchema = z.array(
|
fieldSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -97,9 +108,24 @@ export default function DynamicFormComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 当 initialValues 变化时更新表单值
|
// 当 initialValues 变化时更新表单值
|
||||||
|
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('initialValues', initialValues);
|
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(
|
const mergedValues = itemConfigList.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
@@ -112,6 +138,8 @@ export default function DynamicFormComponent({
|
|||||||
Object.entries(mergedValues).forEach(([key, value]) => {
|
Object.entries(mergedValues).forEach(([key, value]) => {
|
||||||
form.setValue(key as keyof FormValues, value);
|
form.setValue(key as keyof FormValues, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
previousInitialValues.current = initialValues;
|
||||||
}
|
}
|
||||||
}, [initialValues, form, itemConfigList]);
|
}, [initialValues, form, itemConfigList]);
|
||||||
|
|
||||||
@@ -149,7 +177,11 @@ export default function DynamicFormComponent({
|
|||||||
{config.required && <span className="text-red-500">*</span>}
|
{config.required && <span className="text-red-500">*</span>}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<DynamicFormItemComponent config={config} field={field} />
|
<DynamicFormItemComponent
|
||||||
|
config={config}
|
||||||
|
field={field}
|
||||||
|
onFileUploaded={onFileUploaded}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{config.description && (
|
{config.description && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
DynamicFormItemType,
|
DynamicFormItemType,
|
||||||
IDynamicFormItemSchema,
|
IDynamicFormItemSchema,
|
||||||
|
IFileConfig,
|
||||||
} from '@/app/infra/entities/form/dynamic';
|
} from '@/app/infra/entities/form/dynamic';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +17,7 @@ import { ControllerRenderProps } from 'react-hook-form';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
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 { KnowledgeBase } from '@/app/infra/entities/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
@@ -27,19 +28,65 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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({
|
export default function DynamicFormItemComponent({
|
||||||
config,
|
config,
|
||||||
field,
|
field,
|
||||||
|
onFileUploaded,
|
||||||
}: {
|
}: {
|
||||||
config: IDynamicFormItemSchema;
|
config: IDynamicFormItemSchema;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
field: ControllerRenderProps<any, any>;
|
field: ControllerRenderProps<any, any>;
|
||||||
|
onFileUploaded?: (fileKey: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
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 { 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(() => {
|
useEffect(() => {
|
||||||
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
|
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
|
||||||
httpClient
|
httpClient
|
||||||
@@ -48,20 +95,36 @@ export default function DynamicFormItemComponent({
|
|||||||
setLlmModels(resp.models);
|
setLlmModels(resp.models);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error('获取 LLM 模型列表失败:' + err.message);
|
toast.error('Failed to get LLM model list: ' + err.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [config.type]);
|
}, [config.type]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR) {
|
if (
|
||||||
|
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
|
||||||
|
config.type === DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR
|
||||||
|
) {
|
||||||
httpClient
|
httpClient
|
||||||
.getKnowledgeBases()
|
.getKnowledgeBases()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setKnowledgeBases(resp.bases);
|
setKnowledgeBases(resp.bases);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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]);
|
}, [config.type]);
|
||||||
@@ -80,6 +143,9 @@ export default function DynamicFormItemComponent({
|
|||||||
case DynamicFormItemType.STRING:
|
case DynamicFormItemType.STRING:
|
||||||
return <Input {...field} />;
|
return <Input {...field} />;
|
||||||
|
|
||||||
|
case DynamicFormItemType.TEXT:
|
||||||
|
return <Textarea {...field} className="min-h-[120px]" />;
|
||||||
|
|
||||||
case DynamicFormItemType.BOOLEAN:
|
case DynamicFormItemType.BOOLEAN:
|
||||||
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
||||||
|
|
||||||
@@ -284,6 +350,146 @@ export default function DynamicFormItemComponent({
|
|||||||
</Select>
|
</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:
|
case DynamicFormItemType.PROMPT_EDITOR:
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -366,6 +572,185 @@ export default function DynamicFormItemComponent({
|
|||||||
</div>
|
</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:
|
default:
|
||||||
return <Input {...field} />;
|
return <Input {...field} />;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user