diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 7df1aeae..17be2ca3 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -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 \ No newline at end of file + run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c854059d..09bf5926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: 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 diff --git a/README.md b/README.md index b426a0d6..94cc3870 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_EN.md b/README_EN.md index bb7e481c..79117d44 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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) | ✅ | | diff --git a/README_JP.md b/README_JP.md index d4ac47cd..4679e37d 100644 --- a/README_JP.md +++ b/README_JP.md @@ -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) | ✅ | | diff --git a/README_TW.md b/README_TW.md index 2c178a63..3250568a 100644 --- a/README_TW.md +++ b/README_TW.md @@ -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 平台 | diff --git a/docker/README_K8S.md b/docker/README_K8S.md new file mode 100644 index 00000000..6a4889f0 --- /dev/null +++ b/docker/README_K8S.md @@ -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://:30300 +``` + +**选项 C: LoadBalancer(适用于云环境)** + +编辑 `kubernetes.yaml`,取消注释 LoadBalancer Service 部分,然后: + +```bash +kubectl apply -f kubernetes.yaml +# 获取外部 IP +kubectl get svc -n langbot langbot-loadbalancer +# 访问 http:// +``` + +**选项 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 -- tar czf /tmp/backup.tar.gz /app/data +kubectl cp langbot/:/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 + +# 查看事件 +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: +``` + +然后在 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://: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:// +``` + +**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 -- tar czf /tmp/backup.tar.gz /app/data +kubectl cp langbot/:/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 + +# 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: +``` + +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/) diff --git a/docker/deploy-k8s-test.sh b/docker/deploy-k8s-test.sh new file mode 100755 index 00000000..ff8e56e7 --- /dev/null +++ b/docker/deploy-k8s-test.sh @@ -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 "" diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 107a9e26..f9bb6ffa 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,3 +1,5 @@ +# Docker Compose configuration for LangBot +# For Kubernetes deployment, see kubernetes.yaml and README_K8S.md version: "3" services: diff --git a/docker/kubernetes.yaml b/docker/kubernetes.yaml new file mode 100644 index 00000000..424c18eb --- /dev/null +++ b/docker/kubernetes.yaml @@ -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 diff --git a/docs/service-api-openapi.json b/docs/service-api-openapi.json index 1e81adee..aab3758b 100644 --- a/docs/service-api-openapi.json +++ b/docs/service-api-openapi.json @@ -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 `)\n- API Key (via `X-API-Key: ` or `Authorization: Bearer `)\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" diff --git a/pyproject.toml b/pyproject.toml index 88f80943..2be55bb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/langbot/libs/dify_service_api/v1/client.py b/src/langbot/libs/dify_service_api/v1/client.py index 35defe2c..244d701e 100644 --- a/src/langbot/libs/dify_service_api/v1/client.py +++ b/src/langbot/libs/dify_service_api/v1/client.py @@ -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), }, ) diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index 1731d38a..abd68a40 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -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] diff --git a/src/langbot/libs/dingtalk_api/dingtalkevent.py b/src/langbot/libs/dingtalk_api/dingtalkevent.py index 6bca3862..29322bcb 100644 --- a/src/langbot/libs/dingtalk_api/dingtalkevent.py +++ b/src/langbot/libs/dingtalk_api/dingtalkevent.py @@ -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') diff --git a/src/langbot/pkg/api/http/controller/groups/webhooks.py b/src/langbot/pkg/api/http/controller/groups/webhooks.py new file mode 100644 index 00000000..1149f89b --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/webhooks.py @@ -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('/', 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() diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index 17297ed1..66eea592 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -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, ) diff --git a/src/langbot/pkg/api/http/service/webhook.py b/src/langbot/pkg/api/http/service/webhook.py new file mode 100644 index 00000000..b3a67118 --- /dev/null +++ b/src/langbot/pkg/api/http/service/webhook.py @@ -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] diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 9ed4effc..a3ad68e8 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -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 diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index a9d09b6e..51fd9a9f 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -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 diff --git a/src/langbot/pkg/entity/persistence/webhook.py b/src/langbot/pkg/entity/persistence/webhook.py new file mode 100644 index 00000000..326ab6c4 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/webhook.py @@ -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(), + ) diff --git a/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py b/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py new file mode 100644 index 00000000..cb95c6a5 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py @@ -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(), + } + ) + ) diff --git a/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py b/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py new file mode 100644 index 00000000..a98c8050 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py @@ -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 diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index eb708d9b..73c59da4 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -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, diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py index 4bbab0a0..c072a567 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.py +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -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: diff --git a/src/langbot/pkg/platform/webhook_pusher.py b/src/langbot/pkg/platform/webhook_pusher.py new file mode 100644 index 00000000..ab34bfad --- /dev/null +++ b/src/langbot/pkg/platform/webhook_pusher.py @@ -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}') diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index cef7c3c6..25223528 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -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' diff --git a/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml index 5128f61d..4fc22be4 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml @@ -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 \ No newline at end of file + attr: AI302ChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml b/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml index e3f745fb..0ef60d3e 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml index 10aae30f..7c405232 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml index ff0de6f9..4f588fb2 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml @@ -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 \ No newline at end of file + attr: OpenAIChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml index 2b7f9a70..92fcafdc 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml @@ -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 \ No newline at end of file + attr: CompShareChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml index 9a22c5d9..8ef1fcf9 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml @@ -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 \ No newline at end of file + attr: DeepseekChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml index 73fca19c..fdebe9b9 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml index d1aec26b..e818bd7a 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml @@ -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 \ No newline at end of file + attr: GiteeAIChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/jiekouai.png b/src/langbot/pkg/provider/modelmgr/requesters/jiekouai.png new file mode 100644 index 00000000..44dbbed1 Binary files /dev/null and b/src/langbot/pkg/provider/modelmgr/requesters/jiekouai.png differ diff --git a/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.py new file mode 100644 index 00000000..60001037 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.py @@ -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'.*?', '', content, flags=re.DOTALL) + else: + if reasoning_content is not None: + content = '\n' + reasoning_content + '\n\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 '' in delta['content'] and not thinking_started and not thinking_ended: + thinking_started = True + continue + elif delta['content'] == r'' 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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.yaml new file mode 100644 index 00000000..3c791d73 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/jiekouaichatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml index 8c44ab39..81dc82cf 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml index a926d889..8d22002d 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml index e51fdfa5..7a7e3060 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml index 33573df5..e0f44e99 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml @@ -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 \ No newline at end of file + attr: NewAPIChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml b/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml index f7cdeeba..a724f8f8 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml index 8c957dba..f1603200 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml index 90a81614..9e8eb1b0 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml @@ -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 \ No newline at end of file + attr: PPIOChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml index 2cd777d0..46ae1fad 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml @@ -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 \ No newline at end of file + attr: QHAIGCChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml b/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml index 6668b677..77cf682c 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml @@ -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 \ No newline at end of file + attr: ShengSuanYunChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml index 25a20653..28d3314a 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml b/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml index 363583b0..f160bdea 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml @@ -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 \ No newline at end of file + attr: TokenPonyChatCompletions diff --git a/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml index c711ef2d..e5c82657 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml index 2769a402..2e721d70 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml index 34539d95..a4ebb2ec 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml @@ -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 diff --git a/src/langbot/pkg/provider/runners/cozeapi.py b/src/langbot/pkg/provider/runners/cozeapi.py index c4310554..26980f81 100644 --- a/src/langbot/pkg/provider/runners/cozeapi.py +++ b/src/langbot/pkg/provider/runners/cozeapi.py @@ -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) diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index 4d307cbb..21fb471e 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -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 diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py index 7ab1e739..6375ca31 100644 --- a/src/langbot/pkg/provider/runners/localagent.py +++ b/src/langbot/pkg/provider/runners/localagent.py @@ -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 diff --git a/src/langbot/pkg/storage/mgr.py b/src/langbot/pkg/storage/mgr.py index 8d52e465..2f263f15 100644 --- a/src/langbot/pkg/storage/mgr.py +++ b/src/langbot/pkg/storage/mgr.py @@ -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() diff --git a/src/langbot/pkg/storage/providers/s3storage.py b/src/langbot/pkg/storage/providers/s3storage.py new file mode 100644 index 00000000..ed4fc443 --- /dev/null +++ b/src/langbot/pkg/storage/providers/s3storage.py @@ -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 diff --git a/src/langbot/pkg/utils/constants.py b/src/langbot/pkg/utils/constants.py index ee0fe9c9..c225ed82 100644 --- a/src/langbot/pkg/utils/constants.py +++ b/src/langbot/pkg/utils/constants.py @@ -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 diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 366ee782..28c4d57b 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -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' diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index c5398e76..efbb9c3f 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -45,7 +45,7 @@ "content": "You are a helpful assistant." } ], - "knowledge-base": "" + "knowledge-bases": [] }, "dify-service-api": { "base-url": "https://api.dify.ai/v1", diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index e4d16a95..7a13b2b1 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -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 diff --git a/tests/unit_tests/storage/__init__.py b/tests/unit_tests/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/storage/test_storage_provider_selection.py b/tests/unit_tests/storage/test_storage_provider_selection.py new file mode 100644 index 00000000..9f87f10a --- /dev/null +++ b/tests/unit_tests/storage/test_storage_provider_selection.py @@ -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']) diff --git a/web/src/app/home/bots/components/bot-card/botCard.module.css b/web/src/app/home/bots/components/bot-card/botCard.module.css index d9b82b9b..7e05f340 100644 --- a/web/src/app/home/bots/components/bot-card/botCard.module.css +++ b/web/src/app/home/bots/components/bot-card/botCard.module.css @@ -35,7 +35,7 @@ width: 4rem; height: 4rem; margin: 0.2rem; - /* border-radius: 50%; */ + border-radius: 8%; } .basicInfoContainer { diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index d493b8d1..bbc7fb69 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -491,7 +491,7 @@ export default function BotForm({ adapter icon
diff --git a/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx b/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx new file mode 100644 index 00000000..078d7dea --- /dev/null +++ b/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx @@ -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([]); + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(false); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyDescription, setNewKeyDescription] = useState(''); + const [createdKey, setCreatedKey] = useState(null); + const [deleteKeyId, setDeleteKeyId] = useState(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(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 ( + <> + { + // 如果删除确认框是打开的,不允许关闭主对话框 + if (!newOpen && (deleteKeyId || deleteWebhookId)) { + return; + } + onOpenChange(newOpen); + }} + > + + + {t('common.manageApiIntegration')} + + + + + + {t('common.apiKeys')} + + + {t('common.webhooks')} + + + + {/* API Keys Tab */} + +
+ {t('common.apiKeyHint')} +
+ +
+ +
+ + {loading ? ( +
+ {t('common.loading')} +
+ ) : apiKeys.length === 0 ? ( +
+ {t('common.noApiKeys')} +
+ ) : ( +
+ + + + {t('common.name')} + {t('common.apiKeyValue')} + + {t('common.actions')} + + + + + {apiKeys.map((key) => ( + + +
+
{key.name}
+ {key.description && ( +
+ {key.description} +
+ )} +
+
+ + + {maskApiKey(key.key)} + + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+ + {/* Webhooks Tab */} + +
+ {t('common.webhookHint')} +
+ +
+ +
+ + {loading ? ( +
+ {t('common.loading')} +
+ ) : webhooks.length === 0 ? ( +
+ {t('common.noWebhooks')} +
+ ) : ( +
+ + + + {t('common.name')} + {t('common.webhookUrl')} + + {t('common.webhookEnabled')} + + + {t('common.actions')} + + + + + {webhooks.map((webhook) => ( + + +
+
{webhook.name}
+ {webhook.description && ( +
+ {webhook.description} +
+ )} +
+
+ + + {webhook.url} + + + + + handleToggleWebhook(webhook) + } + /> + + + + +
+ ))} +
+
+
+ )} +
+
+ + + + +
+
+ + {/* Create API Key Dialog */} + + + + {t('common.createApiKey')} + +
+
+ + setNewKeyName(e.target.value)} + placeholder={t('common.name')} + className="mt-1" + /> +
+
+ + setNewKeyDescription(e.target.value)} + placeholder={t('common.description')} + className="mt-1" + /> +
+
+ + + + +
+
+ + {/* Show Created Key Dialog */} + setCreatedKey(null)}> + + + {t('common.apiKeyCreated')} + + {t('common.apiKeyCreatedMessage')} + + +
+
+ +
+ + +
+
+
+ + + +
+
+ + {/* Create Webhook Dialog */} + + + + {t('common.createWebhook')} + +
+
+ + setNewWebhookName(e.target.value)} + placeholder={t('common.webhookName')} + className="mt-1" + /> +
+
+ + setNewWebhookUrl(e.target.value)} + placeholder="https://example.com/webhook" + className="mt-1" + /> +
+
+ + setNewWebhookDescription(e.target.value)} + placeholder={t('common.description')} + className="mt-1" + /> +
+
+ + +
+
+ + + + +
+
+ + {/* Delete API Key Confirmation Dialog */} + setCreatedKey(null)}> + + + {t('common.apiKeyCreated')} + + {t('common.apiKeyCreatedMessage')} + + +
+
+ +
+ + +
+
+
+ + + +
+
+ + {/* Delete Confirmation Dialog */} + + + setDeleteKeyId(null)} + /> + setDeleteKeyId(null)} + > + + {t('common.confirmDelete')} + + {t('common.apiKeyDeleteConfirm')} + + + + setDeleteKeyId(null)}> + {t('common.cancel')} + + deleteKeyId && handleDeleteApiKey(deleteKeyId)} + > + {t('common.delete')} + + + + + + + {/* Delete Webhook Confirmation Dialog */} + + + setDeleteWebhookId(null)} + /> + setDeleteWebhookId(null)} + > + + {t('common.confirmDelete')} + + {t('common.webhookDeleteConfirm')} + + + + setDeleteWebhookId(null)}> + {t('common.cancel')} + + + deleteWebhookId && handleDeleteWebhook(deleteWebhookId) + } + > + {t('common.delete')} + + + + + + + ); +} diff --git a/web/src/app/home/components/api-key-management-dialog/ApiKeyManagementDialog.tsx b/web/src/app/home/components/api-key-management-dialog/ApiKeyManagementDialog.tsx deleted file mode 100644 index 35a3781b..00000000 --- a/web/src/app/home/components/api-key-management-dialog/ApiKeyManagementDialog.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(false); - const [showCreateDialog, setShowCreateDialog] = useState(false); - const [newKeyName, setNewKeyName] = useState(''); - const [newKeyDescription, setNewKeyDescription] = useState(''); - const [createdKey, setCreatedKey] = useState(null); - const [deleteKeyId, setDeleteKeyId] = useState(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 ( - <> - { - // 如果删除确认框是打开的,不允许关闭主对话框 - if (!newOpen && deleteKeyId) { - return; - } - onOpenChange(newOpen); - }} - > - - - {t('common.manageApiKeys')} - - - {t('common.apiKeyHint')} -
{ - 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" - > - - - -
-
-
-
- -
-
- -
- - {loading ? ( -
- {t('common.loading')} -
- ) : apiKeys.length === 0 ? ( -
- {t('common.noApiKeys')} -
- ) : ( -
- - - - {t('common.name')} - {t('common.apiKeyValue')} - - {t('common.actions')} - - - - - {apiKeys.map((key) => ( - - -
-
{key.name}
- {key.description && ( -
- {key.description} -
- )} -
-
- - - {maskApiKey(key.key)} - - - -
- - -
-
-
- ))} -
-
-
- )} -
- - - - -
-
- - {/* Create API Key Dialog */} - - - - {t('common.createApiKey')} - -
-
- - setNewKeyName(e.target.value)} - placeholder={t('common.name')} - className="mt-1" - /> -
-
- - setNewKeyDescription(e.target.value)} - placeholder={t('common.description')} - className="mt-1" - /> -
-
- - - - -
-
- - {/* Show Created Key Dialog */} - setCreatedKey(null)}> - - - {t('common.apiKeyCreated')} - - {t('common.apiKeyCreatedMessage')} - - -
-
- -
- - -
-
-
- - - -
-
- - {/* Delete Confirmation Dialog */} - - - setDeleteKeyId(null)} - /> - setDeleteKeyId(null)} - > - - {t('common.confirmDelete')} - - {t('common.apiKeyDeleteConfirm')} - - - - setDeleteKeyId(null)}> - {t('common.cancel')} - - deleteKeyId && handleDeleteApiKey(deleteKeyId)} - > - {t('common.delete')} - - - - - - - ); -} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 5cdd2ff7..dd2178f2 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -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; diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 03603c26..e078adb2 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -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([]); const [bots, setBots] = useState([]); const [uploading, setUploading] = useState(false); + const [kbDialogOpen, setKbDialogOpen] = useState(false); + const [tempSelectedKBIds, setTempSelectedKBIds] = useState([]); const { t } = useTranslation(); const handleFileUpload = async (file: File): Promise => { @@ -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({ ); + case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR: + return ( + <> +
+ {field.value && field.value.length > 0 ? ( +
+ {field.value.map((kbId: string) => { + const kb = knowledgeBases.find((base) => base.uuid === kbId); + if (!kb) return null; + return ( +
+
+
{kb.name}
+ {kb.description && ( +
+ {kb.description} +
+ )} +
+ +
+ ); + })} +
+ ) : ( +
+

+ {t('knowledge.noKnowledgeBaseSelected')} +

+
+ )} +
+ + + + {/* Knowledge Base Selection Dialog */} + + + + {t('knowledge.selectKnowledgeBases')} + +
+ {knowledgeBases.map((base) => { + const isSelected = tempSelectedKBIds.includes( + base.uuid ?? '', + ); + return ( +
{ + const kbId = base.uuid ?? ''; + setTempSelectedKBIds((prev) => + prev.includes(kbId) + ? prev.filter((id) => id !== kbId) + : [...prev, kbId], + ); + }} + > + +
+
{base.name}
+ {base.description && ( +
+ {base.description} +
+ )} +
+
+ ); + })} +
+ + + + +
+
+ + ); + case DynamicFormItemType.BOT_SELECTOR: return ( diff --git a/web/src/app/home/models/component/llm-card/LLMCard.module.css b/web/src/app/home/models/component/llm-card/LLMCard.module.css index aedbebde..c6eed0b7 100644 --- a/web/src/app/home/models/component/llm-card/LLMCard.module.css +++ b/web/src/app/home/models/component/llm-card/LLMCard.module.css @@ -36,7 +36,7 @@ width: 3.8rem; height: 3.8rem; margin: 0.2rem; - border-radius: 50%; + border-radius: 8%; } .basicInfoContainer { diff --git a/web/src/app/home/models/component/llm-form/LLMForm.tsx b/web/src/app/home/models/component/llm-form/LLMForm.tsx index c10f1e94..a20d6745 100644 --- a/web/src/app/home/models/component/llm-form/LLMForm.tsx +++ b/web/src/app/home/models/component/llm-form/LLMForm.tsx @@ -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({ - {requesterNameList.map((item) => ( - - {item.label} - - ))} + + {t('models.modelManufacturer')} + + {requesterNameList + .filter( + (item) => + item.provider_category === 'manufacturer', + ) + .map((item) => ( + + {item.label} + + ))} + + + + {t('models.aggregationPlatform')} + + {requesterNameList + .filter((item) => item.provider_category === 'maas') + .map((item) => ( + + {item.label} + + ))} + + + {t('models.selfDeployed')} + {requesterNameList + .filter( + (item) => + item.provider_category === 'self-hosted', + ) + .map((item) => ( + + {item.label} + + ))} diff --git a/web/src/app/home/pipelines/PipelineDetailDialog.tsx b/web/src/app/home/pipelines/PipelineDetailDialog.tsx index c12bf768..35a780fc 100644 --- a/web/src/app/home/pipelines/PipelineDetailDialog.tsx +++ b/web/src/app/home/pipelines/PipelineDetailDialog.tsx @@ -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({
{getDialogTitle()}
{currentMode === 'config' && ( - - + + {t('pipelines.extensions.toolCount', { count: server.runtime_info.tool_count || 0, })} @@ -416,14 +416,17 @@ export default function PipelineExtension({
{server.runtime_info && server.runtime_info.status === 'connected' && ( -
- - + + + {t('pipelines.extensions.toolCount', { count: server.runtime_info.tool_count || 0, })} -
+ )}
{!server.enable && ( diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 0ff2a2cd..a262db32 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -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(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 ( <>
@@ -478,6 +506,18 @@ export default function PipelineFormComponent({ {t('pipelines.defaultPipelineCannotDelete')}
)} + + {isEditMode && ( + + )} + diff --git a/web/src/app/home/pipelines/page.tsx b/web/src/app/home/pipelines/page.tsx index c0b3930a..bb20abfa 100644 --- a/web/src/app/home/pipelines/page.tsx +++ b/web/src/app/home/pipelines/page.tsx @@ -22,9 +22,6 @@ export default function PluginConfigPage() { const [isEditForm, setIsEditForm] = useState(false); const [pipelineList, setPipelineList] = useState([]); const [selectedPipelineId, setSelectedPipelineId] = useState(''); - - const [selectedPipelineIsDefault, setSelectedPipelineIsDefault] = - useState(false); const [sortByValue, setSortByValue] = useState('created_at'); const [sortOrderValue, setSortOrderValue] = useState('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(); }} diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx index 457ebdc3..327f2bc7 100644 --- a/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent.tsx @@ -46,7 +46,7 @@ export default function PluginCardComponent({ plugin icon
diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx index a5b44dad..2cc76a7a 100644 --- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx @@ -24,7 +24,7 @@ export default function PluginMarketCardComponent({ plugin icon
diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 152828fd..dbe7b145 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -29,6 +29,7 @@ export interface Requester { icon?: string; spec: { config: IDynamicFormItemSchema[]; + provider_category: string; }; } diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index fc2d4e76..d4880143 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -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', } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index a0747120..9a88837d 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index efc291ef..0c02b7c3 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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の回答品質向上のための知識ベースを設定します', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 88562d32..f41db5d2 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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: '配置可用于提升模型回复质量的知识库', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index ff587a8e..e7203803 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -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: '設定可用於提升模型回覆品質的知識庫',