Merge branch 'master' into copilot/create-langbot-python-package

This commit is contained in:
Junyan Qin
2025-11-16 17:50:37 +08:00
88 changed files with 3793 additions and 879 deletions

View File

@@ -1,10 +1,9 @@
name: Build Docker Image
on:
#防止fork乱用action设置只能手动触发构建
workflow_dispatch:
## 发布release的时候会自动构建
release:
types: [published]
workflow_dispatch:
jobs:
publish-docker-image:
runs-on: ubuntu-latest
@@ -43,7 +42,7 @@ jobs:
run: docker buildx create --name mybuilder --use
- name: Build for Release # only relase, exlude pre-release
if: ${{ github.event.release.prerelease == false }}
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
- name: Build for Pre-release # no update for latest tag
if: ${{ github.event.release.prerelease == true }}
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push

View File

@@ -64,6 +64,11 @@ Plugin Runtime automatically starts each installed plugin and interacts through
- 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

View File

@@ -80,6 +80,10 @@ docker compose up -d
直接使用发行版运行,查看文档[手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html)。
#### Kubernetes 部署
参考 [Kubernetes 部署](./docker/README_K8S.md) 文档。
## 😎 保持更新
点击仓库右上角 Star 和 Watch 按钮,获取最新动态。
@@ -131,6 +135,7 @@ docker compose up -d
| [胜算云](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | 全球大模型都可调用(友情推荐) |
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | 大模型和 GPU 资源平台 |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型和 GPU 资源平台 |
| [接口 AI](https://jiekou.ai/) | ✅ | 大模型聚合平台,专注全球大模型接入 |
| [302.AI](https://share.302.ai/SuTG99) | ✅ | 大模型聚合平台 |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
| [Dify](https://dify.ai) | ✅ | LLMOps 平台 |
@@ -143,7 +148,7 @@ docker compose up -d
| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | 大模型聚合平台, LLMOps 平台 |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | 大模型聚合平台 |
| [MCP](https://modelcontextprotocol.io/) | ✅ | 支持通过 MCP 协议获取工具 |
| [百宝箱Tbox](https://www.tbox.cn/open) | ✅ | 蚂蚁百宝箱智能体平台每月免费10亿大模型Token |
| [百宝箱Tbox](https://www.tbox.cn/open) | ✅ | 蚂蚁百宝箱智能体平台每月免费10亿大模型Token |
### TTS

View File

@@ -74,6 +74,10 @@ Community contributed Zeabur template.
Directly use the released version to run, see the [Manual Deployment](https://docs.langbot.app/en/deploy/langbot/manual.html) documentation.
#### Kubernetes Deployment
Refer to the [Kubernetes Deployment](./docker/README_K8S.md) documentation.
## 😎 Stay Ahead
Click the Star and Watch button in the upper right corner of the repository to get the latest updates.
@@ -124,6 +128,7 @@ Or visit the demo environment: https://demo.langbot.dev/
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | LLM and GPU resource platform |
| [Dify](https://dify.ai) | ✅ | LLMOps platform |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | LLM and GPU resource platform |
| [接口 AI](https://jiekou.ai/) | ✅ | LLM aggregation platform, dedicated to global LLMs |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | LLM and GPU resource platform |
| [302.AI](https://share.302.ai/SuTG99) | ✅ | LLM gateway(MaaS) |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |

View File

@@ -55,6 +55,10 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
リリースバージョンを直接使用して実行します。[手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html)のドキュメントを参照してください。
#### Kubernetes デプロイ
[Kubernetes デプロイ](./docker/README_K8S.md) ドキュメントを参照してください。
## 😎 最新情報を入手
リポジトリの右上にある Star と Watch ボタンをクリックして、最新の更新を取得してください。
@@ -104,6 +108,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | 大模型とGPUリソースプラットフォーム |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型とGPUリソースプラットフォーム |
| [接口 AI](https://jiekou.ai/) | ✅ | LLMゲートウェイ(MaaS) |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | LLMとGPUリソースプラットフォーム |
| [302.AI](https://share.302.ai/SuTG99) | ✅ | LLMゲートウェイ(MaaS) |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |

View File

@@ -57,6 +57,10 @@ docker compose up -d
直接使用發行版運行,查看文件[手動部署](https://docs.langbot.app/zh/deploy/langbot/manual.html)。
#### Kubernetes 部署
參考 [Kubernetes 部署](./docker/README_K8S.md) 文件。
## 😎 保持更新
點擊倉庫右上角 Star 和 Watch 按鈕,獲取最新動態。
@@ -107,6 +111,7 @@ docker compose up -d
| [勝算雲](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | 大模型和 GPU 資源平台 |
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | 大模型和 GPU 資源平台 |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型和 GPU 資源平台 |
| [接口 AI](https://jiekou.ai/) | ✅ | 大模型聚合平台,專注全球大模型接入 |
| [302.AI](https://share.302.ai/SuTG99) | ✅ | 大模型聚合平台 |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
| [Dify](https://dify.ai) | ✅ | LLMOps 平台 |

629
docker/README_K8S.md Normal file
View File

@@ -0,0 +1,629 @@
# LangBot Kubernetes 部署指南 / Kubernetes Deployment Guide
[简体中文](#简体中文) | [English](#english)
---
## 简体中文
### 概述
本指南提供了在 Kubernetes 集群中部署 LangBot 的完整步骤。Kubernetes 部署配置基于 `docker-compose.yaml`,适用于生产环境的容器化部署。
### 前置要求
- Kubernetes 集群(版本 1.19+
- `kubectl` 命令行工具已配置并可访问集群
- 集群中有可用的存储类StorageClass用于持久化存储可选但推荐
- 至少 2 vCPU 和 4GB RAM 的可用资源
### 架构说明
Kubernetes 部署包含以下组件:
1. **langbot**: 主应用服务
- 提供 Web UI端口 5300
- 处理平台 webhook端口 2280-2290
- 数据持久化卷
2. **langbot-plugin-runtime**: 插件运行时服务
- WebSocket 通信(端口 5400
- 插件数据持久化卷
3. **持久化存储**:
- `langbot-data`: LangBot 主数据
- `langbot-plugins`: 插件文件
- `langbot-plugin-runtime-data`: 插件运行时数据
### 快速开始
#### 1. 下载部署文件
```bash
# 克隆仓库
git clone https://github.com/langbot-app/LangBot
cd LangBot/docker
# 或直接下载 kubernetes.yaml
wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml
```
#### 2. 部署到 Kubernetes
```bash
# 应用所有配置
kubectl apply -f kubernetes.yaml
# 检查部署状态
kubectl get all -n langbot
# 查看 Pod 日志
kubectl logs -n langbot -l app=langbot -f
```
#### 3. 访问 LangBot
默认情况下LangBot 服务使用 ClusterIP 类型,只能在集群内部访问。您可以选择以下方式之一来访问:
**选项 A: 端口转发(推荐用于测试)**
```bash
kubectl port-forward -n langbot svc/langbot 5300:5300
```
然后访问 http://localhost:5300
**选项 B: NodePort适用于开发环境**
编辑 `kubernetes.yaml`,取消注释 NodePort Service 部分,然后:
```bash
kubectl apply -f kubernetes.yaml
# 获取节点 IP
kubectl get nodes -o wide
# 访问 http://<NODE_IP>:30300
```
**选项 C: LoadBalancer适用于云环境**
编辑 `kubernetes.yaml`,取消注释 LoadBalancer Service 部分,然后:
```bash
kubectl apply -f kubernetes.yaml
# 获取外部 IP
kubectl get svc -n langbot langbot-loadbalancer
# 访问 http://<EXTERNAL_IP>
```
**选项 D: Ingress推荐用于生产环境**
确保集群中已安装 Ingress Controller如 nginx-ingress然后
1. 编辑 `kubernetes.yaml` 中的 Ingress 配置
2. 修改域名为您的实际域名
3. 应用配置:
```bash
kubectl apply -f kubernetes.yaml
# 访问 http://langbot.yourdomain.com
```
### 配置说明
#### 环境变量
`ConfigMap` 中配置环境变量:
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: langbot-config
namespace: langbot
data:
TZ: "Asia/Shanghai" # 修改为您的时区
```
#### 存储配置
默认使用动态存储分配。如果您有特定的 StorageClass请在 PVC 中指定:
```yaml
spec:
storageClassName: your-storage-class-name
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
```
#### 资源限制
根据您的需求调整资源限制:
```yaml
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "2000m"
```
### 常用操作
#### 查看日志
```bash
# 查看 LangBot 主服务日志
kubectl logs -n langbot -l app=langbot -f
# 查看插件运行时日志
kubectl logs -n langbot -l app=langbot-plugin-runtime -f
```
#### 重启服务
```bash
# 重启 LangBot
kubectl rollout restart deployment/langbot -n langbot
# 重启插件运行时
kubectl rollout restart deployment/langbot-plugin-runtime -n langbot
```
#### 更新镜像
```bash
# 更新到最新版本
kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest
kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest
# 检查更新状态
kubectl rollout status deployment/langbot -n langbot
```
#### 扩容(不推荐)
注意:由于 LangBot 使用 ReadWriteOnce 的持久化存储,不支持多副本扩容。如需高可用,请考虑使用 ReadWriteMany 存储或其他架构方案。
#### 备份数据
```bash
# 备份 PVC 数据
kubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data
kubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz
```
### 卸载
```bash
# 删除所有资源(保留 PVC
kubectl delete deployment,service,configmap -n langbot --all
# 删除 PVC会删除数据
kubectl delete pvc -n langbot --all
# 删除命名空间
kubectl delete namespace langbot
```
### 故障排查
#### Pod 无法启动
```bash
# 查看 Pod 状态
kubectl get pods -n langbot
# 查看详细信息
kubectl describe pod -n langbot <pod-name>
# 查看事件
kubectl get events -n langbot --sort-by='.lastTimestamp'
```
#### 存储问题
```bash
# 检查 PVC 状态
kubectl get pvc -n langbot
# 检查 PV
kubectl get pv
```
#### 网络访问问题
```bash
# 检查 Service
kubectl get svc -n langbot
# 检查端口转发
kubectl port-forward -n langbot svc/langbot 5300:5300
```
### 生产环境建议
1. **使用特定版本标签**:避免使用 `latest` 标签,使用具体版本号如 `rockchin/langbot:v1.0.0`
2. **配置资源限制**:根据实际负载调整 CPU 和内存限制
3. **使用 Ingress + TLS**:配置 HTTPS 访问和证书管理
4. **配置监控和告警**:集成 Prometheus、Grafana 等监控工具
5. **定期备份**:配置自动备份策略保护数据
6. **使用专用 StorageClass**:为生产环境配置高性能存储
7. **配置亲和性规则**:确保 Pod 调度到合适的节点
### 高级配置
#### 使用 Secrets 管理敏感信息
如果需要配置 API 密钥等敏感信息:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: langbot-secrets
namespace: langbot
type: Opaque
data:
api_key: <base64-encoded-value>
```
然后在 Deployment 中引用:
```yaml
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: langbot-secrets
key: api_key
```
#### 配置水平自动扩缩容HPA
注意:需要确保使用 ReadWriteMany 存储类型
```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: langbot-hpa
namespace: langbot
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: langbot
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
```
### 参考资源
- [LangBot 官方文档](https://docs.langbot.app)
- [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html)
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
---
## English
### Overview
This guide provides complete steps for deploying LangBot in a Kubernetes cluster. The Kubernetes deployment configuration is based on `docker-compose.yaml` and is suitable for production containerized deployments.
### Prerequisites
- Kubernetes cluster (version 1.19+)
- `kubectl` command-line tool configured with cluster access
- Available StorageClass in the cluster for persistent storage (optional but recommended)
- At least 2 vCPU and 4GB RAM of available resources
### Architecture
The Kubernetes deployment includes the following components:
1. **langbot**: Main application service
- Provides Web UI (port 5300)
- Handles platform webhooks (ports 2280-2290)
- Data persistence volume
2. **langbot-plugin-runtime**: Plugin runtime service
- WebSocket communication (port 5400)
- Plugin data persistence volume
3. **Persistent Storage**:
- `langbot-data`: LangBot main data
- `langbot-plugins`: Plugin files
- `langbot-plugin-runtime-data`: Plugin runtime data
### Quick Start
#### 1. Download Deployment Files
```bash
# Clone repository
git clone https://github.com/langbot-app/LangBot
cd LangBot/docker
# Or download kubernetes.yaml directly
wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml
```
#### 2. Deploy to Kubernetes
```bash
# Apply all configurations
kubectl apply -f kubernetes.yaml
# Check deployment status
kubectl get all -n langbot
# View Pod logs
kubectl logs -n langbot -l app=langbot -f
```
#### 3. Access LangBot
By default, LangBot service uses ClusterIP type, accessible only within the cluster. Choose one of the following methods to access:
**Option A: Port Forwarding (Recommended for testing)**
```bash
kubectl port-forward -n langbot svc/langbot 5300:5300
```
Then visit http://localhost:5300
**Option B: NodePort (Suitable for development)**
Edit `kubernetes.yaml`, uncomment the NodePort Service section, then:
```bash
kubectl apply -f kubernetes.yaml
# Get node IP
kubectl get nodes -o wide
# Visit http://<NODE_IP>:30300
```
**Option C: LoadBalancer (Suitable for cloud environments)**
Edit `kubernetes.yaml`, uncomment the LoadBalancer Service section, then:
```bash
kubectl apply -f kubernetes.yaml
# Get external IP
kubectl get svc -n langbot langbot-loadbalancer
# Visit http://<EXTERNAL_IP>
```
**Option D: Ingress (Recommended for production)**
Ensure an Ingress Controller (e.g., nginx-ingress) is installed in the cluster, then:
1. Edit the Ingress configuration in `kubernetes.yaml`
2. Change the domain to your actual domain
3. Apply configuration:
```bash
kubectl apply -f kubernetes.yaml
# Visit http://langbot.yourdomain.com
```
### Configuration
#### Environment Variables
Configure environment variables in ConfigMap:
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: langbot-config
namespace: langbot
data:
TZ: "Asia/Shanghai" # Change to your timezone
```
#### Storage Configuration
Uses dynamic storage provisioning by default. If you have a specific StorageClass, specify it in PVC:
```yaml
spec:
storageClassName: your-storage-class-name
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
```
#### Resource Limits
Adjust resource limits based on your needs:
```yaml
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "2000m"
```
### Common Operations
#### View Logs
```bash
# View LangBot main service logs
kubectl logs -n langbot -l app=langbot -f
# View plugin runtime logs
kubectl logs -n langbot -l app=langbot-plugin-runtime -f
```
#### Restart Services
```bash
# Restart LangBot
kubectl rollout restart deployment/langbot -n langbot
# Restart plugin runtime
kubectl rollout restart deployment/langbot-plugin-runtime -n langbot
```
#### Update Images
```bash
# Update to latest version
kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest
kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest
# Check update status
kubectl rollout status deployment/langbot -n langbot
```
#### Scaling (Not Recommended)
Note: Due to LangBot using ReadWriteOnce persistent storage, multi-replica scaling is not supported. For high availability, consider using ReadWriteMany storage or alternative architectures.
#### Backup Data
```bash
# Backup PVC data
kubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data
kubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz
```
### Uninstall
```bash
# Delete all resources (keep PVCs)
kubectl delete deployment,service,configmap -n langbot --all
# Delete PVCs (will delete data)
kubectl delete pvc -n langbot --all
# Delete namespace
kubectl delete namespace langbot
```
### Troubleshooting
#### Pods Not Starting
```bash
# Check Pod status
kubectl get pods -n langbot
# View detailed information
kubectl describe pod -n langbot <pod-name>
# View events
kubectl get events -n langbot --sort-by='.lastTimestamp'
```
#### Storage Issues
```bash
# Check PVC status
kubectl get pvc -n langbot
# Check PV
kubectl get pv
```
#### Network Access Issues
```bash
# Check Service
kubectl get svc -n langbot
# Test port forwarding
kubectl port-forward -n langbot svc/langbot 5300:5300
```
### Production Recommendations
1. **Use specific version tags**: Avoid using `latest` tag, use specific version like `rockchin/langbot:v1.0.0`
2. **Configure resource limits**: Adjust CPU and memory limits based on actual load
3. **Use Ingress + TLS**: Configure HTTPS access and certificate management
4. **Configure monitoring and alerts**: Integrate monitoring tools like Prometheus, Grafana
5. **Regular backups**: Configure automated backup strategy to protect data
6. **Use dedicated StorageClass**: Configure high-performance storage for production
7. **Configure affinity rules**: Ensure Pods are scheduled to appropriate nodes
### Advanced Configuration
#### Using Secrets for Sensitive Information
If you need to configure sensitive information like API keys:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: langbot-secrets
namespace: langbot
type: Opaque
data:
api_key: <base64-encoded-value>
```
Then reference in Deployment:
```yaml
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: langbot-secrets
key: api_key
```
#### Configure Horizontal Pod Autoscaling (HPA)
Note: Requires ReadWriteMany storage type
```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: langbot-hpa
namespace: langbot
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: langbot
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
```
### References
- [LangBot Official Documentation](https://docs.langbot.app)
- [Docker Deployment Guide](https://docs.langbot.app/zh/deploy/langbot/docker.html)
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)

74
docker/deploy-k8s-test.sh Executable file
View File

@@ -0,0 +1,74 @@
#!/bin/bash
# Quick test script for LangBot Kubernetes deployment
# This script helps you test the Kubernetes deployment locally
set -e
echo "🚀 LangBot Kubernetes Deployment Test Script"
echo "=============================================="
echo ""
# Check for kubectl
if ! command -v kubectl &> /dev/null; then
echo "❌ kubectl is not installed. Please install kubectl first."
echo "Visit: https://kubernetes.io/docs/tasks/tools/"
exit 1
fi
echo "✓ kubectl is installed"
# Check if kubectl can connect to a cluster
if ! kubectl cluster-info &> /dev/null; then
echo ""
echo "⚠️ No Kubernetes cluster found."
echo ""
echo "To test locally, you can use:"
echo " - kind: https://kind.sigs.k8s.io/"
echo " - minikube: https://minikube.sigs.k8s.io/"
echo " - k3s: https://k3s.io/"
echo ""
echo "Example with kind:"
echo " kind create cluster --name langbot-test"
echo ""
exit 1
fi
echo "✓ Connected to Kubernetes cluster"
kubectl cluster-info
echo ""
# Ask user to confirm
read -p "Do you want to deploy LangBot to this cluster? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Deployment cancelled."
exit 0
fi
echo ""
echo "📦 Deploying LangBot..."
kubectl apply -f kubernetes.yaml
echo ""
echo "⏳ Waiting for pods to be ready..."
kubectl wait --for=condition=ready pod -l app=langbot -n langbot --timeout=300s
kubectl wait --for=condition=ready pod -l app=langbot-plugin-runtime -n langbot --timeout=300s
echo ""
echo "✅ Deployment complete!"
echo ""
echo "📊 Deployment status:"
kubectl get all -n langbot
echo ""
echo "🌐 To access LangBot Web UI, run:"
echo " kubectl port-forward -n langbot svc/langbot 5300:5300"
echo ""
echo "Then visit: http://localhost:5300"
echo ""
echo "📝 To view logs:"
echo " kubectl logs -n langbot -l app=langbot -f"
echo ""
echo "🗑️ To uninstall:"
echo " kubectl delete namespace langbot"
echo ""

View File

@@ -1,3 +1,5 @@
# Docker Compose configuration for LangBot
# For Kubernetes deployment, see kubernetes.yaml and README_K8S.md
version: "3"
services:

400
docker/kubernetes.yaml Normal file
View File

@@ -0,0 +1,400 @@
# Kubernetes Deployment for LangBot
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
#
# Usage:
# kubectl apply -f kubernetes.yaml
#
# Prerequisites:
# - A Kubernetes cluster (1.19+)
# - kubectl configured to communicate with your cluster
# - (Optional) A StorageClass for dynamic volume provisioning
#
# Components:
# - Namespace: langbot
# - PersistentVolumeClaims for data persistence
# - Deployments for langbot and langbot_plugin_runtime
# - Services for network access
# - ConfigMap for timezone configuration
---
# Namespace
apiVersion: v1
kind: Namespace
metadata:
name: langbot
labels:
app: langbot
---
# PersistentVolumeClaim for LangBot data
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: langbot-data
namespace: langbot
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
# Uncomment and modify if you have a specific StorageClass
# storageClassName: your-storage-class
---
# PersistentVolumeClaim for LangBot plugins
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: langbot-plugins
namespace: langbot
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
# Uncomment and modify if you have a specific StorageClass
# storageClassName: your-storage-class
---
# PersistentVolumeClaim for Plugin Runtime data
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: langbot-plugin-runtime-data
namespace: langbot
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
# Uncomment and modify if you have a specific StorageClass
# storageClassName: your-storage-class
---
# ConfigMap for environment configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: langbot-config
namespace: langbot
data:
TZ: "Asia/Shanghai"
PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws"
---
# Deployment for LangBot Plugin Runtime
apiVersion: apps/v1
kind: Deployment
metadata:
name: langbot-plugin-runtime
namespace: langbot
labels:
app: langbot-plugin-runtime
spec:
replicas: 1
selector:
matchLabels:
app: langbot-plugin-runtime
template:
metadata:
labels:
app: langbot-plugin-runtime
spec:
containers:
- name: langbot-plugin-runtime
image: rockchin/langbot:latest
imagePullPolicy: Always
command: ["uv", "run", "-m", "langbot_plugin.cli.__init__", "rt"]
ports:
- containerPort: 5400
name: runtime
protocol: TCP
env:
- name: TZ
valueFrom:
configMapKeyRef:
name: langbot-config
key: TZ
volumeMounts:
- name: plugin-data
mountPath: /app/data/plugins
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "1000m"
# Liveness probe to restart container if it becomes unresponsive
livenessProbe:
tcpSocket:
port: 5400
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Readiness probe to know when container is ready to accept traffic
readinessProbe:
tcpSocket:
port: 5400
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: plugin-data
persistentVolumeClaim:
claimName: langbot-plugin-runtime-data
restartPolicy: Always
---
# Service for LangBot Plugin Runtime
apiVersion: v1
kind: Service
metadata:
name: langbot-plugin-runtime
namespace: langbot
labels:
app: langbot-plugin-runtime
spec:
type: ClusterIP
selector:
app: langbot-plugin-runtime
ports:
- port: 5400
targetPort: 5400
protocol: TCP
name: runtime
---
# Deployment for LangBot
apiVersion: apps/v1
kind: Deployment
metadata:
name: langbot
namespace: langbot
labels:
app: langbot
spec:
replicas: 1
selector:
matchLabels:
app: langbot
template:
metadata:
labels:
app: langbot
spec:
containers:
- name: langbot
image: rockchin/langbot:latest
imagePullPolicy: Always
ports:
- containerPort: 5300
name: web
protocol: TCP
- containerPort: 2280
name: webhook-start
protocol: TCP
# Note: Kubernetes doesn't support port ranges directly in container ports
# The webhook ports 2280-2290 are available, but we only expose the start of the range
# If you need all ports exposed, consider using a Service with multiple port definitions
env:
- name: TZ
valueFrom:
configMapKeyRef:
name: langbot-config
key: TZ
- name: PLUGIN__RUNTIME_WS_URL
valueFrom:
configMapKeyRef:
name: langbot-config
key: PLUGIN__RUNTIME_WS_URL
volumeMounts:
- name: data
mountPath: /app/data
- name: plugins
mountPath: /app/plugins
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "2000m"
# Liveness probe to restart container if it becomes unresponsive
livenessProbe:
httpGet:
path: /
port: 5300
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Readiness probe to know when container is ready to accept traffic
readinessProbe:
httpGet:
path: /
port: 5300
initialDelaySeconds: 30
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
claimName: langbot-data
- name: plugins
persistentVolumeClaim:
claimName: langbot-plugins
restartPolicy: Always
---
# Service for LangBot (ClusterIP for internal access)
apiVersion: v1
kind: Service
metadata:
name: langbot
namespace: langbot
labels:
app: langbot
spec:
type: ClusterIP
selector:
app: langbot
ports:
- port: 5300
targetPort: 5300
protocol: TCP
name: web
- port: 2280
targetPort: 2280
protocol: TCP
name: webhook-2280
- port: 2281
targetPort: 2281
protocol: TCP
name: webhook-2281
- port: 2282
targetPort: 2282
protocol: TCP
name: webhook-2282
- port: 2283
targetPort: 2283
protocol: TCP
name: webhook-2283
- port: 2284
targetPort: 2284
protocol: TCP
name: webhook-2284
- port: 2285
targetPort: 2285
protocol: TCP
name: webhook-2285
- port: 2286
targetPort: 2286
protocol: TCP
name: webhook-2286
- port: 2287
targetPort: 2287
protocol: TCP
name: webhook-2287
- port: 2288
targetPort: 2288
protocol: TCP
name: webhook-2288
- port: 2289
targetPort: 2289
protocol: TCP
name: webhook-2289
- port: 2290
targetPort: 2290
protocol: TCP
name: webhook-2290
---
# Ingress for external access (Optional - requires Ingress Controller)
# Uncomment and modify the following section if you want to expose LangBot via Ingress
# apiVersion: networking.k8s.io/v1
# kind: Ingress
# metadata:
# name: langbot-ingress
# namespace: langbot
# annotations:
# # Uncomment and modify based on your ingress controller
# # nginx.ingress.kubernetes.io/rewrite-target: /
# # cert-manager.io/cluster-issuer: letsencrypt-prod
# spec:
# ingressClassName: nginx # Change based on your ingress controller
# rules:
# - host: langbot.yourdomain.com # Change to your domain
# http:
# paths:
# - path: /
# pathType: Prefix
# backend:
# service:
# name: langbot
# port:
# number: 5300
# # Uncomment for TLS/HTTPS
# # tls:
# # - hosts:
# # - langbot.yourdomain.com
# # secretName: langbot-tls
---
# Service for LangBot with LoadBalancer (Alternative to Ingress)
# Uncomment the following if you want to expose LangBot directly via LoadBalancer
# This is useful in cloud environments (AWS, GCP, Azure, etc.)
# apiVersion: v1
# kind: Service
# metadata:
# name: langbot-loadbalancer
# namespace: langbot
# labels:
# app: langbot
# spec:
# type: LoadBalancer
# selector:
# app: langbot
# ports:
# - port: 80
# targetPort: 5300
# protocol: TCP
# name: web
# - port: 2280
# targetPort: 2280
# protocol: TCP
# name: webhook-start
# # Add more webhook ports as needed
---
# Service for LangBot with NodePort (Alternative for exposing service)
# Uncomment if you want to expose LangBot via NodePort
# This is useful for testing or when LoadBalancer is not available
# apiVersion: v1
# kind: Service
# metadata:
# name: langbot-nodeport
# namespace: langbot
# labels:
# app: langbot
# spec:
# type: NodePort
# selector:
# app: langbot
# ports:
# - port: 5300
# targetPort: 5300
# nodePort: 30300 # Must be in range 30000-32767
# protocol: TCP
# name: web
# - port: 2280
# targetPort: 2280
# nodePort: 30280 # Must be in range 30000-32767
# protocol: TCP
# name: webhook

View File

@@ -3,7 +3,7 @@
"info": {
"title": "LangBot API with API Key Authentication",
"description": "LangBot external service API documentation. These endpoints support API Key authentication \nfor external systems to programmatically access LangBot resources.\n\n**Authentication Methods:**\n- User Token (via `Authorization: Bearer <token>`)\n- API Key (via `X-API-Key: <key>` or `Authorization: Bearer <key>`)\n\nAll endpoints documented here accept BOTH authentication methods.\n",
"version": "4.4.1",
"version": "4.5.0",
"contact": {
"name": "LangBot",
"url": "https://langbot.app"

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.4.1"
version = "4.5.0"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
license-files = ["LICENSE"]
@@ -64,10 +64,11 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.1.9b2",
"langbot-plugin==0.1.11b1",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",
"boto3>=1.35.0",
]
keywords = [
"bot",

View File

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

View File

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

View File

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

View File

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

View File

@@ -105,17 +105,18 @@ class LLMModelsService:
else:
runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data)
# 有些模型厂商默认开启了思考功能,测试容易延迟
extra_args = model_data.get('extra_args', {})
if not extra_args or 'thinking' not in extra_args:
extra_args['thinking'] = {'type': 'disabled'}
# Mon Nov 10 2025: Commented for some providers may not support thinking parameter
# # 有些模型厂商默认开启了思考功能,测试容易延迟
# extra_args = model_data.get('extra_args', {})
# if not extra_args or 'thinking' not in extra_args:
# extra_args['thinking'] = {'type': 'disabled'}
await runtime_llm_model.requester.invoke_llm(
query=None,
model=runtime_llm_model,
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
funcs=[],
extra_args=extra_args,
# extra_args=extra_args,
)

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ from ...provider.modelmgr import modelmgr as llm_model_mgr
from ...provider.tools import toolmgr as llm_tool_mgr
from ...rag.knowledge import kbmgr as rag_mgr
from ...platform import botmgr as im_mgr
from ...platform.webhook_pusher import WebhookPusher
from ...persistence import mgr as persistencemgr
from ...api.http.controller import main as http_controller
from ...api.http.service import user as user_service
@@ -21,6 +22,7 @@ from ...api.http.service import bot as bot_service
from ...api.http.service import knowledge as knowledge_service
from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
@@ -89,6 +91,10 @@ class BuildAppStage(stage.BootingStage):
await im_mgr_inst.initialize()
ap.platform_mgr = im_mgr_inst
# Initialize webhook pusher
webhook_pusher_inst = WebhookPusher(ap)
ap.webhook_pusher = webhook_pusher_inst
pipeline_mgr = pipelinemgr.PipelineManager(ap)
await pipeline_mgr.initialize()
ap.pipeline_mgr = pipeline_mgr
@@ -130,5 +136,8 @@ class BuildAppStage(stage.BootingStage):
apikey_service_inst = apikey_service.ApiKeyService(ap)
ap.apikey_service = apikey_service_inst
webhook_service_inst = webhook_service.WebhookService(ap)
ap.webhook_service = webhook_service_inst
ctrl = controller.Controller(ap)
ap.ctrl = ctrl

View File

@@ -0,0 +1,22 @@
import sqlalchemy
from .base import Base
class Webhook(Base):
"""Webhook for pushing bot events to external systems"""
__tablename__ = 'webhooks'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
url = sqlalchemy.Column(sqlalchemy.String(1024), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)

View File

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

View File

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

View File

@@ -66,6 +66,12 @@ class RuntimeBot:
message_session_id=f'person_{event.sender.id}',
)
# Push to webhooks
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
asyncio.create_task(
self.ap.webhook_pusher.push_person_message(event, self.bot_entity.uuid, adapter.__class__.__name__)
)
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.PERSON,
@@ -91,6 +97,12 @@ class RuntimeBot:
message_session_id=f'group_{event.group.id}',
)
# Push to webhooks
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
asyncio.create_task(
self.ap.webhook_pusher.push_group_message(event, self.bot_entity.uuid, adapter.__class__.__name__)
)
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.GROUP,

View File

@@ -36,11 +36,24 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if atUser.dingtalk_id == event.incoming_message.chatbot_user_id:
yiri_msg_list.append(platform_message.At(target=bot_name))
if event.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.rich_content:
elements = event.rich_content.get('Elements')
for element in elements:
if element.get('Type') == 'text':
text = element.get('Content', '').replace('@' + bot_name, '')
if text.strip():
yiri_msg_list.append(platform_message.Plain(text=text))
elif element.get('Type') == 'image' and element.get('Picture'):
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
else:
# 回退到原有简单逻辑
if event.content:
text_content = event.content.replace('@' + bot_name, '')
yiri_msg_list.append(platform_message.Plain(text=text_content))
if event.picture:
yiri_msg_list.append(platform_message.Image(base64=event.picture))
# 处理其他类型消息(文件、音频等)
if event.file:
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
if event.audio:

View File

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

View File

@@ -129,18 +129,19 @@ class PluginRuntimeConnector:
# We have to launch runtime via cmd but communicate via ws.
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
python_path = sys.executable
env = os.environ.copy()
self.runtime_subprocess_on_windows = await asyncio.create_subprocess_exec(
python_path,
'-m',
'langbot_plugin.cli.__init__',
'rt',
env=env,
)
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())
# 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'

View File

@@ -8,24 +8,25 @@ metadata:
icon: 302ai.png
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.302.ai/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.302.ai/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- text-embedding
- llm
- text-embedding
provider_category: maas
execution:
python:
path: ./302aichatcmpl.py
attr: AI302ChatCompletions
attr: AI302ChatCompletions

View File

@@ -8,22 +8,23 @@ metadata:
icon: anthropic.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.anthropic.com"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.anthropic.com
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- llm
provider_category: manufacturer
execution:
python:
path: ./anthropicmsgs.py

View File

@@ -8,22 +8,23 @@ metadata:
icon: bailian.png
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://dashscope.aliyuncs.com/compatible-mode/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://dashscope.aliyuncs.com/compatible-mode/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- llm
provider_category: maas
execution:
python:
path: ./bailianchatcmpl.py

View File

@@ -8,24 +8,25 @@ metadata:
icon: openai.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.openai.com/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.openai.com/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- text-embedding
- llm
- text-embedding
provider_category: manufacturer
execution:
python:
path: ./chatcmpl.py
attr: OpenAIChatCompletions
attr: OpenAIChatCompletions

View File

@@ -8,23 +8,24 @@ metadata:
icon: compshare.png
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.modelverse.cn/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.modelverse.cn/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- llm
provider_category: maas
execution:
python:
path: ./compsharechatcmpl.py
attr: CompShareChatCompletions
attr: CompShareChatCompletions

View File

@@ -8,23 +8,24 @@ metadata:
icon: deepseek.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.deepseek.com"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.deepseek.com
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- llm
provider_category: manufacturer
execution:
python:
path: ./deepseekchatcmpl.py
attr: DeepseekChatCompletions
attr: DeepseekChatCompletions

View File

@@ -8,22 +8,23 @@ metadata:
icon: gemini.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://generativelanguage.googleapis.com/v1beta/openai"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://generativelanguage.googleapis.com/v1beta/openai
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- llm
provider_category: manufacturer
execution:
python:
path: ./geminichatcmpl.py

View File

@@ -8,24 +8,25 @@ metadata:
icon: giteeai.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://ai.gitee.com/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://ai.gitee.com/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- text-embedding
- llm
- text-embedding
provider_category: maas
execution:
python:
path: ./giteeaichatcmpl.py
attr: GiteeAIChatCompletions
attr: GiteeAIChatCompletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

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

View File

@@ -8,23 +8,24 @@ metadata:
icon: lmstudio.webp
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "http://127.0.0.1:1234/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: http://127.0.0.1:1234/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- text-embedding
- llm
- text-embedding
provider_category: self-hosted
execution:
python:
path: ./lmstudiochatcmpl.py

View File

@@ -8,29 +8,30 @@ metadata:
icon: modelscope.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api-inference.modelscope.cn/v1"
- 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
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api-inference.modelscope.cn/v1
- 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
- llm
provider_category: maas
execution:
python:
path: ./modelscopechatcmpl.py

View File

@@ -8,22 +8,23 @@ metadata:
icon: moonshot.png
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.moonshot.ai/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.moonshot.ai/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- llm
provider_category: manufacturer
execution:
python:
path: ./moonshotchatcmpl.py

View File

@@ -8,24 +8,25 @@ metadata:
icon: newapi.png
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "http://localhost:3000/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: http://localhost:3000/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- text-embedding
- llm
- text-embedding
provider_category: maas
execution:
python:
path: ./newapichatcmpl.py
attr: NewAPIChatCompletions
attr: NewAPIChatCompletions

View File

@@ -8,23 +8,24 @@ metadata:
icon: ollama.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "http://127.0.0.1:11434"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: http://127.0.0.1:11434
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- text-embedding
- llm
- text-embedding
provider_category: self-hosted
execution:
python:
path: ./ollamachat.py

View File

@@ -8,23 +8,24 @@ metadata:
icon: openrouter.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://openrouter.ai/api/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://openrouter.ai/api/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- text-embedding
- llm
- text-embedding
provider_category: maas
execution:
python:
path: ./openrouterchatcmpl.py

View File

@@ -3,36 +3,37 @@ kind: LLMAPIRequester
metadata:
name: ppio-chat-completions
label:
en_US: ppio
en_US: ppio
zh_Hans: 派欧云
icon: ppio.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.ppinfra.com/v3/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
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.ppinfra.com/v3/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
- llm
- text-embedding
provider_category: maas
execution:
python:
path: ./ppiochatcmpl.py
attr: PPIOChatCompletions
attr: PPIOChatCompletions

View File

@@ -8,31 +8,32 @@ metadata:
icon: qhaigc.png
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.qhaigc.net/v1"
- 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
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.qhaigc.net/v1
- 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
- llm
- text-embedding
provider_category: maas
execution:
python:
path: ./qhaigcchatcmpl.py
attr: QHAIGCChatCompletions
attr: QHAIGCChatCompletions

View File

@@ -8,31 +8,32 @@ metadata:
icon: shengsuanyun.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://router.shengsuanyun.com/api/v1"
- 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
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://router.shengsuanyun.com/api/v1
- 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
- llm
- text-embedding
provider_category: maas
execution:
python:
path: ./shengsuanyun.py
attr: ShengSuanYunChatCompletions
attr: ShengSuanYunChatCompletions

View File

@@ -8,23 +8,24 @@ metadata:
icon: siliconflow.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.siliconflow.cn/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.siliconflow.cn/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- text-embedding
- llm
- text-embedding
provider_category: maas
execution:
python:
path: ./siliconflowchatcmpl.py

View File

@@ -8,24 +8,25 @@ metadata:
icon: tokenpony.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.tokenpony.cn/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.tokenpony.cn/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- text-embedding
- llm
- text-embedding
provider_category: maas
execution:
python:
path: ./tokenponychatcmpl.py
attr: TokenPonyChatCompletions
attr: TokenPonyChatCompletions

View File

@@ -8,22 +8,23 @@ metadata:
icon: volcark.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://ark.cn-beijing.volces.com/api/v3"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://ark.cn-beijing.volces.com/api/v3
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- llm
provider_category: maas
execution:
python:
path: ./volcarkchatcmpl.py

View File

@@ -8,22 +8,23 @@ metadata:
icon: xai.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.x.ai/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.x.ai/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- llm
provider_category: manufacturer
execution:
python:
path: ./xaichatcmpl.py

View File

@@ -8,22 +8,23 @@ metadata:
icon: zhipuai.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://open.bigmodel.cn/api/paas/v4"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://open.bigmodel.cn/api/paas/v4
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- llm
provider_category: manufacturer
execution:
python:
path: ./zhipuaichatcmpl.py

View File

@@ -121,7 +121,7 @@ class CozeAPIRunner(runner.RequestRunner):
注意由于cozepy没有提供非流式API这里使用流式API并在结束后一次性返回完整内容
"""
user_id = f'{query.launcher_id}_{query.sender_id}'
user_id = f'{query.launcher_type.value}_{query.launcher_id}'
# 预处理用户消息
additional_messages = await self._preprocess_user_message(query)
@@ -201,7 +201,7 @@ class CozeAPIRunner(runner.RequestRunner):
self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
"""调用聊天助手(流式)"""
user_id = f'{query.launcher_id}_{query.sender_id}'
user_id = f'{query.launcher_type.value}_{query.launcher_id}'
# 预处理用户消息
additional_messages = await self._preprocess_user_message(query)

View File

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

View File

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

View File

@@ -3,11 +3,11 @@ from __future__ import annotations
from ..core import app
from . import provider
from .providers import localstorage
from .providers import localstorage, s3storage
class StorageMgr:
"""存储管理器"""
"""Storage manager"""
ap: app.Application
@@ -15,7 +15,16 @@ class StorageMgr:
def __init__(self, ap: app.Application):
self.ap = ap
self.storage_provider = localstorage.LocalStorageProvider(ap)
async def initialize(self):
storage_config = self.ap.instance_config.data.get('storage', {})
storage_type = storage_config.get('use', 'local')
if storage_type == 's3':
self.storage_provider = s3storage.S3StorageProvider(self.ap)
self.ap.logger.info('Initialized S3 storage backend.')
else:
self.storage_provider = localstorage.LocalStorageProvider(self.ap)
self.ap.logger.info('Initialized local storage backend.')
await self.storage_provider.initialize()

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
import boto3
from botocore.exceptions import ClientError
from ...core import app
from .. import provider
class S3StorageProvider(provider.StorageProvider):
"""S3 object storage provider"""
def __init__(self, ap: app.Application):
super().__init__(ap)
self.s3_client = None
self.bucket_name = None
async def initialize(self):
"""Initialize S3 client with configuration from config.yaml"""
storage_config = self.ap.instance_config.data.get('storage', {})
s3_config = storage_config.get('s3', {})
# Get S3 configuration
endpoint_url = s3_config.get('endpoint_url', '')
access_key_id = s3_config.get('access_key_id', '')
secret_access_key = s3_config.get('secret_access_key', '')
region_name = s3_config.get('region', 'us-east-1')
self.bucket_name = s3_config.get('bucket', 'langbot-storage')
# Initialize S3 client
session = boto3.session.Session()
self.s3_client = session.client(
service_name='s3',
region_name=region_name,
endpoint_url=endpoint_url if endpoint_url else None,
aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key,
)
# Ensure bucket exists
try:
self.s3_client.head_bucket(Bucket=self.bucket_name)
except ClientError as e:
error_code = e.response['Error']['Code']
if error_code == '404':
# Bucket doesn't exist, create it
try:
self.s3_client.create_bucket(Bucket=self.bucket_name)
self.ap.logger.info(f'Created S3 bucket: {self.bucket_name}')
except Exception as create_error:
self.ap.logger.error(f'Failed to create S3 bucket: {create_error}')
raise
else:
self.ap.logger.error(f'Failed to access S3 bucket: {e}')
raise
async def save(
self,
key: str,
value: bytes,
):
"""Save bytes to S3"""
try:
self.s3_client.put_object(
Bucket=self.bucket_name,
Key=key,
Body=value,
)
except Exception as e:
self.ap.logger.error(f'Failed to save to S3: {e}')
raise
async def load(
self,
key: str,
) -> bytes:
"""Load bytes from S3"""
try:
response = self.s3_client.get_object(
Bucket=self.bucket_name,
Key=key,
)
return response['Body'].read()
except Exception as e:
self.ap.logger.error(f'Failed to load from S3: {e}')
raise
async def exists(
self,
key: str,
) -> bool:
"""Check if object exists in S3"""
try:
self.s3_client.head_object(
Bucket=self.bucket_name,
Key=key,
)
return True
except ClientError as e:
if e.response['Error']['Code'] == '404':
return False
else:
self.ap.logger.error(f'Failed to check existence in S3: {e}')
raise
async def delete(
self,
key: str,
):
"""Delete object from S3"""
try:
self.s3_client.delete_object(
Bucket=self.bucket_name,
Key=key,
)
except Exception as e:
self.ap.logger.error(f'Failed to delete from S3: {e}')
raise
async def delete_dir_recursive(
self,
dir_path: str,
):
"""Delete all objects with the given prefix (directory)"""
try:
# Ensure dir_path ends with /
if not dir_path.endswith('/'):
dir_path = dir_path + '/'
# List all objects with the prefix
paginator = self.s3_client.get_paginator('list_objects_v2')
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=dir_path)
# Delete all objects
for page in pages:
if 'Contents' in page:
objects_to_delete = [{'Key': obj['Key']} for obj in page['Contents']]
if objects_to_delete:
self.s3_client.delete_objects(
Bucket=self.bucket_name,
Delete={'Objects': objects_to_delete},
)
except Exception as e:
self.ap.logger.error(f'Failed to delete directory from S3: {e}')
raise

View File

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

View File

@@ -35,6 +35,14 @@ vdb:
host: localhost
port: 6333
api_key: ''
storage:
use: local
s3:
endpoint_url: ''
access_key_id: ''
secret_access_key: ''
region: 'us-east-1'
bucket: 'langbot-storage'
plugin:
enable: true
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'

View File

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

View File

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

View File

View File

@@ -0,0 +1,100 @@
"""
Tests for storage manager and provider selection
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from pkg.storage.mgr import StorageMgr
from pkg.storage.providers.localstorage import LocalStorageProvider
from pkg.storage.providers.s3storage import S3StorageProvider
class TestStorageProviderSelection:
"""Test storage provider selection based on configuration"""
@pytest.mark.asyncio
async def test_default_to_local_storage(self):
"""Test that local storage is used by default when no config is provided"""
# Mock application
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
mock_init.assert_called_once()
@pytest.mark.asyncio
async def test_explicit_local_storage(self):
"""Test that local storage is used when explicitly configured"""
# Mock application
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'storage': {
'use': 'local'
}
}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
mock_init.assert_called_once()
@pytest.mark.asyncio
async def test_s3_storage_provider_selection(self):
"""Test that S3 storage is used when configured"""
# Mock application
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'storage': {
'use': 's3',
's3': {
'endpoint_url': 'https://s3.amazonaws.com',
'access_key_id': 'test_key',
'secret_access_key': 'test_secret',
'region': 'us-east-1',
'bucket': 'test-bucket'
}
}
}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(S3StorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, S3StorageProvider)
mock_init.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_storage_type_defaults_to_local(self):
"""Test that invalid storage type defaults to local storage"""
# Mock application
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'storage': {
'use': 'invalid_type'
}
}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
mock_init.assert_called_once()
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

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

View File

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

View File

@@ -0,0 +1,678 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Trash2, Plus } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogPortal,
AlertDialogOverlay,
} from '@/components/ui/alert-dialog';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { backendClient } from '@/app/infra/http';
interface ApiKey {
id: number;
name: string;
key: string;
description: string;
created_at: string;
}
interface Webhook {
id: number;
name: string;
url: string;
description: string;
enabled: boolean;
created_at: string;
}
interface ApiIntegrationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function ApiIntegrationDialog({
open,
onOpenChange,
}: ApiIntegrationDialogProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('apikeys');
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
const [loading, setLoading] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyDescription, setNewKeyDescription] = useState('');
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);
// Webhook state
const [showCreateWebhookDialog, setShowCreateWebhookDialog] = useState(false);
const [newWebhookName, setNewWebhookName] = useState('');
const [newWebhookUrl, setNewWebhookUrl] = useState('');
const [newWebhookDescription, setNewWebhookDescription] = useState('');
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
// 清理 body 样式,防止对话框关闭后页面无法交互
useEffect(() => {
if (!deleteKeyId && !deleteWebhookId) {
const cleanup = () => {
document.body.style.removeProperty('pointer-events');
};
cleanup();
const timer = setTimeout(cleanup, 100);
return () => clearTimeout(timer);
}
}, [deleteKeyId, deleteWebhookId]);
useEffect(() => {
if (open) {
loadApiKeys();
loadWebhooks();
}
}, [open]);
const loadApiKeys = async () => {
setLoading(true);
try {
const response = (await backendClient.get('/api/v1/apikeys')) as {
keys: ApiKey[];
};
setApiKeys(response.keys || []);
} catch (error) {
toast.error(`Failed to load API keys: ${error}`);
} finally {
setLoading(false);
}
};
const handleCreateApiKey = async () => {
if (!newKeyName.trim()) {
toast.error(t('common.apiKeyNameRequired'));
return;
}
try {
const response = (await backendClient.post('/api/v1/apikeys', {
name: newKeyName,
description: newKeyDescription,
})) as { key: ApiKey };
setCreatedKey(response.key);
toast.success(t('common.apiKeyCreated'));
setNewKeyName('');
setNewKeyDescription('');
setShowCreateDialog(false);
loadApiKeys();
} catch (error) {
toast.error(`Failed to create API key: ${error}`);
}
};
const handleDeleteApiKey = async (keyId: number) => {
try {
await backendClient.delete(`/api/v1/apikeys/${keyId}`);
toast.success(t('common.apiKeyDeleted'));
loadApiKeys();
setDeleteKeyId(null);
} catch (error) {
toast.error(`Failed to delete API key: ${error}`);
}
};
const handleCopyKey = (key: string) => {
navigator.clipboard.writeText(key);
toast.success(t('common.apiKeyCopied'));
};
const maskApiKey = (key: string) => {
if (key.length <= 8) return key;
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`;
};
// Webhook methods
const loadWebhooks = async () => {
setLoading(true);
try {
const response = (await backendClient.get('/api/v1/webhooks')) as {
webhooks: Webhook[];
};
setWebhooks(response.webhooks || []);
} catch (error) {
toast.error(`Failed to load webhooks: ${error}`);
} finally {
setLoading(false);
}
};
const handleCreateWebhook = async () => {
if (!newWebhookName.trim()) {
toast.error(t('common.webhookNameRequired'));
return;
}
if (!newWebhookUrl.trim()) {
toast.error(t('common.webhookUrlRequired'));
return;
}
try {
await backendClient.post('/api/v1/webhooks', {
name: newWebhookName,
url: newWebhookUrl,
description: newWebhookDescription,
enabled: newWebhookEnabled,
});
toast.success(t('common.webhookCreated'));
setNewWebhookName('');
setNewWebhookUrl('');
setNewWebhookDescription('');
setNewWebhookEnabled(true);
setShowCreateWebhookDialog(false);
loadWebhooks();
} catch (error) {
toast.error(`Failed to create webhook: ${error}`);
}
};
const handleDeleteWebhook = async (webhookId: number) => {
try {
await backendClient.delete(`/api/v1/webhooks/${webhookId}`);
toast.success(t('common.webhookDeleted'));
loadWebhooks();
setDeleteWebhookId(null);
} catch (error) {
toast.error(`Failed to delete webhook: ${error}`);
}
};
const handleToggleWebhook = async (webhook: Webhook) => {
try {
await backendClient.put(`/api/v1/webhooks/${webhook.id}`, {
enabled: !webhook.enabled,
});
loadWebhooks();
} catch (error) {
toast.error(`Failed to update webhook: ${error}`);
}
};
return (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
// 如果删除确认框是打开的,不允许关闭主对话框
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
return;
}
onOpenChange(newOpen);
}}
>
<DialogContent className="sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
{t('common.apiKeys')}
</TabsTrigger>
<TabsTrigger
className="px-5 py-4 cursor-pointer"
value="webhooks"
>
{t('common.webhooks')}
</TabsTrigger>
</TabsList>
{/* API Keys Tab */}
<TabsContent value="apikeys" className="space-y-4">
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.apiKeyHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.apiKeyValue')}</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((key) => (
<TableRow key={key.id}>
<TableCell>
<div>
<div className="font-medium">{key.name}</div>
{key.description && (
<div className="text-sm text-muted-foreground">
{key.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(key.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyKey(key.key)}
title={t('common.copyApiKey')}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(key.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* Webhooks Tab */}
<TabsContent value="webhooks" className="space-y-4">
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.webhookHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.webhookUrl')}</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell>
<div>
<div className="font-medium">{webhook.name}</div>
{webhook.description && (
<div className="text-sm text-muted-foreground">
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded break-all">
{webhook.url}
</code>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() =>
handleToggleWebhook(webhook)
}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create API Key Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.createApiKey')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">{t('common.name')}</label>
<Input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder={t('common.name')}
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('common.description')}
</label>
<Input
value={newKeyDescription}
onChange={(e) => setNewKeyDescription(e.target.value)}
placeholder={t('common.description')}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleCreateApiKey}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Show Created Key Dialog */}
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
<DialogDescription>
{t('common.apiKeyCreatedMessage')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t('common.apiKeyValue')}
</label>
<div className="flex gap-2 mt-1">
<Input value={createdKey?.key || ''} readOnly />
<Button
onClick={() => createdKey && handleCopyKey(createdKey.key)}
variant="outline"
size="icon"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create Webhook Dialog */}
<Dialog
open={showCreateWebhookDialog}
onOpenChange={setShowCreateWebhookDialog}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.createWebhook')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">{t('common.name')}</label>
<Input
value={newWebhookName}
onChange={(e) => setNewWebhookName(e.target.value)}
placeholder={t('common.webhookName')}
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('common.webhookUrl')}
</label>
<Input
value={newWebhookUrl}
onChange={(e) => setNewWebhookUrl(e.target.value)}
placeholder="https://example.com/webhook"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('common.description')}
</label>
<Input
value={newWebhookDescription}
onChange={(e) => setNewWebhookDescription(e.target.value)}
placeholder={t('common.description')}
className="mt-1"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={newWebhookEnabled}
onCheckedChange={setNewWebhookEnabled}
/>
<label className="text-sm font-medium">
{t('common.webhookEnabled')}
</label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateWebhookDialog(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleCreateWebhook}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete API Key Confirmation Dialog */}
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
<DialogDescription>
{t('common.apiKeyCreatedMessage')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t('common.apiKeyValue')}
</label>
<div className="flex gap-2 mt-1">
<Input value={createdKey?.key || ''} readOnly />
<Button
onClick={() => createdKey && handleCopyKey(createdKey.key)}
variant="outline"
size="icon"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteKeyId}>
<AlertDialogPortal>
<AlertDialogOverlay
className="z-[60]"
onClick={() => setDeleteKeyId(null)}
/>
<AlertDialogPrimitive.Content
className="fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg"
onEscapeKeyDown={() => setDeleteKeyId(null)}
>
<AlertDialogHeader>
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('common.apiKeyDeleteConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteKeyId(null)}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteKeyId && handleDeleteApiKey(deleteKeyId)}
>
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
</AlertDialog>
{/* Delete Webhook Confirmation Dialog */}
<AlertDialog open={!!deleteWebhookId}>
<AlertDialogPortal>
<AlertDialogOverlay
className="z-[60]"
onClick={() => setDeleteWebhookId(null)}
/>
<AlertDialogPrimitive.Content
className="fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg"
onEscapeKeyDown={() => setDeleteWebhookId(null)}
>
<AlertDialogHeader>
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('common.webhookDeleteConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteWebhookId(null)}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
deleteWebhookId && handleDeleteWebhook(deleteWebhookId)
}
>
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
</AlertDialog>
</>
);
}

View File

@@ -1,379 +0,0 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Trash2, Plus } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogPortal,
AlertDialogOverlay,
} from '@/components/ui/alert-dialog';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { backendClient } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
interface ApiKey {
id: number;
name: string;
key: string;
description: string;
created_at: string;
}
interface ApiKeyManagementDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function ApiKeyManagementDialog({
open,
onOpenChange,
}: ApiKeyManagementDialogProps) {
const { t } = useTranslation();
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyDescription, setNewKeyDescription] = useState('');
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);
// 清理 body 样式,防止对话框关闭后页面无法交互
useEffect(() => {
if (!deleteKeyId) {
const cleanup = () => {
document.body.style.removeProperty('pointer-events');
};
cleanup();
const timer = setTimeout(cleanup, 100);
return () => clearTimeout(timer);
}
}, [deleteKeyId]);
useEffect(() => {
if (open) {
loadApiKeys();
}
}, [open]);
const loadApiKeys = async () => {
setLoading(true);
try {
const response = (await backendClient.get('/api/v1/apikeys')) as {
keys: ApiKey[];
};
setApiKeys(response.keys || []);
} catch (error) {
toast.error(`Failed to load API keys: ${error}`);
} finally {
setLoading(false);
}
};
const handleCreateApiKey = async () => {
if (!newKeyName.trim()) {
toast.error(t('common.apiKeyNameRequired'));
return;
}
try {
const response = (await backendClient.post('/api/v1/apikeys', {
name: newKeyName,
description: newKeyDescription,
})) as { key: ApiKey };
setCreatedKey(response.key);
toast.success(t('common.apiKeyCreated'));
setNewKeyName('');
setNewKeyDescription('');
setShowCreateDialog(false);
loadApiKeys();
} catch (error) {
toast.error(`Failed to create API key: ${error}`);
}
};
const handleDeleteApiKey = async (keyId: number) => {
try {
await backendClient.delete(`/api/v1/apikeys/${keyId}`);
toast.success(t('common.apiKeyDeleted'));
loadApiKeys();
setDeleteKeyId(null);
} catch (error) {
toast.error(`Failed to delete API key: ${error}`);
}
};
const handleCopyKey = (key: string) => {
navigator.clipboard.writeText(key);
toast.success(t('common.apiKeyCopied'));
};
const maskApiKey = (key: string) => {
if (key.length <= 8) return key;
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`;
};
return (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
// 如果删除确认框是打开的,不允许关闭主对话框
if (!newOpen && deleteKeyId) {
return;
}
onOpenChange(newOpen);
}}
>
<DialogContent className="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>{t('common.manageApiKeys')}</DialogTitle>
<DialogDescription>
<span className="cursor-pointer flex items-center gap-1">
{t('common.apiKeyHint')}
<div
onClick={() => {
window.open(
extractI18nObject({
zh_Hans: 'https://docs.langbot.app/zh/tags/readme',
en_US: 'https://docs.langbot.app/en/tags/readme',
}),
'_blank',
);
}}
className="cursor-pointer"
>
<svg
className="w-[1rem] h-[1rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
</svg>
</div>
</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex justify-end">
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.apiKeyValue')}</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((key) => (
<TableRow key={key.id}>
<TableCell>
<div>
<div className="font-medium">{key.name}</div>
{key.description && (
<div className="text-sm text-muted-foreground">
{key.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(key.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyKey(key.key)}
title={t('common.copyApiKey')}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(key.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create API Key Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.createApiKey')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">{t('common.name')}</label>
<Input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder={t('common.name')}
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('common.description')}
</label>
<Input
value={newKeyDescription}
onChange={(e) => setNewKeyDescription(e.target.value)}
placeholder={t('common.description')}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleCreateApiKey}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Show Created Key Dialog */}
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
<DialogDescription>
{t('common.apiKeyCreatedMessage')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t('common.apiKeyValue')}
</label>
<div className="flex gap-2 mt-1">
<Input value={createdKey?.key || ''} readOnly />
<Button
onClick={() => createdKey && handleCopyKey(createdKey.key)}
variant="outline"
size="icon"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteKeyId}>
<AlertDialogPortal>
<AlertDialogOverlay
className="z-[60]"
onClick={() => setDeleteKeyId(null)}
/>
<AlertDialogPrimitive.Content
className="fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg"
onEscapeKeyDown={() => setDeleteKeyId(null)}
>
<AlertDialogHeader>
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('common.apiKeyDeleteConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteKeyId(null)}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteKeyId && handleDeleteApiKey(deleteKeyId)}
>
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
</AlertDialog>
</>
);
}

View File

@@ -58,6 +58,9 @@ export default function DynamicFormComponent({
case 'knowledge-base-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-multi-selector':
fieldSchema = z.array(z.string());
break;
case 'bot-selector':
fieldSchema = z.string();
break;

View File

@@ -29,6 +29,15 @@ import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X } from 'lucide-react';
export default function DynamicFormItemComponent({
config,
@@ -44,6 +53,8 @@ export default function DynamicFormItemComponent({
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [bots, setBots] = useState<Bot[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const { t } = useTranslation();
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
@@ -90,7 +101,10 @@ export default function DynamicFormItemComponent({
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR) {
if (
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
config.type === DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR
) {
httpClient
.getKnowledgeBases()
.then((resp) => {
@@ -336,6 +350,128 @@ export default function DynamicFormItemComponent({
</Select>
);
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
return (
<>
<div className="space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
{field.value.map((kbId: string) => {
const kb = knowledgeBases.find((base) => base.uuid === kbId);
if (!kb) return null;
return (
<div
key={kbId}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex-1">
<div className="font-medium">{kb.name}</div>
{kb.description && (
<div className="text-sm text-muted-foreground">
{kb.description}
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newValue = field.value.filter(
(id: string) => id !== kbId,
);
field.onChange(newValue);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
) : (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('knowledge.noKnowledgeBaseSelected')}
</p>
</div>
)}
</div>
<Button
type="button"
onClick={() => {
setTempSelectedKBIds(field.value || []);
setKbDialogOpen(true);
}}
variant="outline"
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
{t('knowledge.addKnowledgeBase')}
</Button>
{/* Knowledge Base Selection Dialog */}
<Dialog open={kbDialogOpen} onOpenChange={setKbDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('knowledge.selectKnowledgeBases')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{knowledgeBases.map((base) => {
const isSelected = tempSelectedKBIds.includes(
base.uuid ?? '',
);
return (
<div
key={base.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
const kbId = base.uuid ?? '';
setTempSelectedKBIds((prev) =>
prev.includes(kbId)
? prev.filter((id) => id !== kbId)
: [...prev, kbId],
);
}}
>
<Checkbox
checked={isSelected}
aria-label={`Select ${base.name}`}
/>
<div className="flex-1">
<div className="font-medium">{base.name}</div>
{base.description && (
<div className="text-sm text-muted-foreground">
{base.description}
</div>
)}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setKbDialogOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
field.onChange(tempSelectedKBIds);
setKbDialogOpen(false);
}}
>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
case DynamicFormItemType.BOT_SELECTOR:
return (
<Select value={field.value} onValueChange={field.onChange}>

View File

@@ -25,7 +25,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { LanguageSelector } from '@/components/ui/language-selector';
import { Badge } from '@/components/ui/badge';
import PasswordChangeDialog from '@/app/home/components/password-change-dialog/PasswordChangeDialog';
import ApiKeyManagementDialog from '@/app/home/components/api-key-management-dialog/ApiKeyManagementDialog';
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
// TODO 侧边导航栏要加动画
export default function HomeSidebar({
@@ -236,7 +236,7 @@ export default function HomeSidebar({
<path d="M10.7577 11.8281L18.6066 3.97919L20.0208 5.3934L18.6066 6.80761L21.0815 9.28249L19.6673 10.6967L17.1924 8.22183L15.7782 9.63604L17.8995 11.7574L16.4853 13.1716L14.364 11.0503L12.1719 13.2423C13.4581 15.1837 13.246 17.8251 11.5355 19.5355C9.58291 21.4882 6.41709 21.4882 4.46447 19.5355C2.51184 17.5829 2.51184 14.4171 4.46447 12.4645C6.17493 10.754 8.81633 10.5419 10.7577 11.8281ZM10.1213 18.1213C11.2929 16.9497 11.2929 15.0503 10.1213 13.8787C8.94975 12.7071 7.05025 12.7071 5.87868 13.8787C4.70711 15.0503 4.70711 16.9497 5.87868 18.1213C7.05025 19.2929 8.94975 19.2929 10.1213 18.1213Z"></path>
</svg>
}
name={t('common.apiKeys')}
name={t('common.apiIntegration')}
/>
<Popover
@@ -345,7 +345,7 @@ export default function HomeSidebar({
open={passwordChangeOpen}
onOpenChange={setPasswordChangeOpen}
/>
<ApiKeyManagementDialog
<ApiIntegrationDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
/>

View File

@@ -13,7 +13,6 @@ import {
FormMessage,
FormDescription,
} from '@/components/ui/form';
import { IEmbeddingModelEntity } from './ChooseEntity';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Select,
@@ -23,8 +22,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { KnowledgeBase } from '@/app/infra/entities/api';
import { KnowledgeBase, EmbeddingModel } from '@/app/infra/entities/api';
import { toast } from 'sonner';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
const getFormSchema = (t: (key: string) => string) =>
z.object({
@@ -63,9 +67,7 @@ export default function KBForm({
},
});
const [embeddingModelNameList, setEmbeddingModelNameList] = useState<
IEmbeddingModelEntity[]
>([]);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
useEffect(() => {
getEmbeddingModelNameList().then(() => {
@@ -97,14 +99,7 @@ export default function KBForm({
const getEmbeddingModelNameList = async () => {
const resp = await httpClient.getProviderEmbeddingModels();
setEmbeddingModelNameList(
resp.models.map((item) => {
return {
label: item.name,
value: item.uuid,
};
}),
);
setEmbeddingModels(resp.models);
};
const onSubmit = (data: z.infer<typeof formSchema>) => {
@@ -216,10 +211,87 @@ export default function KBForm({
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{embeddingModelNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
{embeddingModels.map((model) => (
<HoverCard
key={model.uuid}
openDelay={0}
closeDelay={0}
>
<HoverCardTrigger asChild>
<SelectItem value={model.uuid}>
{model.name}
</SelectItem>
</HoverCardTrigger>
<HoverCardContent
className="w-80 data-[state=open]:animate-none data-[state=closed]:animate-none"
align="end"
side="right"
sideOffset={10}
>
<div className="space-y-2">
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
model.requester,
)}
alt="icon"
className="w-8 h-8 rounded-full"
/>
<h4 className="font-medium">
{model.name}
</h4>
</div>
<p className="text-sm text-muted-foreground">
{model.description}
</p>
{model.requester_config && (
<div className="flex items-center gap-1 text-xs">
<svg
className="w-4 h-4 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M13.0607 8.11097L14.4749 9.52518C17.2086 12.2589 17.2086 16.691 14.4749 19.4247L14.1214 19.7782C11.3877 22.5119 6.95555 22.5119 4.22188 19.7782C1.48821 17.0446 1.48821 12.6124 4.22188 9.87874L5.6361 11.293C3.68348 13.2456 3.68348 16.4114 5.6361 18.364C7.58872 20.3166 10.7545 20.3166 12.7072 18.364L13.0607 18.0105C15.0133 16.0578 15.0133 12.892 13.0607 10.9394L11.6465 9.52518L13.0607 8.11097ZM19.7782 14.1214L18.364 12.7072C20.3166 10.7545 20.3166 7.58872 18.364 5.6361C16.4114 3.68348 13.2456 3.68348 11.293 5.6361L10.9394 5.98965C8.98678 7.94227 8.98678 11.1081 10.9394 13.0607L12.3536 14.4749L10.9394 15.8891L9.52518 14.4749C6.79151 11.7413 6.79151 7.30911 9.52518 4.57544L9.87874 4.22188C12.6124 1.48821 17.0446 1.48821 19.7782 4.22188C22.5119 6.95555 22.5119 11.3877 19.7782 14.1214Z"></path>
</svg>
<span className="font-semibold">
Base URL
</span>
{model.requester_config.base_url}
</div>
)}
{model.extra_args &&
Object.keys(model.extra_args).length >
0 && (
<div className="text-xs">
<div className="font-semibold mb-1">
{t('models.extraParameters')}
</div>
<div className="space-y-1">
{Object.entries(
model.extra_args as Record<
string,
unknown
>,
).map(([key, value]) => (
<div
key={key}
className="flex items-center gap-1"
>
<span className="text-gray-500">
{key}
</span>
<span className="break-all">
{JSON.stringify(value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
))}
</SelectGroup>
</SelectContent>

View File

@@ -1,4 +1,5 @@
export interface IChooseRequesterEntity {
label: string;
value: string;
provider_category?: string;
}

View File

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

View File

@@ -34,6 +34,7 @@ import {
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
@@ -186,6 +187,7 @@ export default function EmbeddingForm({
return {
label: extractI18nObject(item.label),
value: item.name,
provider_category: item.spec.provider_category || 'manufacturer',
};
}),
);
@@ -425,11 +427,44 @@ export default function EmbeddingForm({
</SelectTrigger>
<SelectContent>
<SelectGroup>
{requesterNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
<SelectLabel>
{t('models.modelManufacturer')}
</SelectLabel>
{requesterNameList
.filter(
(item) =>
item.provider_category === 'manufacturer',
)
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.aggregationPlatform')}
</SelectLabel>
{requesterNameList
.filter((item) => item.provider_category === 'maas')
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{t('models.selfDeployed')}</SelectLabel>
{requesterNameList
.filter(
(item) =>
item.provider_category === 'self-hosted',
)
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>

View File

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

View File

@@ -34,6 +34,7 @@ import {
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
@@ -203,6 +204,7 @@ export default function LLMForm({
return {
label: extractI18nObject(item.label),
value: item.name,
provider_category: item.spec.provider_category || 'manufacturer',
};
}),
);
@@ -440,11 +442,44 @@ export default function LLMForm({
</SelectTrigger>
<SelectContent>
<SelectGroup>
{requesterNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
<SelectLabel>
{t('models.modelManufacturer')}
</SelectLabel>
{requesterNameList
.filter(
(item) =>
item.provider_category === 'manufacturer',
)
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.aggregationPlatform')}
</SelectLabel>
{requesterNameList
.filter((item) => item.provider_category === 'maas')
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{t('models.selfDeployed')}</SelectLabel>
{requesterNameList
.filter(
(item) =>
item.provider_category === 'self-hosted',
)
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -39,7 +39,6 @@ export default function PipelineDialog({
onOpenChange,
pipelineId: propPipelineId,
isEditMode = false,
isDefaultPipeline = false,
onFinish,
onNewPipelineCreated,
onDeletePipeline,
@@ -133,7 +132,6 @@ export default function PipelineDialog({
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<PipelineFormComponent
isDefaultPipeline={isDefaultPipeline}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
isEditMode={isEditMode}
@@ -192,12 +190,11 @@ export default function PipelineDialog({
<DialogTitle>{getDialogTitle()}</DialogTitle>
</DialogHeader>
<div
className="flex-1 auto px-6 pb-4 w-full"
className="flex-1 overflow-y-auto px-6 pb-4 w-full"
style={{ height: 'calc(100% - 4rem)' }}
>
{currentMode === 'config' && (
<PipelineFormComponent
isDefaultPipeline={isDefaultPipeline}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
isEditMode={isEditMode}

View File

@@ -286,8 +286,8 @@ export default function PipelineExtension({
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-white" />
<span className="text-xs text-white">
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark:text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
@@ -416,14 +416,17 @@ export default function PipelineExtension({
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<div className="flex items-center gap-1 mt-1">
<Wrench className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark:text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</div>
</Badge>
)}
</div>
{!server.enable && (

View File

@@ -33,7 +33,6 @@ import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
export default function PipelineFormComponent({
isDefaultPipeline,
onFinish,
onNewPipelineCreated,
isEditMode,
@@ -43,7 +42,6 @@ export default function PipelineFormComponent({
onCancel,
}: {
pipelineId?: string;
isDefaultPipeline: boolean;
isEditMode: boolean;
disableForm: boolean;
showButtons?: boolean;
@@ -54,6 +52,7 @@ export default function PipelineFormComponent({
}) {
const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
const formSchema = isEditMode
? z.object({
@@ -133,6 +132,7 @@ export default function PipelineFormComponent({
httpClient
.getPipeline(pipelineId || '')
.then((resp: GetPipelineResponseData) => {
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
form.reset({
basic: {
name: resp.pipeline.name,
@@ -346,6 +346,34 @@ export default function PipelineFormComponent({
}
};
const handleCopy = () => {
if (pipelineId) {
let newPipelineName = '';
httpClient
.getPipeline(pipelineId)
.then((resp) => {
const originalPipeline = resp.pipeline;
newPipelineName = `${originalPipeline.name}${t(
'pipelines.copySuffix',
)}`;
const newPipeline: Pipeline = {
name: newPipelineName,
description: originalPipeline.description,
config: originalPipeline.config,
};
return httpClient.createPipeline(newPipeline);
})
.then(() => {
onFinish();
toast.success(`${t('common.copySuccess')}: ${newPipelineName}`);
onCancel();
})
.catch((err) => {
toast.error(t('pipelines.createError') + err.message);
});
}
};
return (
<>
<div className="!max-w-[70vw] max-w-6xl h-full p-0 flex flex-col bg-white dark:bg-black">
@@ -478,6 +506,18 @@ export default function PipelineFormComponent({
{t('pipelines.defaultPipelineCannotDelete')}
</div>
)}
{isEditMode && (
<Button
type="button"
variant="default"
onClick={handleCopy}
className="bg-green-600 hover:bg-green-700 text-white"
>
{t('common.copy')}
</Button>
)}
<Button type="submit" form="pipeline-form">
{isEditMode ? t('common.save') : t('common.submit')}
</Button>

View File

@@ -22,9 +22,6 @@ export default function PluginConfigPage() {
const [isEditForm, setIsEditForm] = useState(false);
const [pipelineList, setPipelineList] = useState<PipelineCardVO[]>([]);
const [selectedPipelineId, setSelectedPipelineId] = useState('');
const [selectedPipelineIsDefault, setSelectedPipelineIsDefault] =
useState(false);
const [sortByValue, setSortByValue] = useState<string>('created_at');
const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');
@@ -92,8 +89,6 @@ export default function PluginConfigPage() {
const handleCreateNew = () => {
setIsEditForm(false);
setSelectedPipelineId('');
setSelectedPipelineIsDefault(false);
setDialogOpen(true);
};
@@ -116,7 +111,6 @@ export default function PluginConfigPage() {
onOpenChange={setDialogOpen}
pipelineId={selectedPipelineId || undefined}
isEditMode={isEditForm}
isDefaultPipeline={selectedPipelineIsDefault}
onFinish={() => {
getPipelines();
}}

View File

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

View File

@@ -24,7 +24,7 @@ export default function PluginMarketCardComponent({
<img
src={cardVO.iconURL}
alt="plugin icon"
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0"
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%]"
/>
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 overflow-hidden">

View File

@@ -29,6 +29,7 @@ export interface Requester {
icon?: string;
spec: {
config: IDynamicFormItemSchema[];
provider_category: string;
};
}

View File

@@ -29,6 +29,7 @@ export enum DynamicFormItemType {
PROMPT_EDITOR = 'prompt-editor',
UNKNOWN = 'unknown',
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
KNOWLEDGE_BASE_MULTI_SELECTOR = 'knowledge-base-multi-selector',
PLUGIN_SELECTOR = 'plugin-selector',
BOT_SELECTOR = 'bot-selector',
}

View File

@@ -39,6 +39,7 @@ const enUS = {
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
addRound: 'Add Round',
copy: 'Copy',
copySuccess: 'Copy Successfully',
test: 'Test',
forgotPassword: 'Forgot Password?',
@@ -58,7 +59,9 @@ const enUS = {
changePasswordSuccess: 'Password changed successfully',
changePasswordFailed:
'Failed to change password, please check your current password',
apiIntegration: 'API Integration',
apiKeys: 'API Keys',
manageApiIntegration: 'Manage API Integration',
manageApiKeys: 'Manage API Keys',
createApiKey: 'Create API Key',
apiKeyName: 'API Key Name',
@@ -73,6 +76,20 @@ const enUS = {
noApiKeys: 'No API keys configured',
apiKeyHint:
'API keys allow external systems to access LangBot Service APIs',
webhooks: 'Webhooks',
createWebhook: 'Create Webhook',
webhookName: 'Webhook Name',
webhookUrl: 'Webhook URL',
webhookDescription: 'Webhook Description',
webhookEnabled: 'Enabled',
webhookCreated: 'Webhook created successfully',
webhookDeleted: 'Webhook deleted successfully',
webhookDeleteConfirm: 'Are you sure you want to delete this webhook?',
webhookNameRequired: 'Webhook name is required',
webhookUrlRequired: 'Webhook URL is required',
noWebhooks: 'No webhooks configured',
webhookHint:
'Webhooks allow LangBot to push person and group message events to external systems',
actions: 'Actions',
apiKeyCreatedMessage: 'Please copy this API key.',
},
@@ -124,6 +141,9 @@ const enUS = {
selectModelProvider: 'Select Model Provider',
modelProviderDescription:
'Please fill in the model name provided by the supplier',
modelManufacturer: 'Model Manufacturer',
aggregationPlatform: 'Aggregation Platform',
selfDeployed: 'Self-deployed',
selectModel: 'Select Model',
testSuccess: 'Test successful',
testError: 'Test failed, please check your model configuration',
@@ -440,13 +460,14 @@ const enUS = {
createError: 'Creation failed: ',
saveSuccess: 'Saved successfully',
saveError: 'Save failed: ',
copySuffix: ' Copy',
deleteConfirmation:
'Are you sure you want to delete this pipeline? Bots bound to this pipeline will not work.',
defaultPipelineCannotDelete: 'Default pipeline cannot be deleted',
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
extensions: {
title: 'Plugins',
title: 'Extensions',
loadError: 'Failed to load plugins',
saveSuccess: 'Saved successfully',
saveError: 'Save failed',
@@ -488,6 +509,9 @@ const enUS = {
createKnowledgeBase: 'Create Knowledge Base',
editKnowledgeBase: 'Edit Knowledge Base',
selectKnowledgeBase: 'Select Knowledge Base',
selectKnowledgeBases: 'Select Knowledge Bases',
addKnowledgeBase: 'Add Knowledge Base',
noKnowledgeBaseSelected: 'No knowledge bases selected',
empty: 'Empty',
editDocument: 'Documents',
description: 'Configuring knowledge bases for improved LLM responses',

View File

@@ -40,6 +40,7 @@ const jaJP = {
deleteSuccess: '削除に成功しました',
deleteError: '削除に失敗しました:',
addRound: 'ラウンドを追加',
copy: 'コピー',
copySuccess: 'コピーに成功しました',
test: 'テスト',
forgotPassword: 'パスワードを忘れた?',
@@ -59,7 +60,9 @@ const jaJP = {
changePasswordSuccess: 'パスワードの変更に成功しました',
changePasswordFailed:
'パスワードの変更に失敗しました。現在のパスワードを確認してください',
apiIntegration: 'API統合',
apiKeys: 'API キー',
manageApiIntegration: 'API統合の管理',
manageApiKeys: 'API キーの管理',
createApiKey: 'API キーを作成',
apiKeyName: 'API キー名',
@@ -74,6 +77,20 @@ const jaJP = {
noApiKeys: 'API キーが設定されていません',
apiKeyHint:
'API キーを使用すると、外部システムが LangBot Service API にアクセスできます',
webhooks: 'Webhooks',
createWebhook: 'Webhook を作成',
webhookName: 'Webhook 名',
webhookUrl: 'Webhook URL',
webhookDescription: 'Webhook の説明',
webhookEnabled: '有効',
webhookCreated: 'Webhook が正常に作成されました',
webhookDeleted: 'Webhook が正常に削除されました',
webhookDeleteConfirm: 'この Webhook を削除してもよろしいですか?',
webhookNameRequired: 'Webhook 名は必須です',
webhookUrlRequired: 'Webhook URL は必須です',
noWebhooks: 'Webhook が設定されていません',
webhookHint:
'Webhook を使用すると、LangBot は個人メッセージとグループメッセージイベントを外部システムにプッシュできます',
actions: 'アクション',
apiKeyCreatedMessage: 'この API キーをコピーしてください。',
},
@@ -127,6 +144,9 @@ const jaJP = {
'リクエストボディに追加されるパラメータmax_tokens、temperature、top_p など)',
selectModelProvider: 'モデルプロバイダーを選択',
modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください',
modelManufacturer: 'モデルメーカー',
aggregationPlatform: 'アグリゲーションプラットフォーム',
selfDeployed: 'セルフデプロイ',
selectModel: 'モデルを選択してください',
testSuccess: 'テストに成功しました',
testError: 'テストに失敗しました。モデル設定を確認してください',
@@ -443,6 +463,7 @@ const jaJP = {
createError: '作成に失敗しました:',
saveSuccess: '保存に成功しました',
saveError: '保存に失敗しました:',
copySuffix: ' Copy',
deleteConfirmation:
'本当にこのパイプラインを削除しますか?このパイプラインに紐付けられたボットは動作しなくなります。',
defaultPipelineCannotDelete: 'デフォルトパイプラインは削除できません',
@@ -491,6 +512,9 @@ const jaJP = {
createKnowledgeBase: '知識ベースを作成',
editKnowledgeBase: '知識ベースを編集',
selectKnowledgeBase: '知識ベースを選択',
selectKnowledgeBases: '知識ベースを選択',
addKnowledgeBase: '知識ベースを追加',
noKnowledgeBaseSelected: '知識ベースが選択されていません',
empty: 'なし',
editDocument: 'ドキュメント',
description: 'LLMの回答品質向上のための知識ベースを設定します',

View File

@@ -39,6 +39,7 @@ const zhHans = {
deleteSuccess: '删除成功',
deleteError: '删除失败:',
addRound: '添加回合',
copy: '复制',
copySuccess: '复制成功',
test: '测试',
forgotPassword: '忘记密码?',
@@ -57,7 +58,9 @@ const zhHans = {
passwordsDoNotMatch: '两次输入的密码不一致',
changePasswordSuccess: '密码修改成功',
changePasswordFailed: '密码修改失败,请检查当前密码是否正确',
apiIntegration: 'API 集成',
apiKeys: 'API 密钥',
manageApiIntegration: '管理 API 集成',
manageApiKeys: '管理 API 密钥',
createApiKey: '创建 API 密钥',
apiKeyName: 'API 密钥名称',
@@ -71,6 +74,19 @@ const zhHans = {
apiKeyCopied: 'API 密钥已复制到剪贴板',
noApiKeys: '暂无 API 密钥',
apiKeyHint: 'API 密钥允许外部系统访问 LangBot 的 Service API',
webhooks: 'Webhooks',
createWebhook: '创建 Webhook',
webhookName: 'Webhook 名称',
webhookUrl: 'Webhook URL',
webhookDescription: 'Webhook 描述',
webhookEnabled: '是否启用',
webhookCreated: 'Webhook 创建成功',
webhookDeleted: 'Webhook 删除成功',
webhookDeleteConfirm: '确定要删除此 Webhook 吗?',
webhookNameRequired: 'Webhook 名称不能为空',
webhookUrlRequired: 'Webhook URL 不能为空',
noWebhooks: '暂无 Webhook',
webhookHint: 'Webhook 允许 LangBot 将个人消息和群消息事件推送到外部系统',
actions: '操作',
apiKeyCreatedMessage: '请复制此 API 密钥。',
},
@@ -122,6 +138,9 @@ const zhHans = {
boolean: '布尔值',
selectModelProvider: '选择模型供应商',
modelProviderDescription: '请填写供应商向您提供的模型名称',
modelManufacturer: '模型厂商',
aggregationPlatform: '中转平台',
selfDeployed: '自部署',
selectModel: '请选择模型',
testSuccess: '测试成功',
testError: '测试失败,请检查模型配置',
@@ -423,6 +442,7 @@ const zhHans = {
createError: '创建失败:',
saveSuccess: '保存成功',
saveError: '保存失败:',
copySuffix: ' Copy',
deleteConfirmation:
'你确定要删除这个流水线吗?已绑定此流水线的机器人将无法使用。',
defaultPipelineCannotDelete: '默认流水线不可删除',
@@ -471,6 +491,9 @@ const zhHans = {
createKnowledgeBase: '创建知识库',
editKnowledgeBase: '编辑知识库',
selectKnowledgeBase: '选择知识库',
selectKnowledgeBases: '选择知识库',
addKnowledgeBase: '添加知识库',
noKnowledgeBaseSelected: '未选择知识库',
empty: '无',
editDocument: '文档',
description: '配置可用于提升模型回复质量的知识库',

View File

@@ -39,6 +39,7 @@ const zhHant = {
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
addRound: '新增回合',
copy: '複製',
copySuccess: '複製成功',
test: '測試',
forgotPassword: '忘記密碼?',
@@ -57,7 +58,9 @@ const zhHant = {
passwordsDoNotMatch: '兩次輸入的密碼不一致',
changePasswordSuccess: '密碼修改成功',
changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確',
apiIntegration: 'API 整合',
apiKeys: 'API 金鑰',
manageApiIntegration: '管理 API 整合',
manageApiKeys: '管理 API 金鑰',
createApiKey: '建立 API 金鑰',
apiKeyName: 'API 金鑰名稱',
@@ -71,6 +74,19 @@ const zhHant = {
apiKeyCopied: 'API 金鑰已複製到剪貼簿',
noApiKeys: '暫無 API 金鑰',
apiKeyHint: 'API 金鑰允許外部系統訪問 LangBot 的 Service API',
webhooks: 'Webhooks',
createWebhook: '建立 Webhook',
webhookName: 'Webhook 名稱',
webhookUrl: 'Webhook URL',
webhookDescription: 'Webhook 描述',
webhookEnabled: '是否啟用',
webhookCreated: 'Webhook 建立成功',
webhookDeleted: 'Webhook 刪除成功',
webhookDeleteConfirm: '確定要刪除此 Webhook 嗎?',
webhookNameRequired: 'Webhook 名稱不能為空',
webhookUrlRequired: 'Webhook URL 不能為空',
noWebhooks: '暫無 Webhook',
webhookHint: 'Webhook 允許 LangBot 將個人訊息和群組訊息事件推送到外部系統',
actions: '操作',
apiKeyCreatedMessage: '請複製此 API 金鑰。',
},
@@ -122,6 +138,9 @@ const zhHant = {
boolean: '布林值',
selectModelProvider: '選擇模型供應商',
modelProviderDescription: '請填寫供應商向您提供的模型名稱',
modelManufacturer: '模型廠商',
aggregationPlatform: '中轉平台',
selfDeployed: '自部署',
selectModel: '請選擇模型',
testSuccess: '測試成功',
testError: '測試失敗,請檢查模型設定',
@@ -421,6 +440,7 @@ const zhHant = {
createError: '建立失敗:',
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
copySuffix: ' Copy',
deleteConfirmation:
'您確定要刪除這個流程線嗎?已綁定此流程線的機器人將無法使用。',
defaultPipelineCannotDelete: '預設流程線不可刪除',
@@ -468,6 +488,9 @@ const zhHant = {
createKnowledgeBase: '建立知識庫',
editKnowledgeBase: '編輯知識庫',
selectKnowledgeBase: '選擇知識庫',
selectKnowledgeBases: '選擇知識庫',
addKnowledgeBase: '新增知識庫',
noKnowledgeBaseSelected: '未選擇知識庫',
empty: '無',
editDocument: '文檔',
description: '設定可用於提升模型回覆品質的知識庫',