diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 71ef28fc..42899f8b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,17 @@ > 请在此部分填写你实现/解决/优化的内容: > Summary of what you implemented/solved/optimized: +> + +### 更改前后对比截图 / Screenshots + +> 请在此部分粘贴更改前后对比截图(可以是界面截图、控制台输出、对话截图等): +> Please paste the screenshots of changes before and after here (can be interface screenshots, console output, conversation screenshots, etc.): +> +> 修改前 / Before: +> +> 修改后 / After: +> ## 检查清单 / Checklist diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 2b78d459..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 @@ -41,5 +40,9 @@ jobs: run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }} - name: Create Buildx run: docker buildx create --name mybuilder --use - - name: Build # image name: rockchin/langbot: - run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push + - name: Build for Release # only relase, exlude pre-release + if: ${{ github.event.release.prerelease == false }} + 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/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push \ No newline at end of file diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 00000000..a03f51ef --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,46 @@ +name: Build and Publish to PyPI + +on: + workflow_dispatch: + release: + types: [published] + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Required for trusted publishing to PyPI + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Build frontend + run: | + cd web + npm install -g pnpm + pnpm install + pnpm build + mkdir -p ../src/langbot/web/out + cp -r out ../src/langbot/web/ + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v6 + with: + version: "latest" + + - name: Build package + run: | + uv build + + - name: Publish to PyPI + run: | + uv publish --token ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/test-dev-image.yaml b/.github/workflows/test-dev-image.yaml new file mode 100644 index 00000000..1f2c0aaf --- /dev/null +++ b/.github/workflows/test-dev-image.yaml @@ -0,0 +1,108 @@ +name: Test Dev Image + +on: + workflow_run: + workflows: ["Build Dev Image"] + types: + - completed + branches: + - master + +jobs: + test-dev-image: + runs-on: ubuntu-latest + # Only run if the build workflow succeeded + if: ${{ github.event.workflow_run.conclusion == 'success' }} + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Update Docker Compose to use master tag + working-directory: ./docker + run: | + # Replace 'latest' with 'master' tag for testing the dev image + sed -i 's/rockchin\/langbot:latest/rockchin\/langbot:master/g' docker-compose.yaml + echo "Updated docker-compose.yaml to use master tag:" + cat docker-compose.yaml + + - name: Start Docker Compose + working-directory: ./docker + run: docker compose up -d + + - name: Wait and Test API + run: | + # Function to test API endpoint + test_api() { + echo "Testing API endpoint..." + response=$(curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" http://localhost:5300/api/v1/system/info 2>&1) + curl_exit_code=$? + + if [ $curl_exit_code -ne 0 ]; then + echo "Curl failed with exit code: $curl_exit_code" + echo "Error: $response" + return 1 + fi + + http_code=$(echo "$response" | tail -n 1) + response_body=$(echo "$response" | head -n -1) + + if [ "$http_code" = "200" ]; then + echo "API is healthy! Response code: $http_code" + echo "Response: $response_body" + return 0 + else + echo "API returned non-200 response: $http_code" + echo "Response body: $response_body" + return 1 + fi + } + + # Wait 30 seconds before first attempt + echo "Waiting 30 seconds for services to start..." + sleep 30 + + # Try up to 3 times with 30-second intervals + max_attempts=3 + attempt=1 + + while [ $attempt -le $max_attempts ]; do + echo "Attempt $attempt of $max_attempts" + + if test_api; then + echo "Success! API is responding correctly." + exit 0 + fi + + if [ $attempt -lt $max_attempts ]; then + echo "Retrying in 30 seconds..." + sleep 30 + fi + + attempt=$((attempt + 1)) + done + + # All attempts failed + echo "Failed to get healthy response after $max_attempts attempts" + exit 1 + + - name: Show Container Logs on Failure + if: failure() + working-directory: ./docker + run: | + echo "=== Docker Compose Status ===" + docker compose ps + echo "" + echo "=== LangBot Logs ===" + docker compose logs langbot + echo "" + echo "=== Plugin Runtime Logs ===" + docker compose logs langbot_plugin_runtime + + - name: Cleanup + if: always() + working-directory: ./docker + run: docker compose down diff --git a/.gitignore b/.gitignore index 6e855825..2a0b6245 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,12 @@ test.py .venv/ uv.lock /test +plugins.bak coverage.xml -.coverage \ No newline at end of file +.coverage +src/langbot/web/ + +# Build artifacts +/dist +/build +*.egg-info diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..09bf5926 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS.md + +This file is for guiding code agents (like Claude Code, GitHub Copilot, OpenAI Codex, etc.) to work in LangBot project. + +## Project Overview + +LangBot is a open-source LLM native instant messaging bot development platform, aiming to provide an out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, supporting global instant messaging platforms, and providing rich API interfaces, supporting custom development. + +LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts: + +- `./pkg`: The core python package of the project backend. + - `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc. + - `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc. + - `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc. + - `./pkg/api`: The api module of the project, containing the http api controllers and services. + - `./pkg/plugin`: LangBot bridge for connecting with plugin system. +- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc. +- `./templates`: Templates of config files, components, etc. +- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**. +- `./docker`: docker-compose deployment files. + +## Backend Development + +We use `uv` to manage dependencies. + +```bash +pip install uv +uv sync --dev +``` + +Start the backend and run the project in development mode. + +```bash +uv run main.py +``` + +Then you can access the project at `http://127.0.0.1:5300`. + +## Frontend Development + +We use `pnpm` to manage dependencies. + +```bash +cd web +cp .env.example .env +pnpm install +pnpm dev +``` + +Then you can access the project at `http://127.0.0.1:3000`. + +## Plugin System Architecture + +LangBot is composed of various internal components such as Large Language Model tools, commands, messaging platform adapters, LLM requesters, and more. To meet extensibility and flexibility requirements, we have implemented a production-grade plugin system. + +Each plugin runs in an independent process, managed uniformly by the Plugin Runtime. It has two operating modes: `stdio` and `websocket`. When LangBot is started directly by users (not running in a container), it uses `stdio` mode, which is common for personal users or lightweight environments. When LangBot runs in a container, it uses `websocket` mode, designed specifically for production environments. + +Plugin Runtime automatically starts each installed plugin and interacts through stdio. In plugin development scenarios, developers can use the lbp command-line tool to start plugins and connect to the running Runtime via WebSocket for debugging. + +> Plugin SDK, CLI, Runtime, and entities definitions shared between LangBot and plugins are contained in the [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk) repository. + +## Some Development Tips and Standards + +- LangBot is a global project, any comments in code should be in English, and user experience should be considered in all aspects. +- Thus you should consider the i18n support in all aspects. +- LangBot is widely adopted in both toC and toB scenarios, so you should consider the compatibility and security in all aspects. +- If you were asked to make a commit, please follow the commit message format: + - format: (): + - type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc. + - scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc. + - subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc. + +## Some Principles + +- Keep it simple, stupid. +- Entities should not be multiplied unnecessarily +- 八荣八耻 + + 以瞎猜接口为耻,以认真查询为荣。 + 以模糊执行为耻,以寻求确认为荣。 + 以臆想业务为耻,以人类确认为荣。 + 以创造接口为耻,以复用现有为荣。 + 以跳过验证为耻,以主动测试为荣。 + 以破坏架构为耻,以遵循规范为荣。 + 以假装理解为耻,以诚实无知为荣。 + 以盲目修改为耻,以谨慎重构为荣。 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 13f327fc..a433d8fe 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,16 @@ LangBot 是一个开源的大语言模型原生即时通信机器人开发平台 ## 📦 开始使用 +#### 快速部署 + +使用 `uvx` 一键启动(需要先安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)): + +```bash +uvx langbot +``` + +访问 http://localhost:5300 即可开始使用。 + #### Docker Compose 部署 ```bash @@ -61,6 +71,10 @@ docker compose up -d 直接使用发行版运行,查看文档[手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html)。 +#### Kubernetes 部署 + +参考 [Kubernetes 部署](./docker/README_K8S.md) 文档。 + ## 😎 保持更新 点击仓库右上角 Star 和 Watch 按钮,获取最新动态。 @@ -112,6 +126,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 平台 | @@ -124,7 +139,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 @@ -147,3 +162,9 @@ docker compose up -d + + diff --git a/README_EN.md b/README_EN.md index 86a021fd..596c4923 100644 --- a/README_EN.md +++ b/README_EN.md @@ -25,6 +25,16 @@ LangBot is an open-source LLM native instant messaging robot development platfor ## 📦 Getting Started +#### Quick Start + +Use `uvx` to start with one command (need to install [uv](https://docs.astral.sh/uv/getting-started/installation/)): + +```bash +uvx langbot +``` + +Visit http://localhost:5300 to start using it. + #### Docker Compose Deployment ```bash @@ -55,6 +65,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. @@ -105,6 +119,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..b02ad210 100644 --- a/README_JP.md +++ b/README_JP.md @@ -25,6 +25,16 @@ LangBot は、エージェント、RAG、MCP などの LLM アプリケーショ ## 📦 始め方 +#### クイックスタート + +`uvx` を使用した迅速なデプロイ([uv](https://docs.astral.sh/uv/getting-started/installation/) が必要です): + +```bash +uvx langbot +``` + +http://localhost:5300 にアクセスして使用を開始します。 + #### Docker Compose デプロイ ```bash @@ -55,6 +65,10 @@ LangBotはBTPanelにリストされています。BTPanelをインストール リリースバージョンを直接使用して実行します。[手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html)のドキュメントを参照してください。 +#### Kubernetes デプロイ + +[Kubernetes デプロイ](./docker/README_K8S.md) ドキュメントを参照してください。 + ## 😎 最新情報を入手 リポジトリの右上にある Star と Watch ボタンをクリックして、最新の更新を取得してください。 @@ -104,6 +118,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..aaac42df 100644 --- a/README_TW.md +++ b/README_TW.md @@ -27,6 +27,16 @@ LangBot 是一個開源的大語言模型原生即時通訊機器人開發平台 ## 📦 開始使用 +#### 快速部署 + +使用 `uvx` 一鍵啟動(需要先安裝 [uv](https://docs.astral.sh/uv/getting-started/installation/) ): + +```bash +uvx langbot +``` + +訪問 http://localhost:5300 即可開始使用。 + #### Docker Compose 部署 ```bash @@ -57,6 +67,10 @@ docker compose up -d 直接使用發行版運行,查看文件[手動部署](https://docs.langbot.app/zh/deploy/langbot/manual.html)。 +#### Kubernetes 部署 + +參考 [Kubernetes 部署](./docker/README_K8S.md) 文件。 + ## 😎 保持更新 點擊倉庫右上角 Star 和 Watch 按鈕,獲取最新動態。 @@ -107,6 +121,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..6d2ec1fc 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: @@ -5,6 +7,7 @@ services: langbot_plugin_runtime: image: rockchin/langbot:latest container_name: langbot_plugin_runtime + platform: linux/amd64 # For Apple Silicon compatibility volumes: - ./data/plugins:/app/data/plugins ports: @@ -19,6 +22,7 @@ services: langbot: image: rockchin/langbot:latest container_name: langbot + platform: linux/amd64 # For Apple Silicon compatibility volumes: - ./data:/app/data - ./plugins:/app/plugins 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/API_KEY_AUTH.md b/docs/API_KEY_AUTH.md new file mode 100644 index 00000000..3aa0d363 --- /dev/null +++ b/docs/API_KEY_AUTH.md @@ -0,0 +1,291 @@ +# API Key Authentication + +LangBot now supports API key authentication for external systems to access its HTTP service API. + +## Managing API Keys + +API keys can be managed through the web interface: + +1. Log in to the LangBot web interface +2. Click the "API Keys" button at the bottom of the sidebar +3. Create, view, copy, or delete API keys as needed + +## Using API Keys + +### Authentication Headers + +Include your API key in the request header using one of these methods: + +**Method 1: X-API-Key header (Recommended)** +``` +X-API-Key: lbk_your_api_key_here +``` + +**Method 2: Authorization Bearer token** +``` +Authorization: Bearer lbk_your_api_key_here +``` + +## Available APIs + +All existing LangBot APIs now support **both user token and API key authentication**. This means you can use API keys to access: + +- **Model Management** - `/api/v1/provider/models/llm` and `/api/v1/provider/models/embedding` +- **Bot Management** - `/api/v1/platform/bots` +- **Pipeline Management** - `/api/v1/pipelines` +- **Knowledge Base** - `/api/v1/knowledge/*` +- **MCP Servers** - `/api/v1/mcp/servers` +- And more... + +### Authentication Methods + +Each endpoint accepts **either**: +1. **User Token** (via `Authorization: Bearer `) - for web UI and authenticated users +2. **API Key** (via `X-API-Key` or `Authorization: Bearer `) - for external services + +## Example: Model Management + +### List All LLM Models + +```http +GET /api/v1/provider/models/llm +X-API-Key: lbk_your_api_key_here +``` + +Response: +```json +{ + "code": 0, + "msg": "ok", + "data": { + "models": [ + { + "uuid": "model-uuid", + "name": "GPT-4", + "description": "OpenAI GPT-4 model", + "requester": "openai-chat-completions", + "requester_config": {...}, + "abilities": ["chat", "vision"], + "created_at": "2024-01-01T00:00:00", + "updated_at": "2024-01-01T00:00:00" + } + ] + } +} +``` + +### Create a New LLM Model + +```http +POST /api/v1/provider/models/llm +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "My Custom Model", + "description": "Description of the model", + "requester": "openai-chat-completions", + "requester_config": { + "model": "gpt-4", + "args": {} + }, + "api_keys": [ + { + "name": "default", + "keys": ["sk-..."] + } + ], + "abilities": ["chat"], + "extra_args": {} +} +``` + +### Update an LLM Model + +```http +PUT /api/v1/provider/models/llm/{model_uuid} +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "Updated Model Name", + "description": "Updated description", + ... +} +``` + +### Delete an LLM Model + +```http +DELETE /api/v1/provider/models/llm/{model_uuid} +X-API-Key: lbk_your_api_key_here +``` + +## Example: Bot Management + +### List All Bots + +```http +GET /api/v1/platform/bots +X-API-Key: lbk_your_api_key_here +``` + +### Create a New Bot + +```http +POST /api/v1/platform/bots +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "My Bot", + "adapter": "telegram", + "config": {...} +} +``` + +## Example: Pipeline Management + +### List All Pipelines + +```http +GET /api/v1/pipelines +X-API-Key: lbk_your_api_key_here +``` + +### Create a New Pipeline + +```http +POST /api/v1/pipelines +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "My Pipeline", + "config": {...} +} +``` + +## Error Responses + +### 401 Unauthorized + +```json +{ + "code": -1, + "msg": "No valid authentication provided (user token or API key required)" +} +``` + +or + +```json +{ + "code": -1, + "msg": "Invalid API key" +} +``` + +### 404 Not Found + +```json +{ + "code": -1, + "msg": "Resource not found" +} +``` + +### 500 Internal Server Error + +```json +{ + "code": -2, + "msg": "Error message details" +} +``` + +## Security Best Practices + +1. **Keep API keys secure**: Store them securely and never commit them to version control +2. **Use HTTPS**: Always use HTTPS in production to encrypt API key transmission +3. **Rotate keys regularly**: Create new API keys periodically and delete old ones +4. **Use descriptive names**: Give your API keys meaningful names to track their usage +5. **Delete unused keys**: Remove API keys that are no longer needed +6. **Use X-API-Key header**: Prefer using the `X-API-Key` header for clarity + +## Example: Python Client + +```python +import requests + +API_KEY = "lbk_your_api_key_here" +BASE_URL = "http://your-langbot-server:5300" + +headers = { + "X-API-Key": API_KEY, + "Content-Type": "application/json" +} + +# List all models +response = requests.get(f"{BASE_URL}/api/v1/provider/models/llm", headers=headers) +models = response.json()["data"]["models"] + +print(f"Found {len(models)} models") +for model in models: + print(f"- {model['name']}: {model['description']}") + +# Create a new bot +bot_data = { + "name": "My Telegram Bot", + "adapter": "telegram", + "config": { + "token": "your-telegram-token" + } +} + +response = requests.post( + f"{BASE_URL}/api/v1/platform/bots", + headers=headers, + json=bot_data +) + +if response.status_code == 200: + bot_uuid = response.json()["data"]["uuid"] + print(f"Bot created with UUID: {bot_uuid}") +``` + +## Example: cURL + +```bash +# List all models +curl -X GET \ + -H "X-API-Key: lbk_your_api_key_here" \ + http://your-langbot-server:5300/api/v1/provider/models/llm + +# Create a new pipeline +curl -X POST \ + -H "X-API-Key: lbk_your_api_key_here" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My Pipeline", + "config": {...} + }' \ + http://your-langbot-server:5300/api/v1/pipelines + +# Get bot logs +curl -X POST \ + -H "X-API-Key: lbk_your_api_key_here" \ + -H "Content-Type: application/json" \ + -d '{ + "from_index": -1, + "max_count": 10 + }' \ + http://your-langbot-server:5300/api/v1/platform/bots/{bot_uuid}/logs +``` + +## Notes + +- The same endpoints work for both the web UI (with user tokens) and external services (with API keys) +- No need to learn different API paths - use the existing API documentation with API key authentication +- All endpoints that previously required user authentication now also accept API keys + diff --git a/docs/PYPI_INSTALLATION.md b/docs/PYPI_INSTALLATION.md new file mode 100644 index 00000000..1144d5cb --- /dev/null +++ b/docs/PYPI_INSTALLATION.md @@ -0,0 +1,117 @@ +# LangBot PyPI Package Installation + +## Quick Start with uvx + +The easiest way to run LangBot is using `uvx` (recommended for quick testing): + +```bash +uvx langbot +``` + +This will automatically download and run the latest version of LangBot. + +## Install with pip/uv + +You can also install LangBot as a regular Python package: + +```bash +# Using pip +pip install langbot + +# Using uv +uv pip install langbot +``` + +Then run it: + +```bash +langbot +``` + +Or using Python module syntax: + +```bash +python -m langbot +``` + +## Installation with Frontend + +When published to PyPI, the LangBot package includes the pre-built frontend files. You don't need to build the frontend separately. + +## Data Directory + +When running LangBot as a package, it will create a `data/` directory in your current working directory to store configuration, logs, and other runtime data. You can run LangBot from any directory, and it will set up its data directory there. + +## Command Line Options + +LangBot supports the following command line options: + +- `--standalone-runtime`: Use standalone plugin runtime +- `--debug`: Enable debug mode + +Example: + +```bash +langbot --debug +``` + +## Comparison with Other Installation Methods + +### PyPI Package (uvx/pip) +- **Pros**: Easy to install and update, no need to clone repository or build frontend +- **Cons**: Less flexible for development/customization + +### Docker +- **Pros**: Isolated environment, easy deployment +- **Cons**: Requires Docker + +### Manual Source Installation +- **Pros**: Full control, easy to customize and develop +- **Cons**: Requires building frontend, managing dependencies manually + +## Development + +If you want to contribute or customize LangBot, you should still use the manual installation method by cloning the repository: + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot +uv sync +cd web +npm install +npm run build +cd .. +uv run main.py +``` + +## Updating + +To update to the latest version: + +```bash +# With pip +pip install --upgrade langbot + +# With uv +uv pip install --upgrade langbot + +# With uvx (automatically uses latest) +uvx langbot +``` + +## System Requirements + +- Python 3.10.1 or higher +- Operating System: Linux, macOS, or Windows + +## Differences from Source Installation + +When running LangBot from the PyPI package (via uvx or pip), there are a few behavioral differences compared to running from source: + +1. **Version Check**: The package version does not prompt for user input when the Python version is incompatible. It simply prints an error message and exits. This makes it compatible with non-interactive environments like containers and CI/CD. + +2. **Working Directory**: The package version does not require being run from the LangBot project root. You can run `langbot` from any directory, and it will create a `data/` directory in your current working directory. + +3. **Frontend Files**: The frontend is pre-built and included in the package, so you don't need to run `npm build` separately. + +These differences are intentional to make the package more user-friendly and suitable for various deployment scenarios. diff --git a/TESTING_SUMMARY.md b/docs/TESTING_SUMMARY.md similarity index 100% rename from TESTING_SUMMARY.md rename to docs/TESTING_SUMMARY.md diff --git a/docs/service-api-openapi.json b/docs/service-api-openapi.json new file mode 100644 index 00000000..aab3758b --- /dev/null +++ b/docs/service-api-openapi.json @@ -0,0 +1,1944 @@ +{ + "openapi": "3.0.3", + "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.5.0", + "contact": { + "name": "LangBot", + "url": "https://langbot.app" + }, + "license": { + "name": "AGPL-3.0", + "url": "https://github.com/langbot-app/LangBot/blob/master/LICENSE" + } + }, + "servers": [ + { + "url": "http://localhost:5300", + "description": "Local development server" + } + ], + "tags": [ + { + "name": "Models - LLM", + "description": "Large Language Model management operations" + }, + { + "name": "Models - Embedding", + "description": "Embedding model management operations" + }, + { + "name": "Bots", + "description": "Bot instance management operations" + }, + { + "name": "Pipelines", + "description": "Pipeline configuration management operations" + } + ], + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "paths": { + "/api/v1/provider/models/llm": { + "get": { + "tags": [ + "Models - LLM" + ], + "summary": "List all LLM models", + "description": "Retrieve a list of all configured LLM models", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMModel" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Models - LLM" + ], + "summary": "Create a new LLM model", + "description": "Create and configure a new LLM model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LLMModelCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Model created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/api/v1/provider/models/llm/{model_uuid}": { + "get": { + "tags": [ + "Models - LLM" + ], + "summary": "Get a specific LLM model", + "description": "Retrieve details of a specific LLM model by UUID", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/LLMModel" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Models - LLM" + ], + "summary": "Update an LLM model", + "description": "Update the configuration of an existing LLM model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LLMModelUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Model updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Models - LLM" + ], + "summary": "Delete an LLM model", + "description": "Remove an LLM model from the system", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Model deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/provider/models/llm/{model_uuid}/test": { + "post": { + "tags": [ + "Models - LLM" + ], + "summary": "Test an LLM model", + "description": "Test the connectivity and functionality of an LLM model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Model configuration to test" + } + } + } + }, + "responses": { + "200": { + "description": "Model test successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/api/v1/provider/models/embedding": { + "get": { + "tags": [ + "Models - Embedding" + ], + "summary": "List all embedding models", + "description": "Retrieve a list of all configured embedding models", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmbeddingModel" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Models - Embedding" + ], + "summary": "Create a new embedding model", + "description": "Create and configure a new embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddingModelCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Model created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/provider/models/embedding/{model_uuid}": { + "get": { + "tags": [ + "Models - Embedding" + ], + "summary": "Get a specific embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/EmbeddingModel" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Models - Embedding" + ], + "summary": "Update an embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddingModelUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Model updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Models - Embedding" + ], + "summary": "Delete an embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Model deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/provider/models/embedding/{model_uuid}/test": { + "post": { + "tags": [ + "Models - Embedding" + ], + "summary": "Test an embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Model test successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/platform/bots": { + "get": { + "tags": [ + "Bots" + ], + "summary": "List all bots", + "description": "Retrieve a list of all configured bot instances", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "bots": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Bot" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Bots" + ], + "summary": "Create a new bot", + "description": "Create and configure a new bot instance", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Bot created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/platform/bots/{bot_uuid}": { + "get": { + "tags": [ + "Bots" + ], + "summary": "Get a specific bot", + "description": "Retrieve details of a specific bot instance", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Bot UUID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "bot": { + "$ref": "#/components/schemas/Bot" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Bots" + ], + "summary": "Update a bot", + "description": "Update the configuration of an existing bot instance", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Bot updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Bots" + ], + "summary": "Delete a bot", + "description": "Remove a bot instance from the system", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Bot deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/platform/bots/{bot_uuid}/logs": { + "post": { + "tags": [ + "Bots" + ], + "summary": "Get bot event logs", + "description": "Retrieve event logs for a specific bot", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "from_index": { + "type": "integer", + "default": -1, + "description": "Starting index for logs (-1 for latest)" + }, + "max_count": { + "type": "integer", + "default": 10, + "description": "Maximum number of logs to retrieve" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "type": "object" + } + }, + "total_count": { + "type": "integer" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/pipelines": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "List all pipelines", + "description": "Retrieve a list of all configured pipelines", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "sort_by", + "in": "query", + "schema": { + "type": "string", + "default": "created_at" + }, + "description": "Field to sort by" + }, + { + "name": "sort_order", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + }, + "description": "Sort order" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "pipelines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pipeline" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Pipelines" + ], + "summary": "Create a new pipeline", + "description": "Create and configure a new pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Pipeline created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/pipelines/_/metadata": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "Get pipeline metadata", + "description": "Retrieve metadata and configuration options for pipelines", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "configs": { + "type": "object" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/pipelines/{pipeline_uuid}": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "Get a specific pipeline", + "description": "Retrieve details of a specific pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "pipeline": { + "$ref": "#/components/schemas/Pipeline" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Pipelines" + ], + "summary": "Update a pipeline", + "description": "Update the configuration of an existing pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Pipeline updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Pipelines" + ], + "summary": "Delete a pipeline", + "description": "Remove a pipeline from the system", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Pipeline deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/pipelines/{pipeline_uuid}/extensions": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "Get pipeline extensions", + "description": "Retrieve extensions and plugins configured for a pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Pipelines" + ], + "summary": "Update pipeline extensions", + "description": "Update the extensions configuration for a pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Extensions updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API Key authentication using X-API-Key header.\nExample: `X-API-Key: lbk_your_api_key_here`\n" + }, + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Bearer token authentication. Can be either a user JWT token or an API key.\nExample: `Authorization: Bearer `\n" + } + }, + "parameters": { + "ModelUUID": { + "name": "model_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Model UUID" + } + }, + "schemas": { + "LLMModel": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "GPT-4" + }, + "description": { + "type": "string", + "example": "OpenAI GPT-4 model" + }, + "requester": { + "type": "string", + "example": "openai-chat-completions" + }, + "requester_config": { + "type": "object", + "properties": { + "model": { + "type": "string", + "example": "gpt-4" + }, + "args": { + "type": "object" + } + } + }, + "api_keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "keys": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "abilities": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "chat", + "vision" + ] + }, + "extra_args": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "LLMModelCreate": { + "type": "object", + "required": [ + "name", + "requester", + "requester_config", + "api_keys" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "abilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "LLMModelUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "abilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "EmbeddingModel": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "extra_args": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "EmbeddingModelCreate": { + "type": "object", + "required": [ + "name", + "requester", + "requester_config", + "api_keys" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "EmbeddingModelUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "Bot": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "adapter": { + "type": "string", + "example": "telegram" + }, + "config": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "BotCreate": { + "type": "object", + "required": [ + "name", + "adapter", + "config" + ], + "properties": { + "name": { + "type": "string" + }, + "adapter": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "BotUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "Pipeline": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object" + }, + "is_default": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "PipelineCreate": { + "type": "object", + "required": [ + "name", + "config" + ], + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "PipelineUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "SuccessResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "nullable": true + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": -1 + }, + "msg": { + "type": "string", + "example": "Error message" + } + } + } + }, + "responses": { + "UnauthorizedError": { + "description": "Authentication required or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "no_auth": { + "value": { + "code": -1, + "msg": "No valid authentication provided (user token or API key required)" + } + }, + "invalid_key": { + "value": { + "code": -1, + "msg": "Invalid API key" + } + } + } + } + } + }, + "NotFoundError": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": -1, + "msg": "Resource not found" + } + } + } + }, + "InternalServerError": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": -2, + "msg": "Internal server error" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/libs/dify_service_api/test.py b/libs/dify_service_api/test.py deleted file mode 100644 index 7da04f6b..00000000 --- a/libs/dify_service_api/test.py +++ /dev/null @@ -1,45 +0,0 @@ -from v1 import client # type: ignore - -import asyncio - -import os -import json - - -class TestDifyClient: - async def test_chat_messages(self): - cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL')) - - async for chunk in cln.chat_messages(inputs={}, query='调用工具查看现在几点?', user='test'): - print(json.dumps(chunk, ensure_ascii=False, indent=4)) - - async def test_upload_file(self): - cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL')) - - file_bytes = open('img.png', 'rb').read() - - print(type(file_bytes)) - - file = ('img2.png', file_bytes, 'image/png') - - resp = await cln.upload_file(file=file, user='test') - print(json.dumps(resp, ensure_ascii=False, indent=4)) - - async def test_workflow_run(self): - cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL')) - - # resp = await cln.workflow_run(inputs={}, user="test") - # # print(json.dumps(resp, ensure_ascii=False, indent=4)) - # print(resp) - chunks = [] - - ignored_events = ['text_chunk'] - async for chunk in cln.workflow_run(inputs={}, user='test'): - if chunk['event'] in ignored_events: - continue - chunks.append(chunk) - print(json.dumps(chunks, ensure_ascii=False, indent=4)) - - -if __name__ == '__main__': - asyncio.run(TestDifyClient().test_chat_messages()) diff --git a/main.py b/main.py index 708a01cd..9e1f5c31 100644 --- a/main.py +++ b/main.py @@ -1,117 +1,3 @@ -import asyncio -import argparse -# LangBot 终端启动入口 -# 在此层级解决依赖项检查。 -# LangBot/main.py +import langbot.__main__ -asciiart = r""" - _ ___ _ -| | __ _ _ _ __ _| _ ) ___| |_ -| |__/ _` | ' \/ _` | _ \/ _ \ _| -|____\__,_|_||_\__, |___/\___/\__| - |___/ - -⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot -📖 Documentation 文档地址: https://docs.langbot.app -""" - - -async def main_entry(loop: asyncio.AbstractEventLoop): - parser = argparse.ArgumentParser(description='LangBot') - parser.add_argument( - '--standalone-runtime', - action='store_true', - help='Use standalone plugin runtime / 使用独立插件运行时', - default=False, - ) - parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False) - args = parser.parse_args() - - if args.standalone_runtime: - from pkg.utils import platform - - platform.standalone_runtime = True - - if args.debug: - from pkg.utils import constants - - constants.debug_mode = True - - print(asciiart) - - import sys - - # 检查依赖 - - from pkg.core.bootutils import deps - - missing_deps = await deps.check_deps() - - if missing_deps: - print('以下依赖包未安装,将自动安装,请完成后重启程序:') - print( - 'These dependencies are missing, they will be installed automatically, please restart the program after completion:' - ) - for dep in missing_deps: - print('-', dep) - await deps.install_deps(missing_deps) - print('已自动安装缺失的依赖包,请重启程序。') - print('The missing dependencies have been installed automatically, please restart the program.') - sys.exit(0) - - # # 检查pydantic版本,如果没有 pydantic.v1,则把 pydantic 映射为 v1 - # import pydantic.version - - # if pydantic.version.VERSION < '2.0': - # import pydantic - - # sys.modules['pydantic.v1'] = pydantic - - # 检查配置文件 - - from pkg.core.bootutils import files - - generated_files = await files.generate_files() - - if generated_files: - print('以下文件不存在,已自动生成:') - print('Following files do not exist and have been automatically generated:') - for file in generated_files: - print('-', file) - - from pkg.core import boot - - await boot.main(loop) - - -if __name__ == '__main__': - import os - import sys - - # 必须大于 3.10.1 - if sys.version_info < (3, 10, 1): - print('需要 Python 3.10.1 及以上版本,当前 Python 版本为:', sys.version) - input('按任意键退出...') - print('Your Python version is not supported. Please exit the program by pressing any key.') - exit(1) - - # Check if the current directory is the LangBot project root directory - invalid_pwd = False - - if not os.path.exists('main.py'): - invalid_pwd = True - else: - with open('main.py', 'r', encoding='utf-8') as f: - content = f.read() - if 'LangBot/main.py' not in content: - invalid_pwd = True - if invalid_pwd: - print('请在 LangBot 项目根目录下以命令形式运行此程序。') - input('按任意键退出...') - print('Please run this program in the LangBot project root directory in command form.') - print('Press any key to exit...') - exit(1) - - loop = asyncio.new_event_loop() - - loop.run_until_complete(main_entry(loop)) +langbot.__main__.main() diff --git a/pkg/api/http/controller/groups/pipelines/pipelines.py b/pkg/api/http/controller/groups/pipelines/pipelines.py deleted file mode 100644 index e3d08e28..00000000 --- a/pkg/api/http/controller/groups/pipelines/pipelines.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -import quart - -from ... import group - - -@group.group_class('pipelines', '/api/v1/pipelines') -class PipelinesRouterGroup(group.RouterGroup): - async def initialize(self) -> None: - @self.route('', methods=['GET', 'POST']) - async def _() -> str: - if quart.request.method == 'GET': - sort_by = quart.request.args.get('sort_by', 'created_at') - sort_order = quart.request.args.get('sort_order', 'DESC') - return self.success( - data={'pipelines': await self.ap.pipeline_service.get_pipelines(sort_by, sort_order)} - ) - elif quart.request.method == 'POST': - json_data = await quart.request.json - - pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data) - - return self.success(data={'uuid': pipeline_uuid}) - - @self.route('/_/metadata', methods=['GET']) - async def _() -> str: - return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()}) - - @self.route('/', methods=['GET', 'PUT', 'DELETE']) - async def _(pipeline_uuid: str) -> str: - if quart.request.method == 'GET': - pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) - - if pipeline is None: - return self.http_status(404, -1, 'pipeline not found') - - return self.success(data={'pipeline': pipeline}) - elif quart.request.method == 'PUT': - json_data = await quart.request.json - - await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data) - - return self.success() - elif quart.request.method == 'DELETE': - await self.ap.pipeline_service.delete_pipeline(pipeline_uuid) - - return self.success() diff --git a/pkg/api/http/controller/groups/plugins.py b/pkg/api/http/controller/groups/plugins.py deleted file mode 100644 index 4966553b..00000000 --- a/pkg/api/http/controller/groups/plugins.py +++ /dev/null @@ -1,144 +0,0 @@ -from __future__ import annotations - -import base64 -import quart - -from .....core import taskmgr -from .. import group -from langbot_plugin.runtime.plugin.mgr import PluginInstallSource - - -@group.group_class('plugins', '/api/v1/plugins') -class PluginsRouterGroup(group.RouterGroup): - async def initialize(self) -> None: - @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) - async def _() -> str: - plugins = await self.ap.plugin_connector.list_plugins() - - return self.success(data={'plugins': plugins}) - - @self.route( - '///upgrade', - methods=['POST'], - auth_type=group.AuthType.USER_TOKEN, - ) - async def _(author: str, plugin_name: str) -> str: - ctx = taskmgr.TaskContext.new() - wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx), - kind='plugin-operation', - name=f'plugin-upgrade-{plugin_name}', - label=f'Upgrading plugin {plugin_name}', - context=ctx, - ) - return self.success(data={'task_id': wrapper.id}) - - @self.route( - '//', - methods=['GET', 'DELETE'], - auth_type=group.AuthType.USER_TOKEN, - ) - async def _(author: str, plugin_name: str) -> str: - if quart.request.method == 'GET': - plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) - if plugin is None: - return self.http_status(404, -1, 'plugin not found') - return self.success(data={'plugin': plugin}) - elif quart.request.method == 'DELETE': - ctx = taskmgr.TaskContext.new() - wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_connector.delete_plugin(author, plugin_name, task_context=ctx), - kind='plugin-operation', - name=f'plugin-remove-{plugin_name}', - label=f'Removing plugin {plugin_name}', - context=ctx, - ) - - return self.success(data={'task_id': wrapper.id}) - - @self.route( - '///config', - methods=['GET', 'PUT'], - auth_type=group.AuthType.USER_TOKEN, - ) - async def _(author: str, plugin_name: str) -> quart.Response: - plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) - if plugin is None: - return self.http_status(404, -1, 'plugin not found') - - if quart.request.method == 'GET': - return self.success(data={'config': plugin['plugin_config']}) - elif quart.request.method == 'PUT': - data = await quart.request.json - - await self.ap.plugin_connector.set_plugin_config(author, plugin_name, data) - - return self.success(data={}) - - @self.route( - '///icon', - methods=['GET'], - auth_type=group.AuthType.NONE, - ) - async def _(author: str, plugin_name: str) -> quart.Response: - icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) - icon_base64 = icon_data['plugin_icon_base64'] - mime_type = icon_data['mime_type'] - - icon_data = base64.b64decode(icon_base64) - - return quart.Response(icon_data, mimetype=mime_type) - - @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) - async def _() -> str: - data = await quart.request.json - - ctx = taskmgr.TaskContext.new() - short_source_str = data['source'][-8:] - wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_mgr.install_plugin(data['source'], task_context=ctx), - kind='plugin-operation', - name='plugin-install-github', - label=f'Installing plugin from github ...{short_source_str}', - context=ctx, - ) - - return self.success(data={'task_id': wrapper.id}) - - @self.route('/install/marketplace', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) - async def _() -> str: - data = await quart.request.json - - ctx = taskmgr.TaskContext.new() - wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx), - kind='plugin-operation', - name='plugin-install-marketplace', - label=f'Installing plugin from marketplace ...{data}', - context=ctx, - ) - - return self.success(data={'task_id': wrapper.id}) - - @self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) - async def _() -> str: - file = (await quart.request.files).get('file') - if file is None: - return self.http_status(400, -1, 'file is required') - - file_bytes = file.read() - - data = { - 'plugin_file': file_bytes, - } - - ctx = taskmgr.TaskContext.new() - wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx), - kind='plugin-operation', - name='plugin-install-local', - label=f'Installing plugin from local ...{file.filename}', - context=ctx, - ) - - return self.success(data={'task_id': wrapper.id}) diff --git a/pkg/core/stages/load_config.py b/pkg/core/stages/load_config.py deleted file mode 100644 index 0474b33a..00000000 --- a/pkg/core/stages/load_config.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -import os - -from .. import stage, app -from ..bootutils import config - - -@stage.stage_class('LoadConfigStage') -class LoadConfigStage(stage.BootingStage): - """Load config file stage""" - - async def run(self, ap: app.Application): - """Load config file""" - - # ======= deprecated ======= - if os.path.exists('data/config/command.json'): - ap.command_cfg = await config.load_json_config( - 'data/config/command.json', - 'templates/legacy/command.json', - completion=False, - ) - - if os.path.exists('data/config/pipeline.json'): - ap.pipeline_cfg = await config.load_json_config( - 'data/config/pipeline.json', - 'templates/legacy/pipeline.json', - completion=False, - ) - - if os.path.exists('data/config/platform.json'): - ap.platform_cfg = await config.load_json_config( - 'data/config/platform.json', - 'templates/legacy/platform.json', - completion=False, - ) - - if os.path.exists('data/config/provider.json'): - ap.provider_cfg = await config.load_json_config( - 'data/config/provider.json', - 'templates/legacy/provider.json', - completion=False, - ) - - if os.path.exists('data/config/system.json'): - ap.system_cfg = await config.load_json_config( - 'data/config/system.json', - 'templates/legacy/system.json', - completion=False, - ) - - # ======= deprecated ======= - - ap.instance_config = await config.load_yaml_config( - 'data/config.yaml', 'templates/config.yaml', completion=False - ) - await ap.instance_config.dump_config() - - ap.sensitive_meta = await config.load_json_config( - 'data/metadata/sensitive-words.json', - 'templates/metadata/sensitive-words.json', - ) - await ap.sensitive_meta.dump_config() - - ap.pipeline_config_meta_trigger = await config.load_yaml_config( - 'templates/metadata/pipeline/trigger.yaml', - 'templates/metadata/pipeline/trigger.yaml', - ) - ap.pipeline_config_meta_safety = await config.load_yaml_config( - 'templates/metadata/pipeline/safety.yaml', - 'templates/metadata/pipeline/safety.yaml', - ) - ap.pipeline_config_meta_ai = await config.load_yaml_config( - 'templates/metadata/pipeline/ai.yaml', 'templates/metadata/pipeline/ai.yaml' - ) - ap.pipeline_config_meta_output = await config.load_yaml_config( - 'templates/metadata/pipeline/output.yaml', - 'templates/metadata/pipeline/output.yaml', - ) diff --git a/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml b/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml deleted file mode 100644 index 5128f61d..00000000 --- a/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: 302-ai-chat-completions - label: - en_US: 302.AI - zh_Hans: 302.AI - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./302aichatcmpl.py - attr: AI302ChatCompletions \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml b/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml deleted file mode 100644 index e3f745fb..00000000 --- a/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: anthropic-messages - label: - en_US: Anthropic - zh_Hans: Anthropic - 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 - support_type: - - llm -execution: - python: - path: ./anthropicmsgs.py - attr: AnthropicMessages diff --git a/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml b/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml deleted file mode 100644 index 10aae30f..00000000 --- a/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: bailian-chat-completions - label: - en_US: Aliyun Bailian - zh_Hans: 阿里云百炼 - 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 - support_type: - - llm -execution: - python: - path: ./bailianchatcmpl.py - attr: BailianChatCompletions diff --git a/pkg/provider/modelmgr/requesters/chatcmpl.yaml b/pkg/provider/modelmgr/requesters/chatcmpl.yaml deleted file mode 100644 index ff0de6f9..00000000 --- a/pkg/provider/modelmgr/requesters/chatcmpl.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: openai-chat-completions - label: - en_US: OpenAI - zh_Hans: OpenAI - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./chatcmpl.py - attr: OpenAIChatCompletions \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml b/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml deleted file mode 100644 index 2b7f9a70..00000000 --- a/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: compshare-chat-completions - label: - en_US: CompShare - zh_Hans: 优云智算 - 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 - support_type: - - llm -execution: - python: - path: ./compsharechatcmpl.py - attr: CompShareChatCompletions \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml b/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml deleted file mode 100644 index 9a22c5d9..00000000 --- a/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: deepseek-chat-completions - label: - en_US: DeepSeek - zh_Hans: DeepSeek - 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 - support_type: - - llm -execution: - python: - path: ./deepseekchatcmpl.py - attr: DeepseekChatCompletions \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml b/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml deleted file mode 100644 index 73fca19c..00000000 --- a/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: gemini-chat-completions - label: - en_US: Google Gemini - zh_Hans: Google Gemini - 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 - support_type: - - llm -execution: - python: - path: ./geminichatcmpl.py - attr: GeminiChatCompletions diff --git a/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml b/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml deleted file mode 100644 index d1aec26b..00000000 --- a/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: gitee-ai-chat-completions - label: - en_US: Gitee AI - zh_Hans: Gitee AI - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./giteeaichatcmpl.py - attr: GiteeAIChatCompletions \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml b/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml deleted file mode 100644 index 8c44ab39..00000000 --- a/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: lmstudio-chat-completions - label: - en_US: LM Studio - zh_Hans: LM Studio - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./lmstudiochatcmpl.py - attr: LmStudioChatCompletions diff --git a/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml b/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml deleted file mode 100644 index a926d889..00000000 --- a/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml +++ /dev/null @@ -1,37 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: modelscope-chat-completions - label: - en_US: ModelScope - zh_Hans: 魔搭社区 - 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 - support_type: - - llm -execution: - python: - path: ./modelscopechatcmpl.py - attr: ModelScopeChatCompletions diff --git a/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml b/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml deleted file mode 100644 index e51fdfa5..00000000 --- a/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: moonshot-chat-completions - label: - en_US: Moonshot - zh_Hans: 月之暗面 - 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 - support_type: - - llm -execution: - python: - path: ./moonshotchatcmpl.py - attr: MoonshotChatCompletions diff --git a/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml b/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml deleted file mode 100644 index 33573df5..00000000 --- a/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: new-api-chat-completions - label: - en_US: New API - zh_Hans: New API - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./newapichatcmpl.py - attr: NewAPIChatCompletions \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/ollamachat.yaml b/pkg/provider/modelmgr/requesters/ollamachat.yaml deleted file mode 100644 index f7cdeeba..00000000 --- a/pkg/provider/modelmgr/requesters/ollamachat.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: ollama-chat - label: - en_US: Ollama - zh_Hans: Ollama - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./ollamachat.py - attr: OllamaChatCompletions diff --git a/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml b/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml deleted file mode 100644 index 8c957dba..00000000 --- a/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: openrouter-chat-completions - label: - en_US: OpenRouter - zh_Hans: OpenRouter - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./openrouterchatcmpl.py - attr: OpenRouterChatCompletions diff --git a/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml b/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml deleted file mode 100644 index 90a81614..00000000 --- a/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: ppio-chat-completions - label: - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./ppiochatcmpl.py - attr: PPIOChatCompletions \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml b/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml deleted file mode 100644 index 2cd777d0..00000000 --- a/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: qhaigc-chat-completions - label: - en_US: QH AI - zh_Hans: 启航 AI - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./qhaigcchatcmpl.py - attr: QHAIGCChatCompletions \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/shengsuanyun.yaml b/pkg/provider/modelmgr/requesters/shengsuanyun.yaml deleted file mode 100644 index 6668b677..00000000 --- a/pkg/provider/modelmgr/requesters/shengsuanyun.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: shengsuanyun-chat-completions - label: - en_US: ShengSuanYun - zh_Hans: 胜算云 - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./shengsuanyun.py - attr: ShengSuanYunChatCompletions \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml b/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml deleted file mode 100644 index 25a20653..00000000 --- a/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: siliconflow-chat-completions - label: - en_US: SiliconFlow - zh_Hans: 硅基流动 - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./siliconflowchatcmpl.py - attr: SiliconFlowChatCompletions diff --git a/pkg/provider/modelmgr/requesters/tokenpony.yaml b/pkg/provider/modelmgr/requesters/tokenpony.yaml deleted file mode 100644 index 363583b0..00000000 --- a/pkg/provider/modelmgr/requesters/tokenpony.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: tokenpony-chat-completions - label: - en_US: TokenPony - zh_Hans: 小马算力 - 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 - support_type: - - llm - - text-embedding -execution: - python: - path: ./tokenponychatcmpl.py - attr: TokenPonyChatCompletions \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml b/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml deleted file mode 100644 index c711ef2d..00000000 --- a/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: volcark-chat-completions - label: - en_US: Volc Engine Ark - zh_Hans: 火山方舟 - 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 - support_type: - - llm -execution: - python: - path: ./volcarkchatcmpl.py - attr: VolcArkChatCompletions diff --git a/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml b/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml deleted file mode 100644 index 2769a402..00000000 --- a/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: xai-chat-completions - label: - en_US: xAI - zh_Hans: xAI - 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 - support_type: - - llm -execution: - python: - path: ./xaichatcmpl.py - attr: XaiChatCompletions diff --git a/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml b/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml deleted file mode 100644 index 34539d95..00000000 --- a/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: LLMAPIRequester -metadata: - name: zhipuai-chat-completions - label: - en_US: ZhipuAI - zh_Hans: 智谱 AI - 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 - support_type: - - llm -execution: - python: - path: ./zhipuaichatcmpl.py - attr: ZhipuAIChatCompletions diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py deleted file mode 100644 index 36fa9751..00000000 --- a/pkg/provider/tools/loaders/mcp.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations - -import typing -from contextlib import AsyncExitStack - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.client.sse import sse_client - -from .. import loader -from ....core import app -import langbot_plugin.api.entities.builtin.resource.tool as resource_tool - - -class RuntimeMCPSession: - """运行时 MCP 会话""" - - ap: app.Application - - server_name: str - - server_config: dict - - session: ClientSession - - exit_stack: AsyncExitStack - - functions: list[resource_tool.LLMTool] = [] - - def __init__(self, server_name: str, server_config: dict, ap: app.Application): - self.server_name = server_name - self.server_config = server_config - self.ap = ap - - self.session = None - - self.exit_stack = AsyncExitStack() - self.functions = [] - - async def _init_stdio_python_server(self): - server_params = StdioServerParameters( - command=self.server_config['command'], - args=self.server_config['args'], - env=self.server_config['env'], - ) - - stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) - - stdio, write = stdio_transport - - self.session = await self.exit_stack.enter_async_context(ClientSession(stdio, write)) - - await self.session.initialize() - - async def _init_sse_server(self): - sse_transport = await self.exit_stack.enter_async_context( - sse_client( - self.server_config['url'], - headers=self.server_config.get('headers', {}), - timeout=self.server_config.get('timeout', 10), - ) - ) - - sseio, write = sse_transport - - self.session = await self.exit_stack.enter_async_context(ClientSession(sseio, write)) - - await self.session.initialize() - - async def initialize(self): - self.ap.logger.debug(f'初始化 MCP 会话: {self.server_name} {self.server_config}') - - if self.server_config['mode'] == 'stdio': - await self._init_stdio_python_server() - elif self.server_config['mode'] == 'sse': - await self._init_sse_server() - else: - raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}') - - tools = await self.session.list_tools() - - self.ap.logger.debug(f'获取 MCP 工具: {tools}') - - for tool in tools.tools: - - async def func(*, _tool=tool, **kwargs): - result = await self.session.call_tool(_tool.name, kwargs) - if result.isError: - raise Exception(result.content[0].text) - return result.content[0].text - - func.__name__ = tool.name - - self.functions.append( - resource_tool.LLMTool( - name=tool.name, - human_desc=tool.description, - description=tool.description, - parameters=tool.inputSchema, - func=func, - ) - ) - - async def shutdown(self): - """关闭工具""" - await self.session._exit_stack.aclose() - - -@loader.loader_class('mcp') -class MCPLoader(loader.ToolLoader): - """MCP 工具加载器。 - - 在此加载器中管理所有与 MCP Server 的连接。 - """ - - sessions: dict[str, RuntimeMCPSession] = {} - - _last_listed_functions: list[resource_tool.LLMTool] = [] - - def __init__(self, ap: app.Application): - super().__init__(ap) - self.sessions = {} - self._last_listed_functions = [] - - async def initialize(self): - for server_config in self.ap.instance_config.data.get('mcp', {}).get('servers', []): - if not server_config['enable']: - continue - session = RuntimeMCPSession(server_config['name'], server_config, self.ap) - await session.initialize() - # self.ap.event_loop.create_task(session.initialize()) - self.sessions[server_config['name']] = session - - async def get_tools(self) -> list[resource_tool.LLMTool]: - all_functions = [] - - for session in self.sessions.values(): - all_functions.extend(session.functions) - - self._last_listed_functions = all_functions - - return all_functions - - async def has_tool(self, name: str) -> bool: - return name in [f.name for f in self._last_listed_functions] - - async def invoke_tool(self, name: str, parameters: dict) -> typing.Any: - for server_name, session in self.sessions.items(): - for function in session.functions: - if function.name == name: - return await function.func(**parameters) - - raise ValueError(f'未找到工具: {name}') - - async def shutdown(self): - """关闭工具""" - for session in self.sessions.values(): - await session.shutdown() diff --git a/pkg/storage/mgr.py b/pkg/storage/mgr.py deleted file mode 100644 index 8d52e465..00000000 --- a/pkg/storage/mgr.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - - -from ..core import app -from . import provider -from .providers import localstorage - - -class StorageMgr: - """存储管理器""" - - ap: app.Application - - storage_provider: provider.StorageProvider - - def __init__(self, ap: app.Application): - self.ap = ap - self.storage_provider = localstorage.LocalStorageProvider(ap) - - async def initialize(self): - await self.storage_provider.initialize() diff --git a/pkg/utils/announce.py b/pkg/utils/announce.py deleted file mode 100644 index 8778a04f..00000000 --- a/pkg/utils/announce.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import json -import typing -import os -import base64 -import logging - -import pydantic -import requests - -from ..core import app - - -class Announcement(pydantic.BaseModel): - """公告""" - - id: int - - time: str - - timestamp: int - - content: str - - enabled: typing.Optional[bool] = True - - def to_dict(self) -> dict: - return { - 'id': self.id, - 'time': self.time, - 'timestamp': self.timestamp, - 'content': self.content, - 'enabled': self.enabled, - } - - -class AnnouncementManager: - """公告管理器""" - - ap: app.Application = None - - def __init__(self, ap: app.Application): - self.ap = ap - - async def fetch_all(self) -> list[Announcement]: - """获取所有公告""" - try: - resp = requests.get( - url='https://api.github.com/repos/langbot-app/LangBot/contents/res/announcement.json', - proxies=self.ap.proxy_mgr.get_forward_proxies(), - timeout=5, - ) - resp.raise_for_status() # 检查请求是否成功 - obj_json = resp.json() - b64_content = obj_json['content'] - # 解码 - content = base64.b64decode(b64_content).decode('utf-8') - - return [Announcement(**item) for item in json.loads(content)] - except (requests.RequestException, json.JSONDecodeError, KeyError) as e: - self.ap.logger.warning(f'获取公告失败: {e}') - pass - return [] # 请求失败时返回空列表 - - async def fetch_saved(self) -> list[Announcement]: - if not os.path.exists('data/labels/announcement_saved.json'): - with open('data/labels/announcement_saved.json', 'w', encoding='utf-8') as f: - f.write('[]') - - with open('data/labels/announcement_saved.json', 'r', encoding='utf-8') as f: - content = f.read() - - if not content: - content = '[]' - - return [Announcement(**item) for item in json.loads(content)] - - async def write_saved(self, content: list[Announcement]): - with open('data/labels/announcement_saved.json', 'w', encoding='utf-8') as f: - f.write(json.dumps([item.to_dict() for item in content], indent=4, ensure_ascii=False)) - - async def fetch_new(self) -> list[Announcement]: - """获取新公告""" - all = await self.fetch_all() - saved = await self.fetch_saved() - - to_show: list[Announcement] = [] - - for item in all: - # 遍历saved检查是否有相同id的公告 - for saved_item in saved: - if saved_item.id == item.id: - break - else: - if item.enabled: - # 没有相同id的公告 - to_show.append(item) - - await self.write_saved(all) - return to_show - - async def show_announcements(self) -> typing.Tuple[str, int]: - """显示公告""" - try: - announcements = await self.fetch_new() - ann_text = '' - for ann in announcements: - ann_text += f'[公告] {ann.time}: {ann.content}\n' - - # TODO statistics - - return ann_text, logging.INFO - except Exception as e: - return f'获取公告时出错: {e}', logging.WARNING diff --git a/pyproject.toml b/pyproject.toml index 1384b22c..99eb9aa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,9 @@ [project] name = "langbot" -version = "4.3.9" +version = "4.5.3" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" +license-files = ["LICENSE"] requires-python = ">=3.10.1,<4.0" dependencies = [ "aiocqhttp>=1.4.4", @@ -45,7 +46,6 @@ dependencies = [ "urllib3>=2.4.0", "websockets>=15.0.1", "python-socks>=2.7.1", # dingtalk missing dependency - "taskgroup==0.0.0a4", # graingert/taskgroup#20 "pip>=25.1.1", "ruff>=0.11.9", "pre-commit>=4.2.0", @@ -63,10 +63,11 @@ dependencies = [ "langchain-text-splitters>=0.0.1", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.1.4", + "langbot-plugin==0.1.11", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", + "boto3>=1.35.0", ] keywords = [ "bot", @@ -84,11 +85,10 @@ keywords = [ "onebot", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Framework :: Robot Framework", "Framework :: Robot Framework :: Library", - "License :: OSI Approved :: AGPL-3 License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Topic :: Communications :: Chat", @@ -99,6 +99,16 @@ Homepage = "https://langbot.app" Documentation = "https://docs.langbot.app" Repository = "https://github.com/langbot-app/LangBot" +[project.scripts] +langbot = "langbot.__main__:main" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] } + [dependency-groups] dev = [ "pre-commit>=4.2.0", diff --git a/pytest.ini b/pytest.ini index 80cda02e..ef5b705d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -26,7 +26,7 @@ markers = # Coverage options (when using pytest-cov) [coverage:run] -source = pkg +source = langbot.pkg omit = */tests/* */test_*.py diff --git a/src/langbot/__init__.py b/src/langbot/__init__.py new file mode 100644 index 00000000..ca03d526 --- /dev/null +++ b/src/langbot/__init__.py @@ -0,0 +1,3 @@ +"""LangBot - Easy-to-use global IM bot platform designed for LLM era""" + +__version__ = '4.5.3' diff --git a/src/langbot/__main__.py b/src/langbot/__main__.py new file mode 100644 index 00000000..b94500e7 --- /dev/null +++ b/src/langbot/__main__.py @@ -0,0 +1,104 @@ +"""LangBot entry point for package execution""" + +import asyncio +import argparse +import sys +import os + +# ASCII art banner +asciiart = r""" + _ ___ _ +| | __ _ _ _ __ _| _ ) ___| |_ +| |__/ _` | ' \/ _` | _ \/ _ \ _| +|____\__,_|_||_\__, |___/\___/\__| + |___/ + +⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot +📖 Documentation 文档地址: https://docs.langbot.app +""" + + +async def main_entry(loop: asyncio.AbstractEventLoop): + """Main entry point for LangBot""" + parser = argparse.ArgumentParser(description='LangBot') + parser.add_argument( + '--standalone-runtime', + action='store_true', + help='Use standalone plugin runtime / 使用独立插件运行时', + default=False, + ) + parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False) + args = parser.parse_args() + + if args.standalone_runtime: + from langbot.pkg.utils import platform + + platform.standalone_runtime = True + + if args.debug: + from langbot.pkg.utils import constants + + constants.debug_mode = True + + print(asciiart) + + # Check dependencies + from langbot.pkg.core.bootutils import deps + + missing_deps = await deps.check_deps() + + if missing_deps: + print('以下依赖包未安装,将自动安装,请完成后重启程序:') + print( + 'These dependencies are missing, they will be installed automatically, please restart the program after completion:' + ) + for dep in missing_deps: + print('-', dep) + await deps.install_deps(missing_deps) + print('已自动安装缺失的依赖包,请重启程序。') + print('The missing dependencies have been installed automatically, please restart the program.') + sys.exit(0) + + # Check configuration files + from langbot.pkg.core.bootutils import files + + generated_files = await files.generate_files() + + if generated_files: + print('以下文件不存在,已自动生成:') + print('Following files do not exist and have been automatically generated:') + for file in generated_files: + print('-', file) + + from langbot.pkg.core import boot + + await boot.main(loop) + + +def main(): + """Main function to be called by console script entry point""" + # Check Python version + if sys.version_info < (3, 10, 1): + print('需要 Python 3.10.1 及以上版本,当前 Python 版本为:', sys.version) + print('Your Python version is not supported.') + print('Python 3.10.1 or higher is required. Current version:', sys.version) + sys.exit(1) + + # Set up the working directory + # When installed as a package, we need to handle the working directory differently + # We'll create data directory in current working directory if not exists + os.makedirs('data', exist_ok=True) + + loop = asyncio.new_event_loop() + + try: + loop.run_until_complete(main_entry(loop)) + except KeyboardInterrupt: + print('\n正在退出...') + print('Exiting...') + finally: + loop.close() + + +if __name__ == '__main__': + main() diff --git a/libs/LICENSE b/src/langbot/libs/LICENSE similarity index 100% rename from libs/LICENSE rename to src/langbot/libs/LICENSE diff --git a/libs/README.md b/src/langbot/libs/README.md similarity index 100% rename from libs/README.md rename to src/langbot/libs/README.md diff --git a/libs/coze_server_api/__init__.py b/src/langbot/libs/coze_server_api/__init__.py similarity index 100% rename from libs/coze_server_api/__init__.py rename to src/langbot/libs/coze_server_api/__init__.py diff --git a/libs/coze_server_api/client.py b/src/langbot/libs/coze_server_api/client.py similarity index 60% rename from libs/coze_server_api/client.py rename to src/langbot/libs/coze_server_api/client.py index 67f53736..54fb4749 100644 --- a/libs/coze_server_api/client.py +++ b/src/langbot/libs/coze_server_api/client.py @@ -7,10 +7,8 @@ import os from pathlib import Path - - class AsyncCozeAPIClient: - def __init__(self, api_key: str, api_base: str = "https://api.coze.cn"): + def __init__(self, api_key: str, api_base: str = 'https://api.coze.cn'): self.api_key = api_key self.api_base = api_base self.session = None @@ -24,13 +22,11 @@ class AsyncCozeAPIClient: """退出时自动关闭会话""" await self.close() - - async def coze_session(self): """确保HTTP session存在""" if self.session is None: connector = aiohttp.TCPConnector( - ssl=False if self.api_base.startswith("http://") else True, + ssl=False if self.api_base.startswith('http://') else True, limit=100, limit_per_host=30, keepalive_timeout=30, @@ -42,12 +38,10 @@ class AsyncCozeAPIClient: sock_read=120, ) headers = { - "Authorization": f"Bearer {self.api_key}", - "Accept": "text/event-stream", + 'Authorization': f'Bearer {self.api_key}', + 'Accept': 'text/event-stream', } - self.session = aiohttp.ClientSession( - headers=headers, timeout=timeout, connector=connector - ) + self.session = aiohttp.ClientSession(headers=headers, timeout=timeout, connector=connector) return self.session async def close(self): @@ -63,15 +57,15 @@ class AsyncCozeAPIClient: # 处理 Path 对象 if isinstance(file, Path): if not file.exists(): - raise ValueError(f"File not found: {file}") - with open(file, "rb") as f: + 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: + raise ValueError(f'File not found: {file}') + with open(file, 'rb') as f: file = f.read() # 处理文件对象 @@ -79,43 +73,39 @@ class AsyncCozeAPIClient: file = file.read() session = await self.coze_session() - url = f"{self.api_base}/v1/files/upload" + url = f'{self.api_base}/v1/files/upload' try: file_io = io.BytesIO(file) async with session.post( url, data={ - "file": file_io, + 'file': file_io, }, timeout=aiohttp.ClientTimeout(total=60), ) as response: if response.status == 401: - raise Exception("Coze API 认证失败,请检查 API Key 是否正确") + raise Exception('Coze API 认证失败,请检查 API Key 是否正确') response_text = await response.text() - if response.status != 200: - raise Exception( - f"文件上传失败,状态码: {response.status}, 响应: {response_text}" - ) + raise Exception(f'文件上传失败,状态码: {response.status}, 响应: {response_text}') try: result = await response.json() except json.JSONDecodeError: - raise Exception(f"文件上传响应解析失败: {response_text}") + raise Exception(f'文件上传响应解析失败: {response_text}') - if result.get("code") != 0: - raise Exception(f"文件上传失败: {result.get('msg', '未知错误')}") + if result.get('code') != 0: + raise Exception(f'文件上传失败: {result.get("msg", "未知错误")}') - file_id = result["data"]["id"] + file_id = result['data']['id'] return file_id except asyncio.TimeoutError: - raise Exception("文件上传超时") + raise Exception('文件上传超时') except Exception as e: - raise Exception(f"文件上传失败: {str(e)}") - + raise Exception(f'文件上传失败: {str(e)}') async def chat_messages( self, @@ -139,22 +129,21 @@ class AsyncCozeAPIClient: timeout: 超时时间 """ session = await self.coze_session() - url = f"{self.api_base}/v3/chat" + url = f'{self.api_base}/v3/chat' payload = { - "bot_id": bot_id, - "user_id": user_id, - "stream": stream, - "auto_save_history": auto_save_history, + 'bot_id': bot_id, + 'user_id': user_id, + 'stream': stream, + 'auto_save_history': auto_save_history, } if additional_messages: - payload["additional_messages"] = additional_messages + payload['additional_messages'] = additional_messages params = {} if conversation_id: - params["conversation_id"] = conversation_id - + params['conversation_id'] = conversation_id try: async with session.post( @@ -164,29 +153,25 @@ class AsyncCozeAPIClient: timeout=aiohttp.ClientTimeout(total=timeout), ) as response: if response.status == 401: - raise Exception("Coze API 认证失败,请检查 API Key 是否正确") + raise Exception('Coze API 认证失败,请检查 API Key 是否正确') if response.status != 200: - raise Exception(f"Coze API 流式请求失败,状态码: {response.status}") - + raise Exception(f'Coze API 流式请求失败,状态码: {response.status}') async for chunk in response.content: - chunk = chunk.decode("utf-8") + chunk = chunk.decode('utf-8') if chunk != '\n': - if chunk.startswith("event:"): - chunk_type = chunk.replace("event:", "", 1).strip() - elif chunk.startswith("data:"): - chunk_data = chunk.replace("data:", "", 1).strip() + if chunk.startswith('event:'): + chunk_type = chunk.replace('event:', '', 1).strip() + elif chunk.startswith('data:'): + chunk_data = chunk.replace('data:', '', 1).strip() else: - yield {"event": chunk_type, "data": json.loads(chunk_data)} + yield { + 'event': chunk_type, + 'data': json.loads(chunk_data) if chunk_data else {}, + } # 处理本地部署时,接口返回的data为空值 except asyncio.TimeoutError: - raise Exception(f"Coze API 流式请求超时 ({timeout}秒)") + raise Exception(f'Coze API 流式请求超时 ({timeout}秒)') except Exception as e: - raise Exception(f"Coze API 流式请求失败: {str(e)}") - - - - - - + raise Exception(f'Coze API 流式请求失败: {str(e)}') diff --git a/libs/dify_service_api/README.md b/src/langbot/libs/dify_service_api/README.md similarity index 100% rename from libs/dify_service_api/README.md rename to src/langbot/libs/dify_service_api/README.md diff --git a/libs/dify_service_api/__init__.py b/src/langbot/libs/dify_service_api/__init__.py similarity index 100% rename from libs/dify_service_api/__init__.py rename to src/langbot/libs/dify_service_api/__init__.py diff --git a/libs/dify_service_api/v1/__init__.py b/src/langbot/libs/dify_service_api/v1/__init__.py similarity index 100% rename from libs/dify_service_api/v1/__init__.py rename to src/langbot/libs/dify_service_api/v1/__init__.py diff --git a/libs/dify_service_api/v1/client.py b/src/langbot/libs/dify_service_api/v1/client.py similarity index 86% rename from libs/dify_service_api/v1/client.py rename to src/langbot/libs/dify_service_api/v1/client.py index 35defe2c..244d701e 100644 --- a/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/libs/dify_service_api/v1/client_test.py b/src/langbot/libs/dify_service_api/v1/client_test.py similarity index 100% rename from libs/dify_service_api/v1/client_test.py rename to src/langbot/libs/dify_service_api/v1/client_test.py diff --git a/libs/dify_service_api/v1/errors.py b/src/langbot/libs/dify_service_api/v1/errors.py similarity index 100% rename from libs/dify_service_api/v1/errors.py rename to src/langbot/libs/dify_service_api/v1/errors.py diff --git a/libs/dingtalk_api/EchoHandler.py b/src/langbot/libs/dingtalk_api/EchoHandler.py similarity index 100% rename from libs/dingtalk_api/EchoHandler.py rename to src/langbot/libs/dingtalk_api/EchoHandler.py diff --git a/libs/dingtalk_api/__init__.py b/src/langbot/libs/dingtalk_api/__init__.py similarity index 100% rename from libs/dingtalk_api/__init__.py rename to src/langbot/libs/dingtalk_api/__init__.py diff --git a/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py similarity index 81% rename from libs/dingtalk_api/api.py rename to src/langbot/libs/dingtalk_api/api.py index 1731d38a..abd68a40 100644 --- a/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/libs/dingtalk_api/dingtalkevent.py b/src/langbot/libs/dingtalk_api/dingtalkevent.py similarity index 95% rename from libs/dingtalk_api/dingtalkevent.py rename to src/langbot/libs/dingtalk_api/dingtalkevent.py index adc4ea7c..29322bcb 100644 --- a/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') @@ -39,7 +43,6 @@ class DingTalkEvent(dict): def name(self): return self.get('Name', '') - @property def conversation(self): return self.get('conversation_type', '') diff --git a/libs/official_account_api/__init__.py b/src/langbot/libs/official_account_api/__init__.py similarity index 100% rename from libs/official_account_api/__init__.py rename to src/langbot/libs/official_account_api/__init__.py diff --git a/libs/official_account_api/api.py b/src/langbot/libs/official_account_api/api.py similarity index 99% rename from libs/official_account_api/api.py rename to src/langbot/libs/official_account_api/api.py index bde683a9..671f49a4 100644 --- a/libs/official_account_api/api.py +++ b/src/langbot/libs/official_account_api/api.py @@ -1,12 +1,12 @@ # 微信公众号的加解密算法与企业微信一样,所以直接使用企业微信的加解密算法文件 import time import traceback -from libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt +from langbot.libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt import xml.etree.ElementTree as ET from quart import Quart, request import hashlib from typing import Callable -from .oaevent import OAEvent +from langbot.libs.official_account_api.oaevent import OAEvent import asyncio diff --git a/libs/official_account_api/oaevent.py b/src/langbot/libs/official_account_api/oaevent.py similarity index 100% rename from libs/official_account_api/oaevent.py rename to src/langbot/libs/official_account_api/oaevent.py diff --git a/libs/qq_official_api/__init__.py b/src/langbot/libs/qq_official_api/__init__.py similarity index 100% rename from libs/qq_official_api/__init__.py rename to src/langbot/libs/qq_official_api/__init__.py diff --git a/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py similarity index 100% rename from libs/qq_official_api/api.py rename to src/langbot/libs/qq_official_api/api.py diff --git a/libs/qq_official_api/qqofficialevent.py b/src/langbot/libs/qq_official_api/qqofficialevent.py similarity index 100% rename from libs/qq_official_api/qqofficialevent.py rename to src/langbot/libs/qq_official_api/qqofficialevent.py diff --git a/libs/slack_api/__init__.py b/src/langbot/libs/slack_api/__init__.py similarity index 100% rename from libs/slack_api/__init__.py rename to src/langbot/libs/slack_api/__init__.py diff --git a/libs/slack_api/api.py b/src/langbot/libs/slack_api/api.py similarity index 100% rename from libs/slack_api/api.py rename to src/langbot/libs/slack_api/api.py diff --git a/libs/slack_api/slackevent.py b/src/langbot/libs/slack_api/slackevent.py similarity index 100% rename from libs/slack_api/slackevent.py rename to src/langbot/libs/slack_api/slackevent.py diff --git a/libs/wechatpad_api/LICENSE b/src/langbot/libs/wechatpad_api/LICENSE similarity index 100% rename from libs/wechatpad_api/LICENSE rename to src/langbot/libs/wechatpad_api/LICENSE diff --git a/libs/wechatpad_api/README.md b/src/langbot/libs/wechatpad_api/README.md similarity index 100% rename from libs/wechatpad_api/README.md rename to src/langbot/libs/wechatpad_api/README.md diff --git a/libs/wechatpad_api/__init__.py b/src/langbot/libs/wechatpad_api/__init__.py similarity index 100% rename from libs/wechatpad_api/__init__.py rename to src/langbot/libs/wechatpad_api/__init__.py diff --git a/libs/wechatpad_api/api/__init__.py b/src/langbot/libs/wechatpad_api/api/__init__.py similarity index 100% rename from libs/wechatpad_api/api/__init__.py rename to src/langbot/libs/wechatpad_api/api/__init__.py diff --git a/libs/wechatpad_api/api/chatroom.py b/src/langbot/libs/wechatpad_api/api/chatroom.py similarity index 84% rename from libs/wechatpad_api/api/chatroom.py rename to src/langbot/libs/wechatpad_api/api/chatroom.py index 2d9281a2..63360a23 100644 --- a/libs/wechatpad_api/api/chatroom.py +++ b/src/langbot/libs/wechatpad_api/api/chatroom.py @@ -1,4 +1,4 @@ -from libs.wechatpad_api.util.http_util import post_json +from langbot.libs.wechatpad_api.util.http_util import post_json class ChatRoomApi: diff --git a/libs/wechatpad_api/api/downloadpai.py b/src/langbot/libs/wechatpad_api/api/downloadpai.py similarity index 94% rename from libs/wechatpad_api/api/downloadpai.py rename to src/langbot/libs/wechatpad_api/api/downloadpai.py index 2d45fac6..3fbdb624 100644 --- a/libs/wechatpad_api/api/downloadpai.py +++ b/src/langbot/libs/wechatpad_api/api/downloadpai.py @@ -1,4 +1,4 @@ -from libs.wechatpad_api.util.http_util import post_json +from langbot.libs.wechatpad_api.util.http_util import post_json import httpx import base64 diff --git a/libs/wechatpad_api/api/friend.py b/src/langbot/libs/wechatpad_api/api/friend.py similarity index 100% rename from libs/wechatpad_api/api/friend.py rename to src/langbot/libs/wechatpad_api/api/friend.py diff --git a/libs/wechatpad_api/api/login.py b/src/langbot/libs/wechatpad_api/api/login.py similarity index 96% rename from libs/wechatpad_api/api/login.py rename to src/langbot/libs/wechatpad_api/api/login.py index 4aa4ae8d..23e89b88 100644 --- a/libs/wechatpad_api/api/login.py +++ b/src/langbot/libs/wechatpad_api/api/login.py @@ -1,4 +1,4 @@ -from libs.wechatpad_api.util.http_util import post_json, get_json +from langbot.libs.wechatpad_api.util.http_util import post_json, get_json class LoginApi: diff --git a/libs/wechatpad_api/api/message.py b/src/langbot/libs/wechatpad_api/api/message.py similarity index 97% rename from libs/wechatpad_api/api/message.py rename to src/langbot/libs/wechatpad_api/api/message.py index cca76313..52c7bdb5 100644 --- a/libs/wechatpad_api/api/message.py +++ b/src/langbot/libs/wechatpad_api/api/message.py @@ -1,4 +1,4 @@ -from libs.wechatpad_api.util.http_util import post_json +from langbot.libs.wechatpad_api.util.http_util import post_json class MessageApi: diff --git a/libs/wechatpad_api/api/user.py b/src/langbot/libs/wechatpad_api/api/user.py similarity index 91% rename from libs/wechatpad_api/api/user.py rename to src/langbot/libs/wechatpad_api/api/user.py index d2187c7c..0725b9d3 100644 --- a/libs/wechatpad_api/api/user.py +++ b/src/langbot/libs/wechatpad_api/api/user.py @@ -1,4 +1,4 @@ -from libs.wechatpad_api.util.http_util import post_json, async_request, get_json +from langbot.libs.wechatpad_api.util.http_util import post_json, async_request, get_json class UserApi: diff --git a/libs/wechatpad_api/client.py b/src/langbot/libs/wechatpad_api/client.py similarity index 89% rename from libs/wechatpad_api/client.py rename to src/langbot/libs/wechatpad_api/client.py index 5e699d03..bb9b2f54 100644 --- a/libs/wechatpad_api/client.py +++ b/src/langbot/libs/wechatpad_api/client.py @@ -1,9 +1,9 @@ -from libs.wechatpad_api.api.login import LoginApi -from libs.wechatpad_api.api.friend import FriendApi -from libs.wechatpad_api.api.message import MessageApi -from libs.wechatpad_api.api.user import UserApi -from libs.wechatpad_api.api.downloadpai import DownloadApi -from libs.wechatpad_api.api.chatroom import ChatRoomApi +from langbot.libs.wechatpad_api.api.login import LoginApi +from langbot.libs.wechatpad_api.api.friend import FriendApi +from langbot.libs.wechatpad_api.api.message import MessageApi +from langbot.libs.wechatpad_api.api.user import UserApi +from langbot.libs.wechatpad_api.api.downloadpai import DownloadApi +from langbot.libs.wechatpad_api.api.chatroom import ChatRoomApi class WeChatPadClient: diff --git a/libs/wechatpad_api/util/__init__.py b/src/langbot/libs/wechatpad_api/util/__init__.py similarity index 100% rename from libs/wechatpad_api/util/__init__.py rename to src/langbot/libs/wechatpad_api/util/__init__.py diff --git a/libs/wechatpad_api/util/http_util.py b/src/langbot/libs/wechatpad_api/util/http_util.py similarity index 100% rename from libs/wechatpad_api/util/http_util.py rename to src/langbot/libs/wechatpad_api/util/http_util.py diff --git a/libs/wechatpad_api/util/terminal_printer.py b/src/langbot/libs/wechatpad_api/util/terminal_printer.py similarity index 100% rename from libs/wechatpad_api/util/terminal_printer.py rename to src/langbot/libs/wechatpad_api/util/terminal_printer.py diff --git a/libs/wecom_ai_bot_api/WXBizMsgCrypt3.py b/src/langbot/libs/wecom_ai_bot_api/WXBizMsgCrypt3.py similarity index 99% rename from libs/wecom_ai_bot_api/WXBizMsgCrypt3.py rename to src/langbot/libs/wecom_ai_bot_api/WXBizMsgCrypt3.py index 96e9367f..bd8250da 100644 --- a/libs/wecom_ai_bot_api/WXBizMsgCrypt3.py +++ b/src/langbot/libs/wecom_ai_bot_api/WXBizMsgCrypt3.py @@ -16,7 +16,7 @@ import struct from Crypto.Cipher import AES import xml.etree.cElementTree as ET import socket -from libs.wecom_ai_bot_api import ierror +from langbot.libs.wecom_ai_bot_api import ierror """ diff --git a/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py similarity index 100% rename from libs/wecom_ai_bot_api/api.py rename to src/langbot/libs/wecom_ai_bot_api/api.py diff --git a/libs/wecom_ai_bot_api/ierror.py b/src/langbot/libs/wecom_ai_bot_api/ierror.py similarity index 100% rename from libs/wecom_ai_bot_api/ierror.py rename to src/langbot/libs/wecom_ai_bot_api/ierror.py diff --git a/libs/wecom_ai_bot_api/wecombotevent.py b/src/langbot/libs/wecom_ai_bot_api/wecombotevent.py similarity index 100% rename from libs/wecom_ai_bot_api/wecombotevent.py rename to src/langbot/libs/wecom_ai_bot_api/wecombotevent.py diff --git a/libs/wecom_api/WXBizMsgCrypt3.py b/src/langbot/libs/wecom_api/WXBizMsgCrypt3.py similarity index 100% rename from libs/wecom_api/WXBizMsgCrypt3.py rename to src/langbot/libs/wecom_api/WXBizMsgCrypt3.py diff --git a/libs/wecom_api/__init__.py b/src/langbot/libs/wecom_api/__init__.py similarity index 100% rename from libs/wecom_api/__init__.py rename to src/langbot/libs/wecom_api/__init__.py diff --git a/libs/wecom_api/api.py b/src/langbot/libs/wecom_api/api.py similarity index 97% rename from libs/wecom_api/api.py rename to src/langbot/libs/wecom_api/api.py index fbad27d9..7a6e1c69 100644 --- a/libs/wecom_api/api.py +++ b/src/langbot/libs/wecom_api/api.py @@ -115,14 +115,13 @@ class WecomClient: async def send_image(self, user_id: str, agent_id: int, media_id: str): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) - url = self.base_url + '/media/upload?access_token=' + self.access_token + + url = self.base_url + '/message/send?access_token=' + self.access_token async with httpx.AsyncClient() as client: params = { 'touser': user_id, - 'toparty': '', - 'totag': '', - 'agentid': agent_id, 'msgtype': 'image', + 'agentid': agent_id, 'image': { 'media_id': media_id, }, @@ -131,19 +130,13 @@ class WecomClient: 'enable_duplicate_check': 0, 'duplicate_check_interval': 1800, } - try: - response = await client.post(url, json=params) - data = response.json() - except Exception as e: - await self.logger.error(f'发送图片失败:{data}') - raise Exception('Failed to send image: ' + str(e)) - - # 企业微信错误码40014和42001,代表accesstoken问题 + response = await client.post(url, json=params) + data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) return await self.send_image(user_id, agent_id, media_id) - if data['errcode'] != 0: + await self.logger.error(f'发送图片失败:{data}') raise Exception('Failed to send image: ' + str(data)) async def send_private_msg(self, user_id: str, agent_id: int, content: str): @@ -364,4 +357,3 @@ class WecomClient: async def get_media_id(self, image: platform_message.Image): media_id = await self.upload_to_work(image=image) return media_id - diff --git a/libs/wecom_api/ierror.py b/src/langbot/libs/wecom_api/ierror.py similarity index 100% rename from libs/wecom_api/ierror.py rename to src/langbot/libs/wecom_api/ierror.py diff --git a/libs/wecom_api/wecomevent.py b/src/langbot/libs/wecom_api/wecomevent.py similarity index 100% rename from libs/wecom_api/wecomevent.py rename to src/langbot/libs/wecom_api/wecomevent.py diff --git a/libs/wecom_customer_service_api/__init__.py b/src/langbot/libs/wecom_customer_service_api/__init__.py similarity index 100% rename from libs/wecom_customer_service_api/__init__.py rename to src/langbot/libs/wecom_customer_service_api/__init__.py diff --git a/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py similarity index 100% rename from libs/wecom_customer_service_api/api.py rename to src/langbot/libs/wecom_customer_service_api/api.py diff --git a/libs/wecom_customer_service_api/wecomcsevent.py b/src/langbot/libs/wecom_customer_service_api/wecomcsevent.py similarity index 100% rename from libs/wecom_customer_service_api/wecomcsevent.py rename to src/langbot/libs/wecom_customer_service_api/wecomcsevent.py diff --git a/pkg/__init__.py b/src/langbot/pkg/__init__.py similarity index 100% rename from pkg/__init__.py rename to src/langbot/pkg/__init__.py diff --git a/pkg/api/__init__.py b/src/langbot/pkg/api/__init__.py similarity index 100% rename from pkg/api/__init__.py rename to src/langbot/pkg/api/__init__.py diff --git a/pkg/api/http/__init__.py b/src/langbot/pkg/api/http/__init__.py similarity index 100% rename from pkg/api/http/__init__.py rename to src/langbot/pkg/api/http/__init__.py diff --git a/pkg/api/http/controller/__init__.py b/src/langbot/pkg/api/http/controller/__init__.py similarity index 100% rename from pkg/api/http/controller/__init__.py rename to src/langbot/pkg/api/http/controller/__init__.py diff --git a/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py similarity index 52% rename from pkg/api/http/controller/group.py rename to src/langbot/pkg/api/http/controller/group.py index 8ab4f4d9..2ed55187 100644 --- a/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -9,6 +9,9 @@ from quart.typing import RouteCallable from ....core import app +# Maximum file upload size limit (10MB) +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + preregistered_groups: list[type[RouterGroup]] = [] """Pre-registered list of RouterGroup""" @@ -31,6 +34,8 @@ class AuthType(enum.Enum): NONE = 'none' USER_TOKEN = 'user-token' + API_KEY = 'api-key' + USER_TOKEN_OR_API_KEY = 'user-token-or-api-key' class RouterGroup(abc.ABC): @@ -84,6 +89,65 @@ class RouterGroup(abc.ABC): except Exception as e: return self.http_status(401, -1, str(e)) + elif auth_type == AuthType.API_KEY: + # get API key from Authorization header or X-API-Key header + api_key = quart.request.headers.get('X-API-Key', '') + if not api_key: + auth_header = quart.request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + api_key = auth_header.replace('Bearer ', '') + + if not api_key: + return self.http_status(401, -1, 'No valid API key provided') + + try: + is_valid = await self.ap.apikey_service.verify_api_key(api_key) + if not is_valid: + return self.http_status(401, -1, 'Invalid API key') + except Exception as e: + return self.http_status(401, -1, str(e)) + + elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: + # Try API key first (check X-API-Key header) + api_key = quart.request.headers.get('X-API-Key', '') + + if api_key: + # API key authentication + try: + is_valid = await self.ap.apikey_service.verify_api_key(api_key) + if not is_valid: + return self.http_status(401, -1, 'Invalid API key') + except Exception as e: + return self.http_status(401, -1, str(e)) + else: + # Try user token authentication (Authorization header) + token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') + + if not token: + return self.http_status( + 401, -1, 'No valid authentication provided (user token or API key required)' + ) + + try: + user_email = await self.ap.user_service.verify_jwt_token(token) + + # check if this account exists + user = await self.ap.user_service.get_user_by_email(user_email) + if not user: + return self.http_status(401, -1, 'User not found') + + # check if f accepts user_email parameter + if 'user_email' in f.__code__.co_varnames: + kwargs['user_email'] = user_email + except Exception: + # If user token fails, maybe it's an API key in Authorization header + try: + is_valid = await self.ap.apikey_service.verify_api_key(token) + if not is_valid: + return self.http_status(401, -1, 'Invalid authentication credentials') + except Exception as e: + return self.http_status(401, -1, str(e)) + try: return await f(*args, **kwargs) diff --git a/pkg/api/http/controller/groups/__init__.py b/src/langbot/pkg/api/http/controller/groups/__init__.py similarity index 100% rename from pkg/api/http/controller/groups/__init__.py rename to src/langbot/pkg/api/http/controller/groups/__init__.py diff --git a/src/langbot/pkg/api/http/controller/groups/apikeys.py b/src/langbot/pkg/api/http/controller/groups/apikeys.py new file mode 100644 index 00000000..f53728bf --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/apikeys.py @@ -0,0 +1,43 @@ +import quart + +from .. import group + + +@group.group_class('apikeys', '/api/v1/apikeys') +class ApiKeysRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST']) + async def _() -> str: + if quart.request.method == 'GET': + keys = await self.ap.apikey_service.get_api_keys() + return self.success(data={'keys': keys}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + name = json_data.get('name', '') + description = json_data.get('description', '') + + if not name: + return self.http_status(400, -1, 'Name is required') + + key = await self.ap.apikey_service.create_api_key(name, description) + return self.success(data={'key': key}) + + @self.route('/', methods=['GET', 'PUT', 'DELETE']) + async def _(key_id: int) -> str: + if quart.request.method == 'GET': + key = await self.ap.apikey_service.get_api_key(key_id) + if key is None: + return self.http_status(404, -1, 'API key not found') + return self.success(data={'key': key}) + + elif quart.request.method == 'PUT': + json_data = await quart.request.json + name = json_data.get('name') + description = json_data.get('description') + + await self.ap.apikey_service.update_api_key(key_id, name, description) + return self.success() + + elif quart.request.method == 'DELETE': + await self.ap.apikey_service.delete_api_key(key_id) + return self.success() diff --git a/pkg/api/http/controller/groups/files.py b/src/langbot/pkg/api/http/controller/groups/files.py similarity index 61% rename from pkg/api/http/controller/groups/files.py rename to src/langbot/pkg/api/http/controller/groups/files.py index c90d172e..05877e14 100644 --- a/pkg/api/http/controller/groups/files.py +++ b/src/langbot/pkg/api/http/controller/groups/files.py @@ -31,19 +31,41 @@ class FilesRouterGroup(group.RouterGroup): @self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> quart.Response: request = quart.request + + # Check file size limit before reading the file + content_length = request.content_length + if content_length and content_length > group.MAX_FILE_SIZE: + return self.fail(400, 'File size exceeds 10MB limit. Please split large files into smaller parts.') + # get file bytes from 'file' - file = (await request.files)['file'] + files = await request.files + if 'file' not in files: + return self.fail(400, 'No file provided in request') + + file = files['file'] assert isinstance(file, quart.datastructures.FileStorage) file_bytes = await asyncio.to_thread(file.stream.read) - extension = file.filename.split('.')[-1] - file_name = file.filename.split('.')[0] + + # Double-check actual file size after reading + if len(file_bytes) > group.MAX_FILE_SIZE: + return self.fail(400, 'File size exceeds 10MB limit. Please split large files into smaller parts.') + + # Split filename and extension properly + if '.' in file.filename: + file_name, extension = file.filename.rsplit('.', 1) + else: + file_name = file.filename + extension = '' # check if file name contains '/' or '\' if '/' in file_name or '\\' in file_name: return self.fail(400, 'File name contains invalid characters') - file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension + file_key = file_name + '_' + str(uuid.uuid4())[:8] + if extension: + file_key += '.' + extension + # save file to storage await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) return self.success( diff --git a/pkg/api/http/controller/groups/knowledge/__init__.py b/src/langbot/pkg/api/http/controller/groups/knowledge/__init__.py similarity index 100% rename from pkg/api/http/controller/groups/knowledge/__init__.py rename to src/langbot/pkg/api/http/controller/groups/knowledge/__init__.py diff --git a/pkg/api/http/controller/groups/knowledge/base.py b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py similarity index 100% rename from pkg/api/http/controller/groups/knowledge/base.py rename to src/langbot/pkg/api/http/controller/groups/knowledge/base.py diff --git a/pkg/api/http/controller/groups/logs.py b/src/langbot/pkg/api/http/controller/groups/logs.py similarity index 100% rename from pkg/api/http/controller/groups/logs.py rename to src/langbot/pkg/api/http/controller/groups/logs.py diff --git a/pkg/api/http/controller/groups/pipelines/__init__.py b/src/langbot/pkg/api/http/controller/groups/pipelines/__init__.py similarity index 100% rename from pkg/api/http/controller/groups/pipelines/__init__.py rename to src/langbot/pkg/api/http/controller/groups/pipelines/__init__.py diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py new file mode 100644 index 00000000..924b68d4 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import quart + +from ... import group + + +@group.group_class('pipelines', '/api/v1/pipelines') +class PipelinesRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + if quart.request.method == 'GET': + sort_by = quart.request.args.get('sort_by', 'created_at') + sort_order = quart.request.args.get('sort_order', 'DESC') + return self.success( + data={'pipelines': await self.ap.pipeline_service.get_pipelines(sort_by, sort_order)} + ) + elif quart.request.method == 'POST': + json_data = await quart.request.json + + pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data) + + return self.success(data={'uuid': pipeline_uuid}) + + @self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()}) + + @self.route( + '/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def _(pipeline_uuid: str) -> str: + if quart.request.method == 'GET': + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) + + if pipeline is None: + return self.http_status(404, -1, 'pipeline not found') + + return self.success(data={'pipeline': pipeline}) + elif quart.request.method == 'PUT': + json_data = await quart.request.json + + await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data) + + return self.success() + elif quart.request.method == 'DELETE': + await self.ap.pipeline_service.delete_pipeline(pipeline_uuid) + + return self.success() + + @self.route( + '//extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def _(pipeline_uuid: str) -> str: + if quart.request.method == 'GET': + # Get current extensions and available plugins + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) + if pipeline is None: + return self.http_status(404, -1, 'pipeline not found') + + plugins = await self.ap.plugin_connector.list_plugins() + mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) + + return self.success( + data={ + 'bound_plugins': pipeline.get('extensions_preferences', {}).get('plugins', []), + 'available_plugins': plugins, + 'bound_mcp_servers': pipeline.get('extensions_preferences', {}).get('mcp_servers', []), + 'available_mcp_servers': mcp_servers, + } + ) + elif quart.request.method == 'PUT': + # Update bound plugins and MCP servers for this pipeline + json_data = await quart.request.json + bound_plugins = json_data.get('bound_plugins', []) + bound_mcp_servers = json_data.get('bound_mcp_servers', []) + + await self.ap.pipeline_service.update_pipeline_extensions( + pipeline_uuid, bound_plugins, bound_mcp_servers + ) + + return self.success() diff --git a/pkg/api/http/controller/groups/pipelines/webchat.py b/src/langbot/pkg/api/http/controller/groups/pipelines/webchat.py similarity index 100% rename from pkg/api/http/controller/groups/pipelines/webchat.py rename to src/langbot/pkg/api/http/controller/groups/pipelines/webchat.py diff --git a/pkg/api/http/controller/groups/platform/__init__.py b/src/langbot/pkg/api/http/controller/groups/platform/__init__.py similarity index 100% rename from pkg/api/http/controller/groups/platform/__init__.py rename to src/langbot/pkg/api/http/controller/groups/platform/__init__.py diff --git a/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py similarity index 85% rename from pkg/api/http/controller/groups/platform/adapters.py rename to src/langbot/pkg/api/http/controller/groups/platform/adapters.py index 4136791c..b46e5263 100644 --- a/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -1,6 +1,7 @@ import quart - +import mimetypes from ... import group +from langbot.pkg.utils import importutil @group.group_class('adapters', '/api/v1/platform/adapters') @@ -31,4 +32,6 @@ class AdaptersRouterGroup(group.RouterGroup): if icon_path is None: return self.http_status(404, -1, 'icon not found') - return await quart.send_file(icon_path) + return quart.Response( + importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] + ) diff --git a/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py similarity index 87% rename from pkg/api/http/controller/groups/platform/bots.py rename to src/langbot/pkg/api/http/controller/groups/platform/bots.py index fb22eb91..a2395810 100644 --- a/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -6,7 +6,7 @@ from ... import group @group.group_class('bots', '/api/v1/platform/bots') class BotsRouterGroup(group.RouterGroup): async def initialize(self) -> None: - @self.route('', methods=['GET', 'POST']) + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': return self.success(data={'bots': await self.ap.bot_service.get_bots()}) @@ -15,7 +15,7 @@ class BotsRouterGroup(group.RouterGroup): bot_uuid = await self.ap.bot_service.create_bot(json_data) return self.success(data={'uuid': bot_uuid}) - @self.route('/', methods=['GET', 'PUT', 'DELETE']) + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(bot_uuid: str) -> str: if quart.request.method == 'GET': # 返回运行时信息,包括webhook地址等 @@ -31,7 +31,7 @@ class BotsRouterGroup(group.RouterGroup): await self.ap.bot_service.delete_bot(bot_uuid) return self.success() - @self.route('//logs', methods=['POST']) + @self.route('//logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(bot_uuid: str) -> str: json_data = await quart.request.json from_index = json_data.get('from_index', -1) diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py new file mode 100644 index 00000000..4a25f6d0 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import base64 +import quart +import re +import httpx +import uuid +import os + +from .....core import taskmgr +from .. import group +from langbot_plugin.runtime.plugin.mgr import PluginInstallSource + + +@group.group_class('plugins', '/api/v1/plugins') +class PluginsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + plugins = await self.ap.plugin_connector.list_plugins() + + return self.success(data={'plugins': plugins}) + + @self.route( + '///upgrade', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _(author: str, plugin_name: str) -> str: + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx), + kind='plugin-operation', + name=f'plugin-upgrade-{plugin_name}', + label=f'Upgrading plugin {plugin_name}', + context=ctx, + ) + return self.success(data={'task_id': wrapper.id}) + + @self.route( + '//', + methods=['GET', 'DELETE'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _(author: str, plugin_name: str) -> str: + if quart.request.method == 'GET': + plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) + if plugin is None: + return self.http_status(404, -1, 'plugin not found') + return self.success(data={'plugin': plugin}) + elif quart.request.method == 'DELETE': + delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true' + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + self.ap.plugin_connector.delete_plugin( + author, plugin_name, delete_data=delete_data, task_context=ctx + ), + kind='plugin-operation', + name=f'plugin-remove-{plugin_name}', + label=f'Removing plugin {plugin_name}', + context=ctx, + ) + + return self.success(data={'task_id': wrapper.id}) + + @self.route( + '///config', + methods=['GET', 'PUT'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _(author: str, plugin_name: str) -> quart.Response: + plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) + if plugin is None: + return self.http_status(404, -1, 'plugin not found') + + if quart.request.method == 'GET': + return self.success(data={'config': plugin['plugin_config']}) + elif quart.request.method == 'PUT': + data = await quart.request.json + + await self.ap.plugin_connector.set_plugin_config(author, plugin_name, data) + + return self.success(data={}) + + @self.route( + '///icon', + methods=['GET'], + auth_type=group.AuthType.NONE, + ) + async def _(author: str, plugin_name: str) -> quart.Response: + icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) + icon_base64 = icon_data['plugin_icon_base64'] + mime_type = icon_data['mime_type'] + + icon_data = base64.b64decode(icon_base64) + + return quart.Response(icon_data, mimetype=mime_type) + + @self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Get releases from a GitHub repository URL""" + data = await quart.request.json + repo_url = data.get('repo_url', '') + + # Parse GitHub repository URL to extract owner and repo + # Supports: https://github.com/owner/repo or github.com/owner/repo + pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$' + match = re.search(pattern, repo_url) + + if not match: + return self.http_status(400, -1, 'Invalid GitHub repository URL') + + owner, repo = match.groups() + + try: + # Fetch releases from GitHub API + url = f'https://api.github.com/repos/{owner}/{repo}/releases' + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=10, + ) as client: + response = await client.get(url) + response.raise_for_status() + releases = response.json() + + # Format releases data for frontend + formatted_releases = [] + for release in releases: + formatted_releases.append( + { + 'id': release['id'], + 'tag_name': release['tag_name'], + 'name': release['name'], + 'published_at': release['published_at'], + 'prerelease': release['prerelease'], + 'draft': release['draft'], + } + ) + + return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo}) + except httpx.RequestError as e: + return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}') + + @self.route( + '/github/release-assets', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _() -> str: + """Get assets from a specific GitHub release""" + data = await quart.request.json + owner = data.get('owner', '') + repo = data.get('repo', '') + release_id = data.get('release_id', '') + + if not all([owner, repo, release_id]): + return self.http_status(400, -1, 'Missing required parameters') + + try: + # Fetch release assets from GitHub API + url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}' + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=10, + ) as client: + response = await client.get( + url, + ) + response.raise_for_status() + release = response.json() + + # Format assets data for frontend + formatted_assets = [] + for asset in release.get('assets', []): + formatted_assets.append( + { + 'id': asset['id'], + 'name': asset['name'], + 'size': asset['size'], + 'download_url': asset['browser_download_url'], + 'content_type': asset['content_type'], + } + ) + + # add zipball as a downloadable asset + # formatted_assets.append( + # { + # "id": 0, + # "name": "Source code (zip)", + # "size": -1, + # "download_url": release["zipball_url"], + # "content_type": "application/zip", + # } + # ) + + return self.success(data={'assets': formatted_assets}) + except httpx.RequestError as e: + return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}') + + @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Install plugin from GitHub release asset""" + data = await quart.request.json + asset_url = data.get('asset_url', '') + owner = data.get('owner', '') + repo = data.get('repo', '') + release_tag = data.get('release_tag', '') + + if not asset_url: + return self.http_status(400, -1, 'Missing asset_url parameter') + + ctx = taskmgr.TaskContext.new() + install_info = { + 'asset_url': asset_url, + 'owner': owner, + 'repo': repo, + 'release_tag': release_tag, + 'github_url': f'https://github.com/{owner}/{repo}', + } + + wrapper = self.ap.task_mgr.create_user_task( + self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx), + kind='plugin-operation', + name='plugin-install-github', + label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}', + context=ctx, + ) + + return self.success(data={'task_id': wrapper.id}) + + @self.route( + '/install/marketplace', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _() -> str: + data = await quart.request.json + + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx), + kind='plugin-operation', + name='plugin-install-marketplace', + label=f'Installing plugin from marketplace ...{data}', + context=ctx, + ) + + return self.success(data={'task_id': wrapper.id}) + + @self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + + file_bytes = file.read() + + data = { + 'plugin_file': file_bytes, + } + + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx), + kind='plugin-operation', + name='plugin-install-local', + label=f'Installing plugin from local ...{file.filename}', + context=ctx, + ) + + return self.success(data={'task_id': wrapper.id}) + + @self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Upload a file for plugin configuration""" + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + + # Check file size (10MB limit) + MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + file_bytes = file.read() + if len(file_bytes) > MAX_FILE_SIZE: + return self.http_status(400, -1, 'file size exceeds 10MB limit') + + # Generate unique file key with original extension + original_filename = file.filename + _, ext = os.path.splitext(original_filename) + file_key = f'plugin_config_{uuid.uuid4().hex}{ext}' + + # Save file using storage manager + await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) + + return self.success(data={'file_key': file_key}) + + @self.route('/config-files/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN) + async def _(file_key: str) -> str: + """Delete a plugin configuration file""" + # Only allow deletion of files with plugin_config_ prefix for security + if not file_key.startswith('plugin_config_'): + return self.http_status(400, -1, 'invalid file key') + + try: + await self.ap.storage_mgr.storage_provider.delete(file_key) + return self.success(data={'deleted': True}) + except Exception as e: + return self.http_status(500, -1, f'failed to delete file: {str(e)}') diff --git a/pkg/api/http/controller/groups/provider/__init__.py b/src/langbot/pkg/api/http/controller/groups/provider/__init__.py similarity index 100% rename from pkg/api/http/controller/groups/provider/__init__.py rename to src/langbot/pkg/api/http/controller/groups/provider/__init__.py diff --git a/pkg/api/http/controller/groups/provider/models.py b/src/langbot/pkg/api/http/controller/groups/provider/models.py similarity index 86% rename from pkg/api/http/controller/groups/provider/models.py rename to src/langbot/pkg/api/http/controller/groups/provider/models.py index 0de0c922..25f16995 100644 --- a/pkg/api/http/controller/groups/provider/models.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/models.py @@ -6,7 +6,7 @@ from ... import group @group.group_class('models/llm', '/api/v1/provider/models/llm') class LLMModelsRouterGroup(group.RouterGroup): async def initialize(self) -> None: - @self.route('', methods=['GET', 'POST']) + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()}) @@ -17,7 +17,7 @@ class LLMModelsRouterGroup(group.RouterGroup): return self.success(data={'uuid': model_uuid}) - @self.route('/', methods=['GET', 'PUT', 'DELETE']) + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: if quart.request.method == 'GET': model = await self.ap.llm_model_service.get_llm_model(model_uuid) @@ -37,7 +37,7 @@ class LLMModelsRouterGroup(group.RouterGroup): return self.success() - @self.route('//test', methods=['POST']) + @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: json_data = await quart.request.json @@ -49,7 +49,7 @@ class LLMModelsRouterGroup(group.RouterGroup): @group.group_class('models/embedding', '/api/v1/provider/models/embedding') class EmbeddingModelsRouterGroup(group.RouterGroup): async def initialize(self) -> None: - @self.route('', methods=['GET', 'POST']) + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()}) @@ -60,7 +60,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): return self.success(data={'uuid': model_uuid}) - @self.route('/', methods=['GET', 'PUT', 'DELETE']) + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: if quart.request.method == 'GET': model = await self.ap.embedding_models_service.get_embedding_model(model_uuid) @@ -80,7 +80,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): return self.success() - @self.route('//test', methods=['POST']) + @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: json_data = await quart.request.json diff --git a/pkg/api/http/controller/groups/provider/requesters.py b/src/langbot/pkg/api/http/controller/groups/provider/requesters.py similarity index 86% rename from pkg/api/http/controller/groups/provider/requesters.py rename to src/langbot/pkg/api/http/controller/groups/provider/requesters.py index af9e1540..268ed11d 100644 --- a/pkg/api/http/controller/groups/provider/requesters.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/requesters.py @@ -1,6 +1,8 @@ import quart +import mimetypes from ... import group +from langbot.pkg.utils import importutil @group.group_class('provider/requesters', '/api/v1/provider/requesters') @@ -32,4 +34,6 @@ class RequestersRouterGroup(group.RouterGroup): if icon_path is None: return self.http_status(404, -1, 'icon not found') - return await quart.send_file(icon_path) + return quart.Response( + importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] + ) diff --git a/pkg/api/http/service/__init__.py b/src/langbot/pkg/api/http/controller/groups/resources/__init__.py similarity index 100% rename from pkg/api/http/service/__init__.py rename to src/langbot/pkg/api/http/controller/groups/resources/__init__.py diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py new file mode 100644 index 00000000..ac91abff --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import quart +import traceback + + +from ... import group + + +@group.group_class('mcp', '/api/v1/mcp') +class MCPRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """获取MCP服务器列表""" + if quart.request.method == 'GET': + servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) + + return self.success(data={'servers': servers}) + + elif quart.request.method == 'POST': + data = await quart.request.json + + try: + uuid = await self.ap.mcp_service.create_mcp_server(data) + return self.success(data={'uuid': uuid}) + except Exception as e: + traceback.print_exc() + return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}') + + @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """获取、更新或删除MCP服务器配置""" + + server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) + if server_data is None: + return self.http_status(404, -1, 'Server not found') + + if quart.request.method == 'GET': + return self.success(data={'server': server_data}) + + elif quart.request.method == 'PUT': + data = await quart.request.json + try: + await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data) + return self.success() + except Exception as e: + return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}') + + elif quart.request.method == 'DELETE': + try: + await self.ap.mcp_service.delete_mcp_server(server_data['uuid']) + return self.success() + except Exception as e: + return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}') + + @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """测试MCP服务器连接""" + server_data = await quart.request.json + task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data) + return self.success(data={'task_id': task_id}) diff --git a/pkg/api/http/controller/groups/stats.py b/src/langbot/pkg/api/http/controller/groups/stats.py similarity index 100% rename from pkg/api/http/controller/groups/stats.py rename to src/langbot/pkg/api/http/controller/groups/stats.py diff --git a/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py similarity index 97% rename from pkg/api/http/controller/groups/system.py rename to src/langbot/pkg/api/http/controller/groups/system.py index 1fe04136..82200b1c 100644 --- a/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -13,7 +13,6 @@ class SystemRouterGroup(group.RouterGroup): data={ 'version': constants.semantic_version, 'debug': constants.debug_mode, - 'enabled_platform_count': len(self.ap.platform_mgr.get_running_adapters()), 'enable_marketplace': self.ap.instance_config.data.get('plugin', {}).get( 'enable_marketplace', True ), diff --git a/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py similarity index 100% rename from pkg/api/http/controller/groups/user.py rename to src/langbot/pkg/api/http/controller/groups/user.py diff --git a/pkg/api/http/controller/groups/webhooks.py b/src/langbot/pkg/api/http/controller/groups/webhooks.py similarity index 100% rename from pkg/api/http/controller/groups/webhooks.py rename to src/langbot/pkg/api/http/controller/groups/webhooks.py diff --git a/pkg/api/http/controller/main.py b/src/langbot/pkg/api/http/controller/main.py similarity index 83% rename from pkg/api/http/controller/main.py rename to src/langbot/pkg/api/http/controller/main.py index e45b461d..1541a25e 100644 --- a/pkg/api/http/controller/main.py +++ b/src/langbot/pkg/api/http/controller/main.py @@ -5,6 +5,7 @@ import os import quart import quart_cors +from werkzeug.exceptions import RequestEntityTooLarge from ....core import app, entities as core_entities from ....utils import importutil @@ -15,12 +16,14 @@ from .groups import provider as groups_provider from .groups import platform as groups_platform from .groups import pipelines as groups_pipelines from .groups import knowledge as groups_knowledge +from .groups import resources as groups_resources importutil.import_modules_in_pkg(groups) importutil.import_modules_in_pkg(groups_provider) importutil.import_modules_in_pkg(groups_platform) importutil.import_modules_in_pkg(groups_pipelines) importutil.import_modules_in_pkg(groups_knowledge) +importutil.import_modules_in_pkg(groups_resources) class HTTPController: @@ -33,7 +36,20 @@ class HTTPController: self.quart_app = quart.Quart(__name__) quart_cors.cors(self.quart_app, allow_origin='*') + # Set maximum content length to prevent large file uploads + self.quart_app.config['MAX_CONTENT_LENGTH'] = group.MAX_FILE_SIZE + async def initialize(self) -> None: + # Register custom error handler for file size limit + @self.quart_app.errorhandler(RequestEntityTooLarge) + async def handle_request_entity_too_large(e): + return quart.jsonify( + { + 'code': 400, + 'msg': 'File size exceeds 10MB limit. Please split large files into smaller parts.', + } + ), 400 + await self.register_routes() async def run(self) -> None: @@ -70,7 +86,9 @@ class HTTPController: ginst = g(self.ap, self.quart_app) await ginst.initialize() - frontend_path = 'web/out' + from ....utils import paths + + frontend_path = paths.get_frontend_path() @self.quart_app.route('/') async def index(): diff --git a/pkg/command/__init__.py b/src/langbot/pkg/api/http/service/__init__.py similarity index 100% rename from pkg/command/__init__.py rename to src/langbot/pkg/api/http/service/__init__.py diff --git a/src/langbot/pkg/api/http/service/apikey.py b/src/langbot/pkg/api/http/service/apikey.py new file mode 100644 index 00000000..c46b5608 --- /dev/null +++ b/src/langbot/pkg/api/http/service/apikey.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import secrets +import sqlalchemy + +from ....core import app +from ....entity.persistence import apikey + + +class ApiKeyService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_api_keys(self) -> list[dict]: + """Get all API keys""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey)) + + keys = result.all() + return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys] + + async def create_api_key(self, name: str, description: str = '') -> dict: + """Create a new API key""" + # Generate a secure random API key + key = f'lbk_{secrets.token_urlsafe(32)}' + + key_data = {'name': name, 'key': key, 'description': description} + + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data)) + + # Retrieve the created key + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key) + ) + created_key = result.first() + + return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key) + + async def get_api_key(self, key_id: int) -> dict | None: + """Get a specific API key by ID""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id) + ) + + key = result.first() + + if key is None: + return None + + return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) + + async def verify_api_key(self, key: str) -> bool: + """Verify if an API key is valid""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key) + ) + + key_obj = result.first() + return key_obj is not None + + async def delete_api_key(self, key_id: int) -> None: + """Delete an API key""" + await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id)) + + async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None: + """Update an API key's metadata (name, description)""" + update_data = {} + if name is not None: + update_data['name'] = name + if description is not None: + update_data['description'] = description + + if update_data: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data) + ) diff --git a/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py similarity index 100% rename from pkg/api/http/service/bot.py rename to src/langbot/pkg/api/http/service/bot.py diff --git a/pkg/api/http/service/knowledge.py b/src/langbot/pkg/api/http/service/knowledge.py similarity index 100% rename from pkg/api/http/service/knowledge.py rename to src/langbot/pkg/api/http/service/knowledge.py diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py new file mode 100644 index 00000000..a1b034d0 --- /dev/null +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import sqlalchemy +import uuid +import asyncio + +from ....core import app +from ....entity.persistence import mcp as persistence_mcp +from ....core import taskmgr +from ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus + + +class MCPService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_runtime_info(self, server_name: str) -> dict | None: + session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if session: + return session.get_runtime_info_dict() + return None + + async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) + + servers = result.all() + serialized_servers = [ + self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers + ] + if contain_runtime_info: + for server in serialized_servers: + runtime_info = await self.get_runtime_info(server['name']) + + server['runtime_info'] = runtime_info if runtime_info else None + + return serialized_servers + + async def create_mcp_server(self, server_data: dict) -> str: + server_data['uuid'] = str(uuid.uuid4()) + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid']) + ) + server_entity = result.first() + if server_entity: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity) + if self.ap.tool_mgr.mcp_tool_loader: + task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) + + return server_data['uuid'] + + async def get_mcp_server_by_name(self, server_name: str) -> dict | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name) + ) + server = result.first() + if server is None: + return None + + runtime_info = await self.get_runtime_info(server.name) + server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + server_data['runtime_info'] = runtime_info if runtime_info else None + return server_data + + async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + old_server = result.first() + old_server_name = old_server.name if old_server else None + old_enable = old_server.enable if old_server else False + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_mcp.MCPServer) + .where(persistence_mcp.MCPServer.uuid == server_uuid) + .values(server_data) + ) + + if self.ap.tool_mgr.mcp_tool_loader: + new_enable = server_data.get('enable', False) + + need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions + + if old_enable and not new_enable: + if need_remove: + await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) + + elif not old_enable and new_enable: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + updated_server = result.first() + if updated_server: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) + task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) + + elif old_enable and new_enable: + if need_remove: + await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + updated_server = result.first() + if updated_server: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) + task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) + + async def delete_mcp_server(self, server_uuid: str) -> None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + server = result.first() + server_name = server.name if server else None + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + + if server_name and self.ap.tool_mgr.mcp_tool_loader: + if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: + await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name) + + async def test_mcp_server(self, server_name: str, server_data: dict) -> int: + """测试 MCP 服务器连接并返回任务 ID""" + + runtime_mcp_session: RuntimeMCPSession | None = None + + if server_name != '_': + runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if runtime_mcp_session is None: + raise ValueError(f'Server not found: {server_name}') + + if runtime_mcp_session.status == MCPSessionStatus.ERROR: + coroutine = runtime_mcp_session.start() + else: + coroutine = runtime_mcp_session.refresh() + else: + runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data) + coroutine = runtime_mcp_session.start() + + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + coroutine, + kind='mcp-operation', + name=f'mcp-test-{server_name}', + label=f'Testing MCP server {server_name}', + context=ctx, + ) + return wrapper.id diff --git a/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py similarity index 94% rename from pkg/api/http/service/model.py rename to src/langbot/pkg/api/http/service/model.py index 036c1b9c..66eea592 100644 --- a/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -1,13 +1,14 @@ from __future__ import annotations import uuid + import sqlalchemy +from langbot_plugin.api.entities.builtin.provider import message as provider_message from ....core import app from ....entity.persistence import model as persistence_model from ....entity.persistence import pipeline as persistence_pipeline from ....provider.modelmgr import requester as model_requester -from langbot_plugin.api.entities.builtin.provider import message as provider_message class LLMModelsService: @@ -104,12 +105,18 @@ class LLMModelsService: else: runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data) + # Mon Nov 10 2025: Commented for some providers may not support thinking parameter + # # 有些模型厂商默认开启了思考功能,测试容易延迟 + # extra_args = model_data.get('extra_args', {}) + # if not extra_args or 'thinking' not in extra_args: + # extra_args['thinking'] = {'type': 'disabled'} + await runtime_llm_model.requester.invoke_llm( query=None, model=runtime_llm_model, - messages=[provider_message.Message(role='user', content='Hello, world!')], + messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')], funcs=[], - extra_args=model_data.get('extra_args', {}), + # extra_args=extra_args, ) diff --git a/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py similarity index 72% rename from pkg/api/http/service/pipeline.py rename to src/langbot/pkg/api/http/service/pipeline.py index d3d0bfa7..62e0879f 100644 --- a/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -30,12 +30,12 @@ class PipelineService: def __init__(self, ap: app.Application) -> None: self.ap = ap - async def get_pipeline_metadata(self) -> dict: + async def get_pipeline_metadata(self) -> list[dict]: return [ - self.ap.pipeline_config_meta_trigger.data, - self.ap.pipeline_config_meta_safety.data, - self.ap.pipeline_config_meta_ai.data, - self.ap.pipeline_config_meta_output.data, + self.ap.pipeline_config_meta_trigger, + self.ap.pipeline_config_meta_safety, + self.ap.pipeline_config_meta_ai, + self.ap.pipeline_config_meta_output, ] async def get_pipelines(self, sort_by: str = 'created_at', sort_order: str = 'DESC') -> list[dict]: @@ -74,11 +74,16 @@ class PipelineService: return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str: + from ....utils import paths as path_utils + pipeline_data['uuid'] = str(uuid.uuid4()) pipeline_data['for_version'] = self.ap.ver_mgr.get_current_version() pipeline_data['stages'] = default_stage_order.copy() pipeline_data['is_default'] = default - pipeline_data['config'] = json.load(open('templates/default-pipeline-config.json', 'r', encoding='utf-8')) + + template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') + with open(template_path, 'r', encoding='utf-8') as f: + pipeline_data['config'] = json.load(f) await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**pipeline_data) @@ -136,3 +141,35 @@ class PipelineService: ) ) await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) + + async def update_pipeline_extensions( + self, pipeline_uuid: str, bound_plugins: list[dict], bound_mcp_servers: list[str] = None + ) -> None: + """Update the bound plugins and MCP servers for a pipeline""" + # Get current pipeline + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid + ) + ) + + pipeline = result.first() + if pipeline is None: + raise ValueError(f'Pipeline {pipeline_uuid} not found') + + # Update extensions_preferences + extensions_preferences = pipeline.extensions_preferences or {} + extensions_preferences['plugins'] = bound_plugins + if bound_mcp_servers is not None: + extensions_preferences['mcp_servers'] = bound_mcp_servers + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_pipeline.LegacyPipeline) + .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid) + .values(extensions_preferences=extensions_preferences) + ) + + # Reload pipeline to apply changes + await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) + pipeline = await self.get_pipeline(pipeline_uuid) + await self.ap.pipeline_mgr.load_pipeline(pipeline) diff --git a/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py similarity index 100% rename from pkg/api/http/service/user.py rename to src/langbot/pkg/api/http/service/user.py 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/pkg/command/operators/__init__.py b/src/langbot/pkg/command/__init__.py similarity index 100% rename from pkg/command/operators/__init__.py rename to src/langbot/pkg/command/__init__.py diff --git a/pkg/command/cmdmgr.py b/src/langbot/pkg/command/cmdmgr.py similarity index 91% rename from pkg/command/cmdmgr.py rename to src/langbot/pkg/command/cmdmgr.py index 51dda81e..a1d7e009 100644 --- a/pkg/command/cmdmgr.py +++ b/src/langbot/pkg/command/cmdmgr.py @@ -59,14 +59,15 @@ class CommandManager: context: command_context.ExecuteContext, operator_list: list[operator.CommandOperator], operator: operator.CommandOperator = None, + bound_plugins: list[str] | None = None, ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: """执行命令""" - command_list = await self.ap.plugin_connector.list_commands() + command_list = await self.ap.plugin_connector.list_commands(bound_plugins) for command in command_list: if command.metadata.name == context.command: - async for ret in self.ap.plugin_connector.execute_command(context): + async for ret in self.ap.plugin_connector.execute_command(context, bound_plugins): yield ret break else: @@ -102,5 +103,8 @@ class CommandManager: ctx.shift() - async for ret in self._execute(ctx, self.cmd_list): + # Get bound plugins from query + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + + async for ret in self._execute(ctx, self.cmd_list, bound_plugins=bound_plugins): yield ret diff --git a/pkg/command/operator.py b/src/langbot/pkg/command/operator.py similarity index 100% rename from pkg/command/operator.py rename to src/langbot/pkg/command/operator.py diff --git a/pkg/config/__init__.py b/src/langbot/pkg/command/operators/__init__.py similarity index 100% rename from pkg/config/__init__.py rename to src/langbot/pkg/command/operators/__init__.py diff --git a/pkg/command/operators/delc.py b/src/langbot/pkg/command/operators/delc.py similarity index 100% rename from pkg/command/operators/delc.py rename to src/langbot/pkg/command/operators/delc.py diff --git a/pkg/command/operators/last.py b/src/langbot/pkg/command/operators/last.py similarity index 100% rename from pkg/command/operators/last.py rename to src/langbot/pkg/command/operators/last.py diff --git a/pkg/command/operators/list.py b/src/langbot/pkg/command/operators/list.py similarity index 100% rename from pkg/command/operators/list.py rename to src/langbot/pkg/command/operators/list.py diff --git a/pkg/command/operators/next.py b/src/langbot/pkg/command/operators/next.py similarity index 100% rename from pkg/command/operators/next.py rename to src/langbot/pkg/command/operators/next.py diff --git a/pkg/command/operators/prompt.py b/src/langbot/pkg/command/operators/prompt.py similarity index 100% rename from pkg/command/operators/prompt.py rename to src/langbot/pkg/command/operators/prompt.py diff --git a/pkg/command/operators/resend.py b/src/langbot/pkg/command/operators/resend.py similarity index 100% rename from pkg/command/operators/resend.py rename to src/langbot/pkg/command/operators/resend.py diff --git a/pkg/config/impls/__init__.py b/src/langbot/pkg/config/__init__.py similarity index 100% rename from pkg/config/impls/__init__.py rename to src/langbot/pkg/config/__init__.py diff --git a/pkg/core/__init__.py b/src/langbot/pkg/config/impls/__init__.py similarity index 100% rename from pkg/core/__init__.py rename to src/langbot/pkg/config/impls/__init__.py diff --git a/pkg/config/impls/json.py b/src/langbot/pkg/config/impls/json.py similarity index 65% rename from pkg/config/impls/json.py rename to src/langbot/pkg/config/impls/json.py index 44b4843c..00683415 100644 --- a/pkg/config/impls/json.py +++ b/src/langbot/pkg/config/impls/json.py @@ -1,8 +1,8 @@ import os -import shutil import json +import importlib.resources as resources -from .. import model as file_model +from langbot.pkg.config import model as file_model class JSONConfigFile(file_model.ConfigFile): @@ -11,19 +11,29 @@ class JSONConfigFile(file_model.ConfigFile): def __init__( self, config_file_name: str, - template_file_name: str = None, + template_resource_name: str = None, template_data: dict = None, ) -> None: self.config_file_name = config_file_name - self.template_file_name = template_file_name + self.template_resource_name = template_resource_name self.template_data = template_data def exists(self) -> bool: return os.path.exists(self.config_file_name) + async def get_template_file_str(self) -> str: + if self.template_resource_name is None: + return None + + with ( + resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f + ): + return f.read() + async def create(self): - if self.template_file_name is not None: - shutil.copyfile(self.template_file_name, self.config_file_name) + if await self.get_template_file_str() is not None: + with open(self.config_file_name, 'w', encoding='utf-8') as f: + f.write(await self.get_template_file_str()) elif self.template_data is not None: with open(self.config_file_name, 'w', encoding='utf-8') as f: json.dump(self.template_data, f, indent=4, ensure_ascii=False) @@ -34,9 +44,10 @@ class JSONConfigFile(file_model.ConfigFile): if not self.exists(): await self.create() - if self.template_file_name is not None: - with open(self.template_file_name, 'r', encoding='utf-8') as f: - self.template_data = json.load(f) + template_file_str = await self.get_template_file_str() + + if template_file_str is not None: + self.template_data = json.loads(template_file_str) with open(self.config_file_name, 'r', encoding='utf-8') as f: try: diff --git a/pkg/config/impls/pymodule.py b/src/langbot/pkg/config/impls/pymodule.py similarity index 100% rename from pkg/config/impls/pymodule.py rename to src/langbot/pkg/config/impls/pymodule.py diff --git a/pkg/config/impls/yaml.py b/src/langbot/pkg/config/impls/yaml.py similarity index 64% rename from pkg/config/impls/yaml.py rename to src/langbot/pkg/config/impls/yaml.py index 0d69ef9e..d9dc4bc2 100644 --- a/pkg/config/impls/yaml.py +++ b/src/langbot/pkg/config/impls/yaml.py @@ -1,8 +1,8 @@ import os -import shutil import yaml +import importlib.resources as resources -from .. import model as file_model +from langbot.pkg.config import model as file_model class YAMLConfigFile(file_model.ConfigFile): @@ -11,19 +11,29 @@ class YAMLConfigFile(file_model.ConfigFile): def __init__( self, config_file_name: str, - template_file_name: str = None, + template_resource_name: str = None, template_data: dict = None, ) -> None: self.config_file_name = config_file_name - self.template_file_name = template_file_name + self.template_resource_name = template_resource_name self.template_data = template_data def exists(self) -> bool: return os.path.exists(self.config_file_name) + async def get_template_file_str(self) -> str: + if self.template_resource_name is None: + return None + + with ( + resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f + ): + return f.read() + async def create(self): - if self.template_file_name is not None: - shutil.copyfile(self.template_file_name, self.config_file_name) + if await self.get_template_file_str() is not None: + with open(self.config_file_name, 'w', encoding='utf-8') as f: + f.write(await self.get_template_file_str()) elif self.template_data is not None: with open(self.config_file_name, 'w', encoding='utf-8') as f: yaml.dump(self.template_data, f, indent=4, allow_unicode=True) @@ -34,9 +44,10 @@ class YAMLConfigFile(file_model.ConfigFile): if not self.exists(): await self.create() - if self.template_file_name is not None: - with open(self.template_file_name, 'r', encoding='utf-8') as f: - self.template_data = yaml.load(f, Loader=yaml.FullLoader) + template_file_str = await self.get_template_file_str() + + if template_file_str is not None: + self.template_data = yaml.load(template_file_str, Loader=yaml.FullLoader) with open(self.config_file_name, 'r', encoding='utf-8') as f: try: diff --git a/pkg/config/manager.py b/src/langbot/pkg/config/manager.py similarity index 86% rename from pkg/config/manager.py rename to src/langbot/pkg/config/manager.py index d552b038..d22591b0 100644 --- a/pkg/config/manager.py +++ b/src/langbot/pkg/config/manager.py @@ -62,7 +62,7 @@ async def load_python_module_config(config_name: str, template_name: str, comple async def load_json_config( config_name: str, - template_name: str = None, + template_resource_name: str = None, template_data: dict = None, completion: bool = True, ) -> ConfigManager: @@ -70,11 +70,11 @@ async def load_json_config( Args: config_name (str): Config file name - template_name (str): Template file name + template_resource_name (str): Template resource name template_data (dict): Template data completion (bool): Whether to automatically complete the config file in memory """ - cfg_inst = json_file.JSONConfigFile(config_name, template_name, template_data) + cfg_inst = json_file.JSONConfigFile(config_name, template_resource_name, template_data) cfg_mgr = ConfigManager(cfg_inst) await cfg_mgr.load_config(completion=completion) @@ -84,7 +84,7 @@ async def load_json_config( async def load_yaml_config( config_name: str, - template_name: str = None, + template_resource_name: str = None, template_data: dict = None, completion: bool = True, ) -> ConfigManager: @@ -92,14 +92,14 @@ async def load_yaml_config( Args: config_name (str): Config file name - template_name (str): Template file name + template_resource_name (str): Template resource name template_data (dict): Template data completion (bool): Whether to automatically complete the config file in memory Returns: ConfigManager: Config file manager """ - cfg_inst = yaml_file.YAMLConfigFile(config_name, template_name, template_data) + cfg_inst = yaml_file.YAMLConfigFile(config_name, template_resource_name, template_data) cfg_mgr = ConfigManager(cfg_inst) await cfg_mgr.load_config(completion=completion) diff --git a/pkg/config/model.py b/src/langbot/pkg/config/model.py similarity index 100% rename from pkg/config/model.py rename to src/langbot/pkg/config/model.py diff --git a/pkg/core/bootutils/__init__.py b/src/langbot/pkg/core/__init__.py similarity index 100% rename from pkg/core/bootutils/__init__.py rename to src/langbot/pkg/core/__init__.py diff --git a/pkg/core/app.py b/src/langbot/pkg/core/app.py similarity index 90% rename from pkg/core/app.py rename to src/langbot/pkg/core/app.py index 27b780f6..a3ad68e8 100644 --- a/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -6,15 +6,16 @@ import traceback import os from ..platform import botmgr as im_mgr +from ..platform.webhook_pusher import WebhookPusher from ..provider.session import sessionmgr as llm_session_mgr from ..provider.modelmgr import modelmgr as llm_model_mgr -from ..provider.tools import toolmgr as llm_tool_mgr +from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr from ..config import manager as config_mgr from ..command import cmdmgr from ..plugin import connector as plugin_connector from ..pipeline import pool from ..pipeline import controller, pipelinemgr -from ..utils import version as version_mgr, proxy as proxy_mgr, announce as announce_mgr +from ..utils import version as version_mgr, proxy as proxy_mgr from ..persistence import mgr as persistencemgr from ..api.http.controller import main as http_controller from ..api.http.service import user as user_service @@ -22,6 +23,9 @@ from ..api.http.service import model as model_service from ..api.http.service import pipeline as pipeline_service from ..api.http.service import bot as bot_service from ..api.http.service import knowledge as knowledge_service +from ..api.http.service import mcp as mcp_service +from ..api.http.service import apikey as apikey_service +from ..api.http.service import webhook as webhook_service from ..discover import engine as discover_engine from ..storage import mgr as storagemgr from ..utils import logcache @@ -43,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 @@ -89,8 +95,6 @@ class Application: ver_mgr: version_mgr.VersionManager = None - ann_mgr: announce_mgr.AnnouncementManager = None - proxy_mgr: proxy_mgr.ProxyManager = None logger: logging.Logger = None @@ -119,6 +123,12 @@ class Application: knowledge_service: knowledge_service.KnowledgeService = None + mcp_service: mcp_service.MCPService = None + + apikey_service: apikey_service.ApiKeyService = None + + webhook_service: webhook_service.WebhookService = None + def __init__(self): pass @@ -174,7 +184,11 @@ class Application: async def print_web_access_info(self): """Print access webui tips""" - if not os.path.exists(os.path.join('.', 'web/out')): + from ..utils import paths + + frontend_path = paths.get_frontend_path() + + if not os.path.exists(frontend_path): self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh') self.logger.warning( 'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en' diff --git a/pkg/core/boot.py b/src/langbot/pkg/core/boot.py similarity index 100% rename from pkg/core/boot.py rename to src/langbot/pkg/core/boot.py diff --git a/pkg/core/migrations/__init__.py b/src/langbot/pkg/core/bootutils/__init__.py similarity index 100% rename from pkg/core/migrations/__init__.py rename to src/langbot/pkg/core/bootutils/__init__.py diff --git a/pkg/core/bootutils/config.py b/src/langbot/pkg/core/bootutils/config.py similarity index 100% rename from pkg/core/bootutils/config.py rename to src/langbot/pkg/core/bootutils/config.py diff --git a/pkg/core/bootutils/deps.py b/src/langbot/pkg/core/bootutils/deps.py similarity index 100% rename from pkg/core/bootutils/deps.py rename to src/langbot/pkg/core/bootutils/deps.py diff --git a/pkg/core/bootutils/files.py b/src/langbot/pkg/core/bootutils/files.py similarity index 77% rename from pkg/core/bootutils/files.py rename to src/langbot/pkg/core/bootutils/files.py index 3599e41b..067b60c1 100644 --- a/pkg/core/bootutils/files.py +++ b/src/langbot/pkg/core/bootutils/files.py @@ -5,7 +5,6 @@ import shutil required_files = { - 'plugins/__init__.py': 'templates/__init__.py', 'data/config.yaml': 'templates/config.yaml', } @@ -15,13 +14,14 @@ required_paths = [ 'data/metadata', 'data/logs', 'data/labels', - 'plugins', ] async def generate_files() -> list[str]: global required_files, required_paths + from ...utils import paths as path_utils + for required_paths in required_paths: if not os.path.exists(required_paths): os.mkdir(required_paths) @@ -29,7 +29,8 @@ async def generate_files() -> list[str]: generated_files = [] for file in required_files: if not os.path.exists(file): - shutil.copyfile(required_files[file], file) + template_path = path_utils.get_resource_path(required_files[file]) + shutil.copyfile(template_path, file) generated_files.append(file) return generated_files diff --git a/pkg/core/bootutils/log.py b/src/langbot/pkg/core/bootutils/log.py similarity index 100% rename from pkg/core/bootutils/log.py rename to src/langbot/pkg/core/bootutils/log.py diff --git a/pkg/core/entities.py b/src/langbot/pkg/core/entities.py similarity index 100% rename from pkg/core/entities.py rename to src/langbot/pkg/core/entities.py diff --git a/pkg/core/migration.py b/src/langbot/pkg/core/migration.py similarity index 100% rename from pkg/core/migration.py rename to src/langbot/pkg/core/migration.py diff --git a/pkg/core/notes/__init__.py b/src/langbot/pkg/core/migrations/__init__.py similarity index 100% rename from pkg/core/notes/__init__.py rename to src/langbot/pkg/core/migrations/__init__.py diff --git a/pkg/core/migrations/m001_sensitive_word_migration.py b/src/langbot/pkg/core/migrations/m001_sensitive_word_migration.py similarity index 100% rename from pkg/core/migrations/m001_sensitive_word_migration.py rename to src/langbot/pkg/core/migrations/m001_sensitive_word_migration.py diff --git a/pkg/core/migrations/m002_openai_config_migration.py b/src/langbot/pkg/core/migrations/m002_openai_config_migration.py similarity index 100% rename from pkg/core/migrations/m002_openai_config_migration.py rename to src/langbot/pkg/core/migrations/m002_openai_config_migration.py diff --git a/pkg/core/migrations/m003_anthropic_requester_cfg_completion.py b/src/langbot/pkg/core/migrations/m003_anthropic_requester_cfg_completion.py similarity index 100% rename from pkg/core/migrations/m003_anthropic_requester_cfg_completion.py rename to src/langbot/pkg/core/migrations/m003_anthropic_requester_cfg_completion.py diff --git a/pkg/core/migrations/m004_moonshot_cfg_completion.py b/src/langbot/pkg/core/migrations/m004_moonshot_cfg_completion.py similarity index 100% rename from pkg/core/migrations/m004_moonshot_cfg_completion.py rename to src/langbot/pkg/core/migrations/m004_moonshot_cfg_completion.py diff --git a/pkg/core/migrations/m005_deepseek_cfg_completion.py b/src/langbot/pkg/core/migrations/m005_deepseek_cfg_completion.py similarity index 100% rename from pkg/core/migrations/m005_deepseek_cfg_completion.py rename to src/langbot/pkg/core/migrations/m005_deepseek_cfg_completion.py diff --git a/pkg/core/migrations/m006_vision_config.py b/src/langbot/pkg/core/migrations/m006_vision_config.py similarity index 100% rename from pkg/core/migrations/m006_vision_config.py rename to src/langbot/pkg/core/migrations/m006_vision_config.py diff --git a/pkg/core/migrations/m007_qcg_center_url.py b/src/langbot/pkg/core/migrations/m007_qcg_center_url.py similarity index 100% rename from pkg/core/migrations/m007_qcg_center_url.py rename to src/langbot/pkg/core/migrations/m007_qcg_center_url.py diff --git a/pkg/core/migrations/m008_ad_fixwin_config_migrate.py b/src/langbot/pkg/core/migrations/m008_ad_fixwin_config_migrate.py similarity index 100% rename from pkg/core/migrations/m008_ad_fixwin_config_migrate.py rename to src/langbot/pkg/core/migrations/m008_ad_fixwin_config_migrate.py diff --git a/pkg/core/migrations/m009_msg_truncator_cfg.py b/src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py similarity index 100% rename from pkg/core/migrations/m009_msg_truncator_cfg.py rename to src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py diff --git a/pkg/core/migrations/m010_ollama_requester_config.py b/src/langbot/pkg/core/migrations/m010_ollama_requester_config.py similarity index 100% rename from pkg/core/migrations/m010_ollama_requester_config.py rename to src/langbot/pkg/core/migrations/m010_ollama_requester_config.py diff --git a/pkg/core/migrations/m011_command_prefix_config.py b/src/langbot/pkg/core/migrations/m011_command_prefix_config.py similarity index 100% rename from pkg/core/migrations/m011_command_prefix_config.py rename to src/langbot/pkg/core/migrations/m011_command_prefix_config.py diff --git a/pkg/core/migrations/m012_runner_config.py b/src/langbot/pkg/core/migrations/m012_runner_config.py similarity index 100% rename from pkg/core/migrations/m012_runner_config.py rename to src/langbot/pkg/core/migrations/m012_runner_config.py diff --git a/pkg/core/migrations/m013_http_api_config.py b/src/langbot/pkg/core/migrations/m013_http_api_config.py similarity index 100% rename from pkg/core/migrations/m013_http_api_config.py rename to src/langbot/pkg/core/migrations/m013_http_api_config.py diff --git a/pkg/core/migrations/m014_force_delay_config.py b/src/langbot/pkg/core/migrations/m014_force_delay_config.py similarity index 100% rename from pkg/core/migrations/m014_force_delay_config.py rename to src/langbot/pkg/core/migrations/m014_force_delay_config.py diff --git a/pkg/core/migrations/m015_gitee_ai_config.py b/src/langbot/pkg/core/migrations/m015_gitee_ai_config.py similarity index 100% rename from pkg/core/migrations/m015_gitee_ai_config.py rename to src/langbot/pkg/core/migrations/m015_gitee_ai_config.py diff --git a/pkg/core/migrations/m016_dify_service_api.py b/src/langbot/pkg/core/migrations/m016_dify_service_api.py similarity index 100% rename from pkg/core/migrations/m016_dify_service_api.py rename to src/langbot/pkg/core/migrations/m016_dify_service_api.py diff --git a/pkg/core/migrations/m017_dify_api_timeout_params.py b/src/langbot/pkg/core/migrations/m017_dify_api_timeout_params.py similarity index 100% rename from pkg/core/migrations/m017_dify_api_timeout_params.py rename to src/langbot/pkg/core/migrations/m017_dify_api_timeout_params.py diff --git a/pkg/core/migrations/m018_xai_config.py b/src/langbot/pkg/core/migrations/m018_xai_config.py similarity index 100% rename from pkg/core/migrations/m018_xai_config.py rename to src/langbot/pkg/core/migrations/m018_xai_config.py diff --git a/pkg/core/migrations/m019_zhipuai_config.py b/src/langbot/pkg/core/migrations/m019_zhipuai_config.py similarity index 100% rename from pkg/core/migrations/m019_zhipuai_config.py rename to src/langbot/pkg/core/migrations/m019_zhipuai_config.py diff --git a/pkg/core/migrations/m020_wecom_config.py b/src/langbot/pkg/core/migrations/m020_wecom_config.py similarity index 100% rename from pkg/core/migrations/m020_wecom_config.py rename to src/langbot/pkg/core/migrations/m020_wecom_config.py diff --git a/pkg/core/migrations/m021_lark_config.py b/src/langbot/pkg/core/migrations/m021_lark_config.py similarity index 100% rename from pkg/core/migrations/m021_lark_config.py rename to src/langbot/pkg/core/migrations/m021_lark_config.py diff --git a/pkg/core/migrations/m022_lmstudio_config.py b/src/langbot/pkg/core/migrations/m022_lmstudio_config.py similarity index 100% rename from pkg/core/migrations/m022_lmstudio_config.py rename to src/langbot/pkg/core/migrations/m022_lmstudio_config.py diff --git a/pkg/core/migrations/m023_siliconflow_config.py b/src/langbot/pkg/core/migrations/m023_siliconflow_config.py similarity index 100% rename from pkg/core/migrations/m023_siliconflow_config.py rename to src/langbot/pkg/core/migrations/m023_siliconflow_config.py diff --git a/pkg/core/migrations/m024_discord_config.py b/src/langbot/pkg/core/migrations/m024_discord_config.py similarity index 100% rename from pkg/core/migrations/m024_discord_config.py rename to src/langbot/pkg/core/migrations/m024_discord_config.py diff --git a/pkg/core/migrations/m025_gewechat_config.py b/src/langbot/pkg/core/migrations/m025_gewechat_config.py similarity index 100% rename from pkg/core/migrations/m025_gewechat_config.py rename to src/langbot/pkg/core/migrations/m025_gewechat_config.py diff --git a/pkg/core/migrations/m026_qqofficial_config.py b/src/langbot/pkg/core/migrations/m026_qqofficial_config.py similarity index 100% rename from pkg/core/migrations/m026_qqofficial_config.py rename to src/langbot/pkg/core/migrations/m026_qqofficial_config.py diff --git a/pkg/core/migrations/m027_wx_official_account_config.py b/src/langbot/pkg/core/migrations/m027_wx_official_account_config.py similarity index 100% rename from pkg/core/migrations/m027_wx_official_account_config.py rename to src/langbot/pkg/core/migrations/m027_wx_official_account_config.py diff --git a/pkg/core/migrations/m028_aliyun_requester_config.py b/src/langbot/pkg/core/migrations/m028_aliyun_requester_config.py similarity index 100% rename from pkg/core/migrations/m028_aliyun_requester_config.py rename to src/langbot/pkg/core/migrations/m028_aliyun_requester_config.py diff --git a/pkg/core/migrations/m029_dashscope_app_api_config.py b/src/langbot/pkg/core/migrations/m029_dashscope_app_api_config.py similarity index 100% rename from pkg/core/migrations/m029_dashscope_app_api_config.py rename to src/langbot/pkg/core/migrations/m029_dashscope_app_api_config.py diff --git a/pkg/core/migrations/m030_lark_config_cmpl.py b/src/langbot/pkg/core/migrations/m030_lark_config_cmpl.py similarity index 100% rename from pkg/core/migrations/m030_lark_config_cmpl.py rename to src/langbot/pkg/core/migrations/m030_lark_config_cmpl.py diff --git a/pkg/core/migrations/m031_dingtalk_config.py b/src/langbot/pkg/core/migrations/m031_dingtalk_config.py similarity index 100% rename from pkg/core/migrations/m031_dingtalk_config.py rename to src/langbot/pkg/core/migrations/m031_dingtalk_config.py diff --git a/pkg/core/migrations/m032_volcark_config.py b/src/langbot/pkg/core/migrations/m032_volcark_config.py similarity index 100% rename from pkg/core/migrations/m032_volcark_config.py rename to src/langbot/pkg/core/migrations/m032_volcark_config.py diff --git a/pkg/core/migrations/m033_dify_thinking_config.py b/src/langbot/pkg/core/migrations/m033_dify_thinking_config.py similarity index 100% rename from pkg/core/migrations/m033_dify_thinking_config.py rename to src/langbot/pkg/core/migrations/m033_dify_thinking_config.py diff --git a/pkg/core/migrations/m034_gewechat_file_url_config.py b/src/langbot/pkg/core/migrations/m034_gewechat_file_url_config.py similarity index 100% rename from pkg/core/migrations/m034_gewechat_file_url_config.py rename to src/langbot/pkg/core/migrations/m034_gewechat_file_url_config.py diff --git a/pkg/core/migrations/m035_wxoa_mode.py b/src/langbot/pkg/core/migrations/m035_wxoa_mode.py similarity index 100% rename from pkg/core/migrations/m035_wxoa_mode.py rename to src/langbot/pkg/core/migrations/m035_wxoa_mode.py diff --git a/pkg/core/migrations/m036_wxoa_loading_message.py b/src/langbot/pkg/core/migrations/m036_wxoa_loading_message.py similarity index 100% rename from pkg/core/migrations/m036_wxoa_loading_message.py rename to src/langbot/pkg/core/migrations/m036_wxoa_loading_message.py diff --git a/pkg/core/migrations/m037_mcp_config.py b/src/langbot/pkg/core/migrations/m037_mcp_config.py similarity index 100% rename from pkg/core/migrations/m037_mcp_config.py rename to src/langbot/pkg/core/migrations/m037_mcp_config.py diff --git a/pkg/core/migrations/m038_tg_dingtalk_markdown.py b/src/langbot/pkg/core/migrations/m038_tg_dingtalk_markdown.py similarity index 100% rename from pkg/core/migrations/m038_tg_dingtalk_markdown.py rename to src/langbot/pkg/core/migrations/m038_tg_dingtalk_markdown.py diff --git a/pkg/core/migrations/m039_modelscope_cfg_completion.py b/src/langbot/pkg/core/migrations/m039_modelscope_cfg_completion.py similarity index 100% rename from pkg/core/migrations/m039_modelscope_cfg_completion.py rename to src/langbot/pkg/core/migrations/m039_modelscope_cfg_completion.py diff --git a/pkg/core/migrations/m040_ppio_config.py b/src/langbot/pkg/core/migrations/m040_ppio_config.py similarity index 100% rename from pkg/core/migrations/m040_ppio_config.py rename to src/langbot/pkg/core/migrations/m040_ppio_config.py diff --git a/pkg/core/note.py b/src/langbot/pkg/core/note.py similarity index 100% rename from pkg/core/note.py rename to src/langbot/pkg/core/note.py diff --git a/pkg/core/stages/__init__.py b/src/langbot/pkg/core/notes/__init__.py similarity index 100% rename from pkg/core/stages/__init__.py rename to src/langbot/pkg/core/notes/__init__.py diff --git a/pkg/core/notes/n001_classic_msgs.py b/src/langbot/pkg/core/notes/n001_classic_msgs.py similarity index 87% rename from pkg/core/notes/n001_classic_msgs.py rename to src/langbot/pkg/core/notes/n001_classic_msgs.py index 265ddbe9..190958c6 100644 --- a/pkg/core/notes/n001_classic_msgs.py +++ b/src/langbot/pkg/core/notes/n001_classic_msgs.py @@ -13,6 +13,4 @@ class ClassicNotes(note.LaunchNote): return True async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]: - yield await self.ap.ann_mgr.show_announcements() - yield await self.ap.ver_mgr.show_version_update() diff --git a/pkg/core/notes/n002_selection_mode_on_windows.py b/src/langbot/pkg/core/notes/n002_selection_mode_on_windows.py similarity index 100% rename from pkg/core/notes/n002_selection_mode_on_windows.py rename to src/langbot/pkg/core/notes/n002_selection_mode_on_windows.py diff --git a/pkg/core/notes/n003_print_version.py b/src/langbot/pkg/core/notes/n003_print_version.py similarity index 100% rename from pkg/core/notes/n003_print_version.py rename to src/langbot/pkg/core/notes/n003_print_version.py diff --git a/pkg/core/stage.py b/src/langbot/pkg/core/stage.py similarity index 100% rename from pkg/core/stage.py rename to src/langbot/pkg/core/stage.py diff --git a/pkg/discover/__init__.py b/src/langbot/pkg/core/stages/__init__.py similarity index 100% rename from pkg/discover/__init__.py rename to src/langbot/pkg/core/stages/__init__.py diff --git a/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py similarity index 85% rename from pkg/core/stages/build_app.py rename to src/langbot/pkg/core/stages/build_app.py index 54a64ae8..51fd9a9f 100644 --- a/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from .. import stage, app -from ...utils import version, proxy, announce +from ...utils import version, proxy from ...pipeline import pool, controller, pipelinemgr from ...plugin import connector as plugin_connector from ...command import cmdmgr @@ -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 @@ -19,6 +20,9 @@ from ...api.http.service import model as model_service from ...api.http.service import pipeline as pipeline_service from ...api.http.service import bot as bot_service from ...api.http.service import knowledge as knowledge_service +from ...api.http.service import mcp as mcp_service +from ...api.http.service import apikey as apikey_service +from ...api.http.service import webhook as webhook_service from ...discover import engine as discover_engine from ...storage import mgr as storagemgr from ...utils import logcache @@ -35,7 +39,7 @@ class BuildAppStage(stage.BootingStage): ap.task_mgr = taskmgr.AsyncTaskManager(ap) discover = discover_engine.ComponentDiscoveryEngine(ap) - discover.discover_blueprint('components.yaml') + discover.discover_blueprint('templates/components.yaml') ap.discover = discover proxy_mgr = proxy.ProxyManager(ap) @@ -46,10 +50,6 @@ class BuildAppStage(stage.BootingStage): await ver_mgr.initialize() ap.ver_mgr = ver_mgr - # Send announcement - ann_mgr = announce.AnnouncementManager(ap) - ap.ann_mgr = ann_mgr - ap.query_pool = pool.QueryPool() log_cache = logcache.LogCache() @@ -91,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 @@ -126,5 +130,14 @@ class BuildAppStage(stage.BootingStage): knowledge_service_inst = knowledge_service.KnowledgeService(ap) ap.knowledge_service = knowledge_service_inst + mcp_service_inst = mcp_service.MCPService(ap) + ap.mcp_service = mcp_service_inst + + apikey_service_inst = apikey_service.ApiKeyService(ap) + ap.apikey_service = apikey_service_inst + + webhook_service_inst = webhook_service.WebhookService(ap) + ap.webhook_service = webhook_service_inst + ctrl = controller.Controller(ap) ap.ctrl = ctrl diff --git a/pkg/core/stages/genkeys.py b/src/langbot/pkg/core/stages/genkeys.py similarity index 100% rename from pkg/core/stages/genkeys.py rename to src/langbot/pkg/core/stages/genkeys.py diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py new file mode 100644 index 00000000..b2b5abba --- /dev/null +++ b/src/langbot/pkg/core/stages/load_config.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import os +from typing import Any +import yaml +import importlib.resources as resources + +from .. import stage, app +from ..bootutils import config + + +def _apply_env_overrides_to_config(cfg: dict) -> dict: + """Apply environment variable overrides to data/config.yaml + + Environment variables should be uppercase and use __ (double underscore) + to represent nested keys. For example: + - CONCURRENCY__PIPELINE overrides concurrency.pipeline + - PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url + + Arrays and dict types are ignored. + + Args: + cfg: Configuration dictionary + + Returns: + Updated configuration dictionary + """ + + def convert_value(value: str, original_value: Any) -> Any: + """Convert string value to appropriate type based on original value + + Args: + value: String value from environment variable + original_value: Original value to infer type from + + Returns: + Converted value (falls back to string if conversion fails) + """ + if isinstance(original_value, bool): + return value.lower() in ('true', '1', 'yes', 'on') + elif isinstance(original_value, int): + try: + return int(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + elif isinstance(original_value, float): + try: + return float(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + else: + return value + + # Process environment variables + for env_key, env_value in os.environ.items(): + # Check if the environment variable is uppercase and contains __ + if not env_key.isupper(): + continue + if '__' not in env_key: + continue + + print(f'apply env overrides to config: env_key: {env_key}, env_value: {env_value}') + + # Convert environment variable name to config path + # e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline'] + keys = [key.lower() for key in env_key.split('__')] + + # Navigate to the target value and validate the path + current = cfg + + for i, key in enumerate(keys): + if not isinstance(current, dict) or key not in current: + break + + if i == len(keys) - 1: + # At the final key - check if it's a scalar value + if isinstance(current[key], (dict, list)): + # Skip dict and list types + pass + else: + # Valid scalar value - convert and set it + converted_value = convert_value(env_value, current[key]) + current[key] = converted_value + else: + # Navigate deeper + current = current[key] + + return cfg + + +@stage.stage_class('LoadConfigStage') +class LoadConfigStage(stage.BootingStage): + """Load config file stage""" + + async def run(self, ap: app.Application): + """Load config file""" + + # # ======= deprecated ======= + # if os.path.exists('data/config/command.json'): + # ap.command_cfg = await config.load_json_config( + # 'data/config/command.json', + # 'templates/legacy/command.json', + # completion=False, + # ) + + # if os.path.exists('data/config/pipeline.json'): + # ap.pipeline_cfg = await config.load_json_config( + # 'data/config/pipeline.json', + # 'templates/legacy/pipeline.json', + # completion=False, + # ) + + # if os.path.exists('data/config/platform.json'): + # ap.platform_cfg = await config.load_json_config( + # 'data/config/platform.json', + # 'templates/legacy/platform.json', + # completion=False, + # ) + + # if os.path.exists('data/config/provider.json'): + # ap.provider_cfg = await config.load_json_config( + # 'data/config/provider.json', + # 'templates/legacy/provider.json', + # completion=False, + # ) + + # if os.path.exists('data/config/system.json'): + # ap.system_cfg = await config.load_json_config( + # 'data/config/system.json', + # 'templates/legacy/system.json', + # completion=False, + # ) + + # # ======= deprecated ======= + + ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False) + + # Apply environment variable overrides to data/config.yaml + ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data) + + await ap.instance_config.dump_config() + + ap.sensitive_meta = await config.load_json_config( + 'data/metadata/sensitive-words.json', + 'metadata/sensitive-words.json', + ) + await ap.sensitive_meta.dump_config() + + async def load_resource_yaml_template_data(resource_name: str) -> dict: + with resources.files('langbot.templates').joinpath(resource_name).open('r', encoding='utf-8') as f: + return yaml.load(f, Loader=yaml.FullLoader) + + ap.pipeline_config_meta_trigger = await load_resource_yaml_template_data('metadata/pipeline/trigger.yaml') + ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml') + ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml') + ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml') diff --git a/pkg/core/stages/migrate.py b/src/langbot/pkg/core/stages/migrate.py similarity index 100% rename from pkg/core/stages/migrate.py rename to src/langbot/pkg/core/stages/migrate.py diff --git a/pkg/core/stages/setup_logger.py b/src/langbot/pkg/core/stages/setup_logger.py similarity index 100% rename from pkg/core/stages/setup_logger.py rename to src/langbot/pkg/core/stages/setup_logger.py diff --git a/pkg/core/stages/show_notes.py b/src/langbot/pkg/core/stages/show_notes.py similarity index 100% rename from pkg/core/stages/show_notes.py rename to src/langbot/pkg/core/stages/show_notes.py diff --git a/pkg/core/taskmgr.py b/src/langbot/pkg/core/taskmgr.py similarity index 98% rename from pkg/core/taskmgr.py rename to src/langbot/pkg/core/taskmgr.py index ca6eb029..4eee7104 100644 --- a/pkg/core/taskmgr.py +++ b/src/langbot/pkg/core/taskmgr.py @@ -156,7 +156,7 @@ class TaskWrapper: 'state': self.task._state, 'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None, 'exception_traceback': exception_traceback, - 'result': self.assume_result().__str__() if self.assume_result() is not None else None, + 'result': self.assume_result() if self.assume_result() is not None else None, }, } diff --git a/pkg/entity/__init__.py b/src/langbot/pkg/discover/__init__.py similarity index 100% rename from pkg/entity/__init__.py rename to src/langbot/pkg/discover/__init__.py diff --git a/pkg/discover/engine.py b/src/langbot/pkg/discover/engine.py similarity index 91% rename from pkg/discover/engine.py rename to src/langbot/pkg/discover/engine.py index 335862c0..ca7f2588 100644 --- a/pkg/discover/engine.py +++ b/src/langbot/pkg/discover/engine.py @@ -6,7 +6,8 @@ import os import yaml import pydantic -from ..core import app +from langbot.pkg.core import app +from langbot.pkg.utils import importutil class I18nString(pydantic.BaseModel): @@ -165,7 +166,7 @@ class Component(pydantic.BaseModel): if module_path.endswith('.py'): module_path = module_path[:-3] module_path = module_path.replace('/', '.').replace('\\', '.') - module = importlib.import_module(module_path) + module = importlib.import_module(f'langbot.{module_path}') return getattr(module, self.execution.python.attr) def to_plain_dict(self) -> dict: @@ -193,16 +194,17 @@ class ComponentDiscoveryEngine: def load_component_manifest(self, path: str, owner: str = 'builtin', no_save: bool = False) -> Component | None: """加载组件清单""" - with open(path, 'r', encoding='utf-8') as f: - manifest = yaml.safe_load(f) - if not Component.is_component_manifest(manifest): - return None - comp = Component(owner=owner, manifest=manifest, rel_path=path) - if not no_save: - if comp.kind not in self.components: - self.components[comp.kind] = [] - self.components[comp.kind].append(comp) - return comp + # with open(path, 'r', encoding='utf-8') as f: + # manifest = yaml.safe_load(f) + manifest = yaml.safe_load(importutil.read_resource_file(path)) + if not Component.is_component_manifest(manifest): + return None + comp = Component(owner=owner, manifest=manifest, rel_path=path) + if not no_save: + if comp.kind not in self.components: + self.components[comp.kind] = [] + self.components[comp.kind].append(comp) + return comp def load_component_manifests_in_dir( self, @@ -217,7 +219,8 @@ class ComponentDiscoveryEngine: def recursive_load_component_manifests_in_dir(path: str, depth: int = 1): if depth > max_depth: return - for file in os.listdir(path): + + for file in importutil.list_resource_files(path): if (not os.path.isdir(os.path.join(path, file))) and (file.endswith('.yaml') or file.endswith('.yml')): comp = self.load_component_manifest(os.path.join(path, file), owner, no_save) if comp is not None: diff --git a/pkg/entity/errors/__init__.py b/src/langbot/pkg/entity/__init__.py similarity index 100% rename from pkg/entity/errors/__init__.py rename to src/langbot/pkg/entity/__init__.py diff --git a/pkg/entity/persistence/__init__.py b/src/langbot/pkg/entity/errors/__init__.py similarity index 100% rename from pkg/entity/persistence/__init__.py rename to src/langbot/pkg/entity/errors/__init__.py diff --git a/pkg/entity/errors/platform.py b/src/langbot/pkg/entity/errors/platform.py similarity index 100% rename from pkg/entity/errors/platform.py rename to src/langbot/pkg/entity/errors/platform.py diff --git a/pkg/entity/errors/provider.py b/src/langbot/pkg/entity/errors/provider.py similarity index 100% rename from pkg/entity/errors/provider.py rename to src/langbot/pkg/entity/errors/provider.py diff --git a/pkg/entity/rag/__init__.py b/src/langbot/pkg/entity/persistence/__init__.py similarity index 100% rename from pkg/entity/rag/__init__.py rename to src/langbot/pkg/entity/persistence/__init__.py diff --git a/src/langbot/pkg/entity/persistence/apikey.py b/src/langbot/pkg/entity/persistence/apikey.py new file mode 100644 index 00000000..488c0324 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/apikey.py @@ -0,0 +1,21 @@ +import sqlalchemy + +from .base import Base + + +class ApiKey(Base): + """API Key for external service authentication""" + + __tablename__ = 'api_keys' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True) + description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='') + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/pkg/entity/persistence/base.py b/src/langbot/pkg/entity/persistence/base.py similarity index 100% rename from pkg/entity/persistence/base.py rename to src/langbot/pkg/entity/persistence/base.py diff --git a/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py similarity index 100% rename from pkg/entity/persistence/bot.py rename to src/langbot/pkg/entity/persistence/bot.py diff --git a/pkg/entity/persistence/bstorage.py b/src/langbot/pkg/entity/persistence/bstorage.py similarity index 100% rename from pkg/entity/persistence/bstorage.py rename to src/langbot/pkg/entity/persistence/bstorage.py diff --git a/src/langbot/pkg/entity/persistence/mcp.py b/src/langbot/pkg/entity/persistence/mcp.py new file mode 100644 index 00000000..74478dc7 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/mcp.py @@ -0,0 +1,20 @@ +import sqlalchemy + +from .base import Base + + +class MCPServer(Base): + __tablename__ = 'mcp_servers' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) + mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse + extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/pkg/entity/persistence/metadata.py b/src/langbot/pkg/entity/persistence/metadata.py similarity index 78% rename from pkg/entity/persistence/metadata.py rename to src/langbot/pkg/entity/persistence/metadata.py index 4db732b9..ac3b4602 100644 --- a/pkg/entity/persistence/metadata.py +++ b/src/langbot/pkg/entity/persistence/metadata.py @@ -1,12 +1,13 @@ import sqlalchemy from .base import Base +from ...utils import constants initial_metadata = [ { 'key': 'database_version', - 'value': '0', + 'value': str(constants.required_database_version), }, ] diff --git a/pkg/entity/persistence/model.py b/src/langbot/pkg/entity/persistence/model.py similarity index 100% rename from pkg/entity/persistence/model.py rename to src/langbot/pkg/entity/persistence/model.py diff --git a/pkg/entity/persistence/pipeline.py b/src/langbot/pkg/entity/persistence/pipeline.py similarity index 95% rename from pkg/entity/persistence/pipeline.py rename to src/langbot/pkg/entity/persistence/pipeline.py index 3a21dbf2..8c0093aa 100644 --- a/pkg/entity/persistence/pipeline.py +++ b/src/langbot/pkg/entity/persistence/pipeline.py @@ -22,6 +22,7 @@ class LegacyPipeline(Base): is_default = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) stages = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) + extensions_preferences = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) class PipelineRunRecord(Base): diff --git a/pkg/entity/persistence/plugin.py b/src/langbot/pkg/entity/persistence/plugin.py similarity index 100% rename from pkg/entity/persistence/plugin.py rename to src/langbot/pkg/entity/persistence/plugin.py diff --git a/pkg/entity/persistence/rag.py b/src/langbot/pkg/entity/persistence/rag.py similarity index 100% rename from pkg/entity/persistence/rag.py rename to src/langbot/pkg/entity/persistence/rag.py diff --git a/pkg/entity/persistence/user.py b/src/langbot/pkg/entity/persistence/user.py similarity index 100% rename from pkg/entity/persistence/user.py rename to src/langbot/pkg/entity/persistence/user.py diff --git a/pkg/entity/persistence/vector.py b/src/langbot/pkg/entity/persistence/vector.py similarity index 100% rename from pkg/entity/persistence/vector.py rename to src/langbot/pkg/entity/persistence/vector.py 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/pkg/persistence/__init__.py b/src/langbot/pkg/entity/rag/__init__.py similarity index 100% rename from pkg/persistence/__init__.py rename to src/langbot/pkg/entity/rag/__init__.py diff --git a/pkg/entity/rag/retriever.py b/src/langbot/pkg/entity/rag/retriever.py similarity index 100% rename from pkg/entity/rag/retriever.py rename to src/langbot/pkg/entity/rag/retriever.py diff --git a/pkg/persistence/databases/__init__.py b/src/langbot/pkg/persistence/__init__.py similarity index 100% rename from pkg/persistence/databases/__init__.py rename to src/langbot/pkg/persistence/__init__.py diff --git a/pkg/persistence/database.py b/src/langbot/pkg/persistence/database.py similarity index 100% rename from pkg/persistence/database.py rename to src/langbot/pkg/persistence/database.py diff --git a/pkg/persistence/migrations/__init__.py b/src/langbot/pkg/persistence/databases/__init__.py similarity index 100% rename from pkg/persistence/migrations/__init__.py rename to src/langbot/pkg/persistence/databases/__init__.py diff --git a/pkg/persistence/databases/postgresql.py b/src/langbot/pkg/persistence/databases/postgresql.py similarity index 100% rename from pkg/persistence/databases/postgresql.py rename to src/langbot/pkg/persistence/databases/postgresql.py diff --git a/pkg/persistence/databases/sqlite.py b/src/langbot/pkg/persistence/databases/sqlite.py similarity index 100% rename from pkg/persistence/databases/sqlite.py rename to src/langbot/pkg/persistence/databases/sqlite.py diff --git a/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py similarity index 95% rename from pkg/persistence/mgr.py rename to src/langbot/pkg/persistence/mgr.py index bab2cde5..53b20011 100644 --- a/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -78,6 +78,8 @@ class PersistenceManager: self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.') + await self.write_default_pipeline() + async def create_tables(self): # create tables async with self.get_db_engine().connect() as conn: @@ -98,13 +100,14 @@ class PersistenceManager: if row is None: await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item)) + async def write_default_pipeline(self): # write default pipeline result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline)) default_pipeline_uuid = None if result.first() is None: self.ap.logger.info('Creating default pipeline...') - pipeline_config = json.load(open('templates/default-pipeline-config.json', 'r', encoding='utf-8')) + pipeline_config = json.loads(importutil.read_resource_file('templates/default-pipeline-config.json')) default_pipeline_uuid = str(uuid.uuid4()) pipeline_data = { @@ -115,6 +118,7 @@ class PersistenceManager: 'name': 'ChatPipeline', 'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线', 'config': pipeline_config, + 'extensions_preferences': {}, } await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data)) diff --git a/pkg/persistence/migration.py b/src/langbot/pkg/persistence/migration.py similarity index 100% rename from pkg/persistence/migration.py rename to src/langbot/pkg/persistence/migration.py diff --git a/pkg/pipeline/__init__.py b/src/langbot/pkg/persistence/migrations/__init__.py similarity index 100% rename from pkg/pipeline/__init__.py rename to src/langbot/pkg/persistence/migrations/__init__.py diff --git a/pkg/persistence/migrations/dbm001_migrate_v3_config.py b/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py similarity index 99% rename from pkg/persistence/migrations/dbm001_migrate_v3_config.py rename to src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py index 1f2d9770..55e63fff 100644 --- a/pkg/persistence/migrations/dbm001_migrate_v3_config.py +++ b/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py @@ -212,7 +212,9 @@ class DBMigrateV3Config(migration.DBMigration): self.ap.instance_config.data['api']['port'] = self.ap.system_cfg.data['http-api']['port'] self.ap.instance_config.data['command'] = { 'prefix': self.ap.command_cfg.data['command-prefix'], - 'enable': self.ap.command_cfg.data['command-enable'] if 'command-enable' in self.ap.command_cfg.data else True, + 'enable': self.ap.command_cfg.data['command-enable'] + if 'command-enable' in self.ap.command_cfg.data + else True, 'privilege': self.ap.command_cfg.data['privilege'], } self.ap.instance_config.data['concurrency']['pipeline'] = self.ap.system_cfg.data['pipeline-concurrency'] diff --git a/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py b/src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py similarity index 100% rename from pkg/persistence/migrations/dbm002_combine_quote_msg_config.py rename to src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py diff --git a/pkg/persistence/migrations/dbm003_n8n_config.py b/src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py similarity index 100% rename from pkg/persistence/migrations/dbm003_n8n_config.py rename to src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py diff --git a/pkg/persistence/migrations/dbm004_rag_kb_uuid.py b/src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py similarity index 100% rename from pkg/persistence/migrations/dbm004_rag_kb_uuid.py rename to src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py diff --git a/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py b/src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py similarity index 100% rename from pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py rename to src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py diff --git a/pkg/persistence/migrations/dbm006_langflow_api_config.py b/src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py similarity index 100% rename from pkg/persistence/migrations/dbm006_langflow_api_config.py rename to src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py diff --git a/pkg/persistence/migrations/dbm007_plugin_install_source.py b/src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py similarity index 100% rename from pkg/persistence/migrations/dbm007_plugin_install_source.py rename to src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py diff --git a/pkg/persistence/migrations/dbm008_plugin_config.py b/src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py similarity index 100% rename from pkg/persistence/migrations/dbm008_plugin_config.py rename to src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py diff --git a/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py b/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py new file mode 100644 index 00000000..927da4bf --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py @@ -0,0 +1,20 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(9) +class DBMigratePipelineExtensionPreferences(migration.DBMigration): + """Pipeline extension preferences""" + + async def upgrade(self): + """Upgrade""" + + sql_text = sqlalchemy.text( + "ALTER TABLE legacy_pipelines ADD COLUMN extensions_preferences JSON NOT NULL DEFAULT '{}'" + ) + await self.ap.persistence_mgr.execute_async(sql_text) + + async def downgrade(self): + """Downgrade""" + sql_text = sqlalchemy.text('ALTER TABLE legacy_pipelines DROP COLUMN extensions_preferences') + await self.ap.persistence_mgr.execute_async(sql_text) 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/pkg/pipeline/bansess/__init__.py b/src/langbot/pkg/pipeline/__init__.py similarity index 100% rename from pkg/pipeline/bansess/__init__.py rename to src/langbot/pkg/pipeline/__init__.py diff --git a/pkg/pipeline/cntfilter/__init__.py b/src/langbot/pkg/pipeline/bansess/__init__.py similarity index 100% rename from pkg/pipeline/cntfilter/__init__.py rename to src/langbot/pkg/pipeline/bansess/__init__.py diff --git a/pkg/pipeline/bansess/bansess.py b/src/langbot/pkg/pipeline/bansess/bansess.py similarity index 100% rename from pkg/pipeline/bansess/bansess.py rename to src/langbot/pkg/pipeline/bansess/bansess.py diff --git a/pkg/pipeline/cntfilter/filters/__init__.py b/src/langbot/pkg/pipeline/cntfilter/__init__.py similarity index 100% rename from pkg/pipeline/cntfilter/filters/__init__.py rename to src/langbot/pkg/pipeline/cntfilter/__init__.py diff --git a/pkg/pipeline/cntfilter/cntfilter.py b/src/langbot/pkg/pipeline/cntfilter/cntfilter.py similarity index 100% rename from pkg/pipeline/cntfilter/cntfilter.py rename to src/langbot/pkg/pipeline/cntfilter/cntfilter.py diff --git a/pkg/pipeline/cntfilter/entities.py b/src/langbot/pkg/pipeline/cntfilter/entities.py similarity index 100% rename from pkg/pipeline/cntfilter/entities.py rename to src/langbot/pkg/pipeline/cntfilter/entities.py diff --git a/pkg/pipeline/cntfilter/filter.py b/src/langbot/pkg/pipeline/cntfilter/filter.py similarity index 100% rename from pkg/pipeline/cntfilter/filter.py rename to src/langbot/pkg/pipeline/cntfilter/filter.py diff --git a/pkg/pipeline/longtext/__init__.py b/src/langbot/pkg/pipeline/cntfilter/filters/__init__.py similarity index 100% rename from pkg/pipeline/longtext/__init__.py rename to src/langbot/pkg/pipeline/cntfilter/filters/__init__.py diff --git a/pkg/pipeline/cntfilter/filters/baiduexamine.py b/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py similarity index 100% rename from pkg/pipeline/cntfilter/filters/baiduexamine.py rename to src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py diff --git a/pkg/pipeline/cntfilter/filters/banwords.py b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py similarity index 100% rename from pkg/pipeline/cntfilter/filters/banwords.py rename to src/langbot/pkg/pipeline/cntfilter/filters/banwords.py diff --git a/pkg/pipeline/cntfilter/filters/cntignore.py b/src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py similarity index 100% rename from pkg/pipeline/cntfilter/filters/cntignore.py rename to src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py diff --git a/pkg/pipeline/controller.py b/src/langbot/pkg/pipeline/controller.py similarity index 100% rename from pkg/pipeline/controller.py rename to src/langbot/pkg/pipeline/controller.py diff --git a/pkg/pipeline/entities.py b/src/langbot/pkg/pipeline/entities.py similarity index 100% rename from pkg/pipeline/entities.py rename to src/langbot/pkg/pipeline/entities.py diff --git a/pkg/pipeline/longtext/strategies/__init__.py b/src/langbot/pkg/pipeline/longtext/__init__.py similarity index 100% rename from pkg/pipeline/longtext/strategies/__init__.py rename to src/langbot/pkg/pipeline/longtext/__init__.py diff --git a/pkg/pipeline/longtext/longtext.py b/src/langbot/pkg/pipeline/longtext/longtext.py similarity index 100% rename from pkg/pipeline/longtext/longtext.py rename to src/langbot/pkg/pipeline/longtext/longtext.py diff --git a/pkg/pipeline/msgtrun/__init__.py b/src/langbot/pkg/pipeline/longtext/strategies/__init__.py similarity index 100% rename from pkg/pipeline/msgtrun/__init__.py rename to src/langbot/pkg/pipeline/longtext/strategies/__init__.py diff --git a/pkg/pipeline/longtext/strategies/forward.py b/src/langbot/pkg/pipeline/longtext/strategies/forward.py similarity index 100% rename from pkg/pipeline/longtext/strategies/forward.py rename to src/langbot/pkg/pipeline/longtext/strategies/forward.py diff --git a/pkg/pipeline/longtext/strategies/image.py b/src/langbot/pkg/pipeline/longtext/strategies/image.py similarity index 100% rename from pkg/pipeline/longtext/strategies/image.py rename to src/langbot/pkg/pipeline/longtext/strategies/image.py diff --git a/pkg/pipeline/longtext/strategy.py b/src/langbot/pkg/pipeline/longtext/strategy.py similarity index 100% rename from pkg/pipeline/longtext/strategy.py rename to src/langbot/pkg/pipeline/longtext/strategy.py diff --git a/pkg/pipeline/msgtrun/truncators/__init__.py b/src/langbot/pkg/pipeline/msgtrun/__init__.py similarity index 100% rename from pkg/pipeline/msgtrun/truncators/__init__.py rename to src/langbot/pkg/pipeline/msgtrun/__init__.py diff --git a/pkg/pipeline/msgtrun/msgtrun.py b/src/langbot/pkg/pipeline/msgtrun/msgtrun.py similarity index 100% rename from pkg/pipeline/msgtrun/msgtrun.py rename to src/langbot/pkg/pipeline/msgtrun/msgtrun.py diff --git a/pkg/pipeline/msgtrun/truncator.py b/src/langbot/pkg/pipeline/msgtrun/truncator.py similarity index 100% rename from pkg/pipeline/msgtrun/truncator.py rename to src/langbot/pkg/pipeline/msgtrun/truncator.py diff --git a/pkg/pipeline/preproc/__init__.py b/src/langbot/pkg/pipeline/msgtrun/truncators/__init__.py similarity index 100% rename from pkg/pipeline/preproc/__init__.py rename to src/langbot/pkg/pipeline/msgtrun/truncators/__init__.py diff --git a/pkg/pipeline/msgtrun/truncators/round.py b/src/langbot/pkg/pipeline/msgtrun/truncators/round.py similarity index 100% rename from pkg/pipeline/msgtrun/truncators/round.py rename to src/langbot/pkg/pipeline/msgtrun/truncators/round.py diff --git a/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py similarity index 91% rename from pkg/pipeline/pipelinemgr.py rename to src/langbot/pkg/pipeline/pipelinemgr.py index ab663293..9470eb23 100644 --- a/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -69,6 +69,12 @@ class RuntimePipeline: stage_containers: list[StageInstContainer] """阶段实例容器""" + bound_plugins: list[str] + """绑定到此流水线的插件列表(格式:author/plugin_name)""" + + bound_mcp_servers: list[str] + """绑定到此流水线的MCP服务器列表(格式:uuid)""" + def __init__( self, ap: app.Application, @@ -79,8 +85,19 @@ class RuntimePipeline: self.pipeline_entity = pipeline_entity self.stage_containers = stage_containers + # Extract bound plugins and MCP servers from extensions_preferences + extensions_prefs = pipeline_entity.extensions_preferences or {} + plugin_list = extensions_prefs.get('plugins', []) + self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if plugin_list else [] + + mcp_server_list = extensions_prefs.get('mcp_servers', []) + self.bound_mcp_servers = mcp_server_list if mcp_server_list else [] + async def run(self, query: pipeline_query.Query): query.pipeline_config = self.pipeline_entity.config + # Store bound plugins and MCP servers in query for filtering + query.variables['_pipeline_bound_plugins'] = self.bound_plugins + query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers await self.process_query(query) async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult): @@ -188,6 +205,9 @@ class RuntimePipeline: async def process_query(self, query: pipeline_query.Query): """处理请求""" try: + # Get bound plugins for this pipeline + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + # ======== 触发 MessageReceived 事件 ======== event_type = ( events.PersonMessageReceived @@ -203,7 +223,7 @@ class RuntimePipeline: message_chain=query.message_chain, ) - event_ctx = await self.ap.plugin_connector.emit_event(event_obj) + event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins) if event_ctx.is_prevented_default(): return diff --git a/pkg/pipeline/pool.py b/src/langbot/pkg/pipeline/pool.py similarity index 100% rename from pkg/pipeline/pool.py rename to src/langbot/pkg/pipeline/pool.py diff --git a/pkg/pipeline/process/__init__.py b/src/langbot/pkg/pipeline/preproc/__init__.py similarity index 100% rename from pkg/pipeline/process/__init__.py rename to src/langbot/pkg/pipeline/preproc/__init__.py diff --git a/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py similarity index 89% rename from pkg/pipeline/preproc/preproc.py rename to src/langbot/pkg/pipeline/preproc/preproc.py index 8e8e1755..f6f98abb 100644 --- a/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -65,7 +65,14 @@ class PreProcessor(stage.PipelineStage): query.use_llm_model_uuid = llm_model.model_entity.uuid if llm_model.model_entity.abilities.__contains__('func_call'): - query.use_funcs = await self.ap.tool_mgr.get_all_tools() + # Get bound plugins and MCP servers for filtering tools + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) + query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers) + + self.ap.logger.debug(f'Bound plugins: {bound_plugins}') + self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') + self.ap.logger.debug(f'Use funcs: {query.use_funcs}') variables = { 'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}', @@ -130,7 +137,9 @@ class PreProcessor(stage.PipelineStage): query=query, ) - event_ctx = await self.ap.plugin_connector.emit_event(event) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) query.prompt.messages = event_ctx.event.default_prompt query.messages = event_ctx.event.prompt diff --git a/pkg/pipeline/process/handlers/__init__.py b/src/langbot/pkg/pipeline/process/__init__.py similarity index 100% rename from pkg/pipeline/process/handlers/__init__.py rename to src/langbot/pkg/pipeline/process/__init__.py diff --git a/pkg/pipeline/process/handler.py b/src/langbot/pkg/pipeline/process/handler.py similarity index 100% rename from pkg/pipeline/process/handler.py rename to src/langbot/pkg/pipeline/process/handler.py diff --git a/pkg/pipeline/ratelimit/__init__.py b/src/langbot/pkg/pipeline/process/handlers/__init__.py similarity index 100% rename from pkg/pipeline/ratelimit/__init__.py rename to src/langbot/pkg/pipeline/process/handlers/__init__.py diff --git a/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py similarity index 97% rename from pkg/pipeline/process/handlers/chat.py rename to src/langbot/pkg/pipeline/process/handlers/chat.py index 6e133cc9..66ab5a01 100644 --- a/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -43,7 +43,9 @@ class ChatMessageHandler(handler.MessageHandler): query=query, ) - event_ctx = await self.ap.plugin_connector.emit_event(event) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) is_create_card = False # 判断下是否需要创建流式卡片 diff --git a/pkg/pipeline/process/handlers/command.py b/src/langbot/pkg/pipeline/process/handlers/command.py similarity index 96% rename from pkg/pipeline/process/handlers/command.py rename to src/langbot/pkg/pipeline/process/handlers/command.py index 52bcdb6f..6d686acd 100644 --- a/pkg/pipeline/process/handlers/command.py +++ b/src/langbot/pkg/pipeline/process/handlers/command.py @@ -45,7 +45,9 @@ class CommandHandler(handler.MessageHandler): query=query, ) - event_ctx = await self.ap.plugin_connector.emit_event(event) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) if event_ctx.is_prevented_default(): if event_ctx.event.reply_message_chain is not None: diff --git a/pkg/pipeline/process/process.py b/src/langbot/pkg/pipeline/process/process.py similarity index 100% rename from pkg/pipeline/process/process.py rename to src/langbot/pkg/pipeline/process/process.py diff --git a/pkg/pipeline/ratelimit/algos/__init__.py b/src/langbot/pkg/pipeline/ratelimit/__init__.py similarity index 100% rename from pkg/pipeline/ratelimit/algos/__init__.py rename to src/langbot/pkg/pipeline/ratelimit/__init__.py diff --git a/pkg/pipeline/ratelimit/algo.py b/src/langbot/pkg/pipeline/ratelimit/algo.py similarity index 100% rename from pkg/pipeline/ratelimit/algo.py rename to src/langbot/pkg/pipeline/ratelimit/algo.py diff --git a/pkg/pipeline/respback/__init__.py b/src/langbot/pkg/pipeline/ratelimit/algos/__init__.py similarity index 100% rename from pkg/pipeline/respback/__init__.py rename to src/langbot/pkg/pipeline/ratelimit/algos/__init__.py diff --git a/pkg/pipeline/ratelimit/algos/fixedwin.py b/src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py similarity index 100% rename from pkg/pipeline/ratelimit/algos/fixedwin.py rename to src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py diff --git a/pkg/pipeline/ratelimit/ratelimit.py b/src/langbot/pkg/pipeline/ratelimit/ratelimit.py similarity index 100% rename from pkg/pipeline/ratelimit/ratelimit.py rename to src/langbot/pkg/pipeline/ratelimit/ratelimit.py diff --git a/pkg/pipeline/resprule/__init__.py b/src/langbot/pkg/pipeline/respback/__init__.py similarity index 100% rename from pkg/pipeline/resprule/__init__.py rename to src/langbot/pkg/pipeline/respback/__init__.py diff --git a/pkg/pipeline/respback/respback.py b/src/langbot/pkg/pipeline/respback/respback.py similarity index 100% rename from pkg/pipeline/respback/respback.py rename to src/langbot/pkg/pipeline/respback/respback.py diff --git a/pkg/pipeline/resprule/rules/__init__.py b/src/langbot/pkg/pipeline/resprule/__init__.py similarity index 100% rename from pkg/pipeline/resprule/rules/__init__.py rename to src/langbot/pkg/pipeline/resprule/__init__.py diff --git a/pkg/pipeline/resprule/entities.py b/src/langbot/pkg/pipeline/resprule/entities.py similarity index 100% rename from pkg/pipeline/resprule/entities.py rename to src/langbot/pkg/pipeline/resprule/entities.py diff --git a/pkg/pipeline/resprule/resprule.py b/src/langbot/pkg/pipeline/resprule/resprule.py similarity index 100% rename from pkg/pipeline/resprule/resprule.py rename to src/langbot/pkg/pipeline/resprule/resprule.py diff --git a/pkg/pipeline/resprule/rule.py b/src/langbot/pkg/pipeline/resprule/rule.py similarity index 100% rename from pkg/pipeline/resprule/rule.py rename to src/langbot/pkg/pipeline/resprule/rule.py diff --git a/pkg/pipeline/wrapper/__init__.py b/src/langbot/pkg/pipeline/resprule/rules/__init__.py similarity index 100% rename from pkg/pipeline/wrapper/__init__.py rename to src/langbot/pkg/pipeline/resprule/rules/__init__.py diff --git a/pkg/pipeline/resprule/rules/atbot.py b/src/langbot/pkg/pipeline/resprule/rules/atbot.py similarity index 91% rename from pkg/pipeline/resprule/rules/atbot.py rename to src/langbot/pkg/pipeline/resprule/rules/atbot.py index 68c3ace9..9d549d10 100644 --- a/pkg/pipeline/resprule/rules/atbot.py +++ b/src/langbot/pkg/pipeline/resprule/rules/atbot.py @@ -21,7 +21,9 @@ class AtBotRule(rule_model.GroupRespondRule): def remove_at(message_chain: platform_message.MessageChain): nonlocal found for component in message_chain.root: - if isinstance(component, platform_message.At) and str(component.target) == str(query.adapter.bot_account_id): + if isinstance(component, platform_message.At) and str(component.target) == str( + query.adapter.bot_account_id + ): message_chain.remove(component) found = True break diff --git a/pkg/pipeline/resprule/rules/prefix.py b/src/langbot/pkg/pipeline/resprule/rules/prefix.py similarity index 100% rename from pkg/pipeline/resprule/rules/prefix.py rename to src/langbot/pkg/pipeline/resprule/rules/prefix.py diff --git a/pkg/pipeline/resprule/rules/random.py b/src/langbot/pkg/pipeline/resprule/rules/random.py similarity index 100% rename from pkg/pipeline/resprule/rules/random.py rename to src/langbot/pkg/pipeline/resprule/rules/random.py diff --git a/pkg/pipeline/resprule/rules/regexp.py b/src/langbot/pkg/pipeline/resprule/rules/regexp.py similarity index 100% rename from pkg/pipeline/resprule/rules/regexp.py rename to src/langbot/pkg/pipeline/resprule/rules/regexp.py diff --git a/pkg/pipeline/stage.py b/src/langbot/pkg/pipeline/stage.py similarity index 100% rename from pkg/pipeline/stage.py rename to src/langbot/pkg/pipeline/stage.py diff --git a/pkg/platform/__init__.py b/src/langbot/pkg/pipeline/wrapper/__init__.py similarity index 100% rename from pkg/platform/__init__.py rename to src/langbot/pkg/pipeline/wrapper/__init__.py diff --git a/pkg/pipeline/wrapper/wrapper.py b/src/langbot/pkg/pipeline/wrapper/wrapper.py similarity index 94% rename from pkg/pipeline/wrapper/wrapper.py rename to src/langbot/pkg/pipeline/wrapper/wrapper.py index 6267c864..4af5fb00 100644 --- a/pkg/pipeline/wrapper/wrapper.py +++ b/src/langbot/pkg/pipeline/wrapper/wrapper.py @@ -72,7 +72,9 @@ class ResponseWrapper(stage.PipelineStage): query=query, ) - event_ctx = await self.ap.plugin_connector.emit_event(event) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) if event_ctx.is_prevented_default(): yield entities.StageProcessResult( @@ -115,7 +117,9 @@ class ResponseWrapper(stage.PipelineStage): query=query, ) - event_ctx = await self.ap.plugin_connector.emit_event(event) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) if event_ctx.is_prevented_default(): yield entities.StageProcessResult( diff --git a/pkg/platform/sources/__init__.py b/src/langbot/pkg/platform/__init__.py similarity index 100% rename from pkg/platform/sources/__init__.py rename to src/langbot/pkg/platform/__init__.py diff --git a/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py similarity index 93% rename from pkg/platform/botmgr.py rename to src/langbot/pkg/platform/botmgr.py index 78f3bd0d..4b2a568f 100644 --- a/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, @@ -157,6 +169,9 @@ class PlatformManager: self.adapter_dict = {} async def initialize(self): + # delete all bot log images + await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images') + self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter') adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {} for component in self.adapter_components: diff --git a/pkg/platform/logger.py b/src/langbot/pkg/platform/logger.py similarity index 98% rename from pkg/platform/logger.py rename to src/langbot/pkg/platform/logger.py index 05fce394..68164865 100644 --- a/pkg/platform/logger.py +++ b/src/langbot/pkg/platform/logger.py @@ -149,7 +149,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): extension = mimetypes.guess_extension(mime_type) if extension is None: extension = '.jpg' - image_key = f'{message_session_id}-{uuid.uuid4()}{extension}' + image_key = f'bot_log_images/{message_session_id}-{uuid.uuid4()}{extension}' await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes) image_keys.append(image_key) diff --git a/pkg/provider/modelmgr/__init__.py b/src/langbot/pkg/platform/sources/__init__.py similarity index 100% rename from pkg/provider/modelmgr/__init__.py rename to src/langbot/pkg/platform/sources/__init__.py diff --git a/pkg/platform/sources/aiocqhttp.py b/src/langbot/pkg/platform/sources/aiocqhttp.py similarity index 100% rename from pkg/platform/sources/aiocqhttp.py rename to src/langbot/pkg/platform/sources/aiocqhttp.py diff --git a/pkg/platform/sources/aiocqhttp.yaml b/src/langbot/pkg/platform/sources/aiocqhttp.yaml similarity index 100% rename from pkg/platform/sources/aiocqhttp.yaml rename to src/langbot/pkg/platform/sources/aiocqhttp.yaml diff --git a/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py similarity index 86% rename from pkg/platform/sources/dingtalk.py rename to src/langbot/pkg/platform/sources/dingtalk.py index d5d85997..c072a567 100644 --- a/pkg/platform/sources/dingtalk.py +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -1,13 +1,13 @@ import traceback import typing -from libs.dingtalk_api.dingtalkevent import DingTalkEvent +from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities -from libs.dingtalk_api.api import DingTalkClient +from langbot.libs.dingtalk_api.api import DingTalkClient import datetime -from ..logger import EventLogger +from langbot.pkg.platform.logger import EventLogger class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @@ -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: @@ -106,7 +119,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) def __init__(self, config: dict, logger: EventLogger): - required_keys = [ 'client_id', 'client_secret', @@ -117,13 +129,13 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if missing_keys: raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员') bot = DingTalkClient( - client_id=config['client_id'], - client_secret=config['client_secret'], - robot_name=config['robot_name'], - robot_code=config['robot_code'], - markdown_card=config['markdown_card'], - logger=logger, - ) + client_id=config['client_id'], + client_secret=config['client_secret'], + robot_name=config['robot_name'], + robot_code=config['robot_code'], + markdown_card=config['markdown_card'], + logger=logger, + ) bot_account_id = config['robot_name'] super().__init__( config=config, @@ -132,7 +144,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot_account_id=bot_account_id, bot=bot, listeners={}, - ) async def reply_message( diff --git a/pkg/platform/sources/dingtalk.svg b/src/langbot/pkg/platform/sources/dingtalk.svg similarity index 100% rename from pkg/platform/sources/dingtalk.svg rename to src/langbot/pkg/platform/sources/dingtalk.svg diff --git a/pkg/platform/sources/dingtalk.yaml b/src/langbot/pkg/platform/sources/dingtalk.yaml similarity index 100% rename from pkg/platform/sources/dingtalk.yaml rename to src/langbot/pkg/platform/sources/dingtalk.yaml diff --git a/pkg/platform/sources/discord.py b/src/langbot/pkg/platform/sources/discord.py similarity index 100% rename from pkg/platform/sources/discord.py rename to src/langbot/pkg/platform/sources/discord.py diff --git a/pkg/platform/sources/discord.svg b/src/langbot/pkg/platform/sources/discord.svg similarity index 100% rename from pkg/platform/sources/discord.svg rename to src/langbot/pkg/platform/sources/discord.svg diff --git a/pkg/platform/sources/discord.yaml b/src/langbot/pkg/platform/sources/discord.yaml similarity index 100% rename from pkg/platform/sources/discord.yaml rename to src/langbot/pkg/platform/sources/discord.yaml diff --git a/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py similarity index 99% rename from pkg/platform/sources/lark.py rename to src/langbot/pkg/platform/sources/lark.py index 23257e6f..684091a2 100644 --- a/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -627,6 +627,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): except Exception as e: raise e + async def create_message_card(self, message_id, event) -> str: """ 创建卡片消息。 diff --git a/pkg/platform/sources/lark.svg b/src/langbot/pkg/platform/sources/lark.svg similarity index 100% rename from pkg/platform/sources/lark.svg rename to src/langbot/pkg/platform/sources/lark.svg diff --git a/pkg/platform/sources/lark.yaml b/src/langbot/pkg/platform/sources/lark.yaml similarity index 100% rename from pkg/platform/sources/lark.yaml rename to src/langbot/pkg/platform/sources/lark.yaml diff --git a/pkg/platform/sources/legacy/gewechat.png b/src/langbot/pkg/platform/sources/legacy/gewechat.png similarity index 100% rename from pkg/platform/sources/legacy/gewechat.png rename to src/langbot/pkg/platform/sources/legacy/gewechat.png diff --git a/pkg/platform/sources/legacy/gewechat.py b/src/langbot/pkg/platform/sources/legacy/gewechat.py similarity index 100% rename from pkg/platform/sources/legacy/gewechat.py rename to src/langbot/pkg/platform/sources/legacy/gewechat.py diff --git a/pkg/platform/sources/legacy/gewechat.yaml b/src/langbot/pkg/platform/sources/legacy/gewechat.yaml similarity index 100% rename from pkg/platform/sources/legacy/gewechat.yaml rename to src/langbot/pkg/platform/sources/legacy/gewechat.yaml diff --git a/pkg/platform/sources/legacy/nakuru.png b/src/langbot/pkg/platform/sources/legacy/nakuru.png similarity index 100% rename from pkg/platform/sources/legacy/nakuru.png rename to src/langbot/pkg/platform/sources/legacy/nakuru.png diff --git a/pkg/platform/sources/legacy/nakuru.py b/src/langbot/pkg/platform/sources/legacy/nakuru.py similarity index 100% rename from pkg/platform/sources/legacy/nakuru.py rename to src/langbot/pkg/platform/sources/legacy/nakuru.py diff --git a/pkg/platform/sources/legacy/nakuru.yaml b/src/langbot/pkg/platform/sources/legacy/nakuru.yaml similarity index 100% rename from pkg/platform/sources/legacy/nakuru.yaml rename to src/langbot/pkg/platform/sources/legacy/nakuru.yaml diff --git a/pkg/platform/sources/legacy/qqbotpy.py b/src/langbot/pkg/platform/sources/legacy/qqbotpy.py similarity index 100% rename from pkg/platform/sources/legacy/qqbotpy.py rename to src/langbot/pkg/platform/sources/legacy/qqbotpy.py diff --git a/pkg/platform/sources/legacy/qqbotpy.svg b/src/langbot/pkg/platform/sources/legacy/qqbotpy.svg similarity index 100% rename from pkg/platform/sources/legacy/qqbotpy.svg rename to src/langbot/pkg/platform/sources/legacy/qqbotpy.svg diff --git a/pkg/platform/sources/legacy/qqbotpy.yaml b/src/langbot/pkg/platform/sources/legacy/qqbotpy.yaml similarity index 100% rename from pkg/platform/sources/legacy/qqbotpy.yaml rename to src/langbot/pkg/platform/sources/legacy/qqbotpy.yaml diff --git a/pkg/platform/sources/line.png b/src/langbot/pkg/platform/sources/line.png similarity index 100% rename from pkg/platform/sources/line.png rename to src/langbot/pkg/platform/sources/line.png diff --git a/pkg/platform/sources/line.py b/src/langbot/pkg/platform/sources/line.py similarity index 80% rename from pkg/platform/sources/line.py rename to src/langbot/pkg/platform/sources/line.py index 1cbf9850..29ab361e 100644 --- a/pkg/platform/sources/line.py +++ b/src/langbot/pkg/platform/sources/line.py @@ -3,18 +3,11 @@ import quart import traceback -import typing import asyncio -import re import base64 -import uuid -import json import datetime -import hashlib -from Crypto.Cipher import AES -from ...core import app import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events @@ -22,30 +15,15 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie from ..logger import EventLogger - -from linebot.v3 import ( - WebhookHandler -) -from linebot.v3.exceptions import ( - InvalidSignatureError -) -from linebot.v3.messaging import ( - Configuration, - ApiClient, - MessagingApi, - ReplyMessageRequest, - TextMessage, - ImageMessage -) +from linebot.v3 import WebhookHandler +from linebot.v3.exceptions import InvalidSignatureError +from linebot.v3.messaging import Configuration, ApiClient, MessagingApi, ReplyMessageRequest, TextMessage, ImageMessage from linebot.v3.webhooks import ( MessageEvent, TextMessageContent, ImageMessageContent, VideoMessageContent, AudioMessageContent, - FileMessageContent, - LocationMessageContent, - StickerMessageContent ) # from linebot import WebhookParser @@ -53,12 +31,9 @@ from linebot.v3.webhook import WebhookParser from linebot.v3.messaging import MessagingApiBlob - class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod - async def yiri2target( - message_chain: platform_message.MessageChain, api_client: ApiClient - ) -> typing.Tuple[list]: + async def yiri2target(message_chain: platform_message.MessageChain, api_client: ApiClient) -> typing.Tuple[list]: content_list = [] for component in message_chain: if isinstance(component, platform_message.At): @@ -72,15 +47,11 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter): elif isinstance(component, platform_message.Voice): content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) - return content_list @staticmethod - async def target2yiri( - message, - bot_client - ) -> platform_message.MessageChain: + async def target2yiri(message, bot_client) -> platform_message.MessageChain: lb_msg_list = [] msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000) @@ -99,8 +70,8 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter): # 如果需要Data URI格式(用于直接嵌入HTML等) # 首先需要知道图片类型,LINE图片通常是JPEG - data_uri = f"data:image/jpeg;base64,{base64_string}" - lb_msg_list.append(platform_message.Image(base64 = data_uri)) + data_uri = f'data:image/jpeg;base64,{base64_string}' + lb_msg_list.append(platform_message.Image(base64=data_uri)) return platform_message.MessageChain(lb_msg_list) @@ -112,13 +83,10 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): pass @staticmethod - async def target2yiri( - event, - bot_client - ) -> platform_events.Event: + async def target2yiri(event, bot_client) -> platform_events.Event: message_chain = await LINEMessageConverter.target2yiri(event, bot_client) - if event.source.type== 'user': + if event.source.type == 'user': return platform_events.FriendMessage( sender=platform_entities.Friend( id=event.message.id, @@ -150,6 +118,7 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): source_platform_object=event, ) + class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: MessagingApi api_client: ApiClient @@ -166,7 +135,6 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): config: dict quart_app: quart.Quart - card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片 seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识 @@ -179,22 +147,21 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot_account_id = config.get('bot_account_id', 'langbot') - super().__init__( - config = config, - logger = logger, - quart_app = quart.Quart(__name__), - listeners = {}, - card_id_dict = {}, - seq = 1, - event_converter = LINEEventConverter(), - message_converter = LINEMessageConverter(), - line_webhook = line_webhook, - parser = parser, + config=config, + logger=logger, + quart_app=quart.Quart(__name__), + listeners={}, + card_id_dict={}, + seq=1, + event_converter=LINEEventConverter(), + message_converter=LINEMessageConverter(), + line_webhook=line_webhook, + parser=parser, configuration=configuration, - api_client = api_client, - bot = MessagingApi(api_client), - bot_account_id = bot_account_id, + api_client=api_client, + bot=MessagingApi(api_client), + bot_account_id=bot_account_id, ) @self.quart_app.route('/line/callback', methods=['POST']) @@ -205,28 +172,22 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): events = parser.parse(body, signature) # 解密解析消息 try: - # print(events) lb_event = await self.event_converter.target2yiri(events[0], self.api_client) if lb_event.__class__ in self.listeners: await self.listeners[lb_event.__class__](lb_event, self) except InvalidSignatureError: - self.logger.info(f"Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}") + self.logger.info( + f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}' + ) return quart.Response('Invalid signature', status=400) - return {'code': 200, 'message': 'ok'} except Exception: await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}') return {'code': 500, 'message': 'error'} - - - - - async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): - pass async def reply_message( @@ -242,14 +203,14 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.bot.reply_message_with_http_info( ReplyMessageRequest( reply_token=message_source.source_platform_object.reply_token, - messages=[TextMessage(text=content['content'])] + messages=[TextMessage(text=content['content'])], ) ) elif content['type'] == 'image': - self.bot.reply_message_with_http_info( + self.bot.reply_message_with_http_info( ReplyMessageRequest( reply_token=message_source.source_platform_object.reply_token, - messages=[ImageMessage(text=content['content'])] + messages=[ImageMessage(text=content['content'])], ) ) @@ -259,14 +220,18 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): def register_listener( self, event_type: typing.Type[platform_events.Event], - callback: typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], ): self.listeners[event_type] = callback def unregister_listener( self, event_type: typing.Type[platform_events.Event], - callback: typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], ): self.listeners.pop(event_type) @@ -276,6 +241,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): async def shutdown_trigger_placeholder(): while True: await asyncio.sleep(1) + await self.quart_app.run_task( host='0.0.0.0', port=port, diff --git a/pkg/platform/sources/line.yaml b/src/langbot/pkg/platform/sources/line.yaml similarity index 100% rename from pkg/platform/sources/line.yaml rename to src/langbot/pkg/platform/sources/line.yaml diff --git a/pkg/platform/sources/officialaccount.png b/src/langbot/pkg/platform/sources/officialaccount.png similarity index 100% rename from pkg/platform/sources/officialaccount.png rename to src/langbot/pkg/platform/sources/officialaccount.png diff --git a/pkg/platform/sources/officialaccount.py b/src/langbot/pkg/platform/sources/officialaccount.py similarity index 100% rename from pkg/platform/sources/officialaccount.py rename to src/langbot/pkg/platform/sources/officialaccount.py diff --git a/pkg/platform/sources/officialaccount.yaml b/src/langbot/pkg/platform/sources/officialaccount.yaml similarity index 100% rename from pkg/platform/sources/officialaccount.yaml rename to src/langbot/pkg/platform/sources/officialaccount.yaml diff --git a/pkg/platform/sources/onebot.png b/src/langbot/pkg/platform/sources/onebot.png similarity index 100% rename from pkg/platform/sources/onebot.png rename to src/langbot/pkg/platform/sources/onebot.png diff --git a/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py similarity index 100% rename from pkg/platform/sources/qqofficial.py rename to src/langbot/pkg/platform/sources/qqofficial.py diff --git a/pkg/platform/sources/qqofficial.svg b/src/langbot/pkg/platform/sources/qqofficial.svg similarity index 100% rename from pkg/platform/sources/qqofficial.svg rename to src/langbot/pkg/platform/sources/qqofficial.svg diff --git a/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml similarity index 100% rename from pkg/platform/sources/qqofficial.yaml rename to src/langbot/pkg/platform/sources/qqofficial.yaml diff --git a/pkg/platform/sources/slack.png b/src/langbot/pkg/platform/sources/slack.png similarity index 100% rename from pkg/platform/sources/slack.png rename to src/langbot/pkg/platform/sources/slack.png diff --git a/pkg/platform/sources/slack.py b/src/langbot/pkg/platform/sources/slack.py similarity index 97% rename from pkg/platform/sources/slack.py rename to src/langbot/pkg/platform/sources/slack.py index 1b7937e0..1109612e 100644 --- a/pkg/platform/sources/slack.py +++ b/src/langbot/pkg/platform/sources/slack.py @@ -5,15 +5,15 @@ import traceback import datetime -from libs.slack_api.api import SlackClient +from langbot.libs.slack_api.api import SlackClient import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter -from libs.slack_api.slackevent import SlackEvent +from langbot.libs.slack_api.slackevent import SlackEvent import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.entities as platform_entities from langbot_plugin.api.entities.builtin.command import errors as command_errors -from ...utils import image -from ..logger import EventLogger +from langbot.pkg.utils import image +from langbot.pkg.platform.logger import EventLogger class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter): diff --git a/pkg/platform/sources/slack.yaml b/src/langbot/pkg/platform/sources/slack.yaml similarity index 100% rename from pkg/platform/sources/slack.yaml rename to src/langbot/pkg/platform/sources/slack.yaml diff --git a/pkg/platform/sources/telegram.py b/src/langbot/pkg/platform/sources/telegram.py similarity index 100% rename from pkg/platform/sources/telegram.py rename to src/langbot/pkg/platform/sources/telegram.py diff --git a/pkg/platform/sources/telegram.svg b/src/langbot/pkg/platform/sources/telegram.svg similarity index 100% rename from pkg/platform/sources/telegram.svg rename to src/langbot/pkg/platform/sources/telegram.svg diff --git a/pkg/platform/sources/telegram.yaml b/src/langbot/pkg/platform/sources/telegram.yaml similarity index 100% rename from pkg/platform/sources/telegram.yaml rename to src/langbot/pkg/platform/sources/telegram.yaml diff --git a/pkg/platform/sources/webchat.py b/src/langbot/pkg/platform/sources/webchat.py similarity index 100% rename from pkg/platform/sources/webchat.py rename to src/langbot/pkg/platform/sources/webchat.py diff --git a/pkg/platform/sources/webchat.yaml b/src/langbot/pkg/platform/sources/webchat.yaml similarity index 100% rename from pkg/platform/sources/webchat.yaml rename to src/langbot/pkg/platform/sources/webchat.yaml diff --git a/pkg/platform/sources/wechatpad.png b/src/langbot/pkg/platform/sources/wechatpad.png similarity index 100% rename from pkg/platform/sources/wechatpad.png rename to src/langbot/pkg/platform/sources/wechatpad.png diff --git a/pkg/platform/sources/wechatpad.py b/src/langbot/pkg/platform/sources/wechatpad.py similarity index 99% rename from pkg/platform/sources/wechatpad.py rename to src/langbot/pkg/platform/sources/wechatpad.py index 26d735ae..72609cfc 100644 --- a/pkg/platform/sources/wechatpad.py +++ b/src/langbot/pkg/platform/sources/wechatpad.py @@ -4,7 +4,7 @@ import json import time import httpx -from libs.wechatpad_api.client import WeChatPadClient +from langbot.libs.wechatpad_api.client import WeChatPadClient import typing import asyncio @@ -16,7 +16,7 @@ import threading import quart -from ..logger import EventLogger +from langbot.pkg.platform.logger import EventLogger import xml.etree.ElementTree as ET from typing import Optional, Tuple from functools import partial diff --git a/pkg/platform/sources/wechatpad.yaml b/src/langbot/pkg/platform/sources/wechatpad.yaml similarity index 100% rename from pkg/platform/sources/wechatpad.yaml rename to src/langbot/pkg/platform/sources/wechatpad.yaml diff --git a/pkg/platform/sources/wecom.png b/src/langbot/pkg/platform/sources/wecom.png similarity index 100% rename from pkg/platform/sources/wecom.png rename to src/langbot/pkg/platform/sources/wecom.png diff --git a/pkg/platform/sources/wecom.py b/src/langbot/pkg/platform/sources/wecom.py similarity index 100% rename from pkg/platform/sources/wecom.py rename to src/langbot/pkg/platform/sources/wecom.py diff --git a/pkg/platform/sources/wecom.yaml b/src/langbot/pkg/platform/sources/wecom.yaml similarity index 100% rename from pkg/platform/sources/wecom.yaml rename to src/langbot/pkg/platform/sources/wecom.yaml diff --git a/pkg/platform/sources/wecombot.png b/src/langbot/pkg/platform/sources/wecombot.png similarity index 100% rename from pkg/platform/sources/wecombot.png rename to src/langbot/pkg/platform/sources/wecombot.png diff --git a/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py similarity index 100% rename from pkg/platform/sources/wecombot.py rename to src/langbot/pkg/platform/sources/wecombot.py diff --git a/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml similarity index 100% rename from pkg/platform/sources/wecombot.yaml rename to src/langbot/pkg/platform/sources/wecombot.yaml diff --git a/pkg/platform/sources/wecomcs.py b/src/langbot/pkg/platform/sources/wecomcs.py similarity index 98% rename from pkg/platform/sources/wecomcs.py rename to src/langbot/pkg/platform/sources/wecomcs.py index 7c82bfea..7d3a6ff7 100644 --- a/pkg/platform/sources/wecomcs.py +++ b/src/langbot/pkg/platform/sources/wecomcs.py @@ -6,9 +6,9 @@ import traceback import datetime import pydantic -from libs.wecom_customer_service_api.api import WecomCSClient +from langbot.libs.wecom_customer_service_api.api import WecomCSClient import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter -from libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent +from langbot.libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events diff --git a/pkg/platform/sources/wecomcs.yaml b/src/langbot/pkg/platform/sources/wecomcs.yaml similarity index 100% rename from pkg/platform/sources/wecomcs.yaml rename to src/langbot/pkg/platform/sources/wecomcs.yaml 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/pkg/plugin/__init__.py b/src/langbot/pkg/plugin/__init__.py similarity index 100% rename from pkg/plugin/__init__.py rename to src/langbot/pkg/plugin/__init__.py diff --git a/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py similarity index 63% rename from pkg/plugin/connector.py rename to src/langbot/pkg/plugin/connector.py index 5a979474..25223528 100644 --- a/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -6,19 +6,24 @@ from typing import Any import typing import os import sys - +import httpx from async_lru import alru_cache from ..core import app from . import handler from ..utils import platform -from langbot_plugin.runtime.io.controllers.stdio import client as stdio_client_controller +from langbot_plugin.runtime.io.controllers.stdio import ( + client as stdio_client_controller, +) from langbot_plugin.runtime.io.controllers.ws import client as ws_client_controller from langbot_plugin.api.entities import events from langbot_plugin.api.entities import context import langbot_plugin.runtime.io.connection as base_connection from langbot_plugin.api.definition.components.manifest import ComponentManifest -from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors +from langbot_plugin.api.entities.builtin.command import ( + context as command_context, + errors as command_errors, +) from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from ..core import taskmgr @@ -38,6 +43,10 @@ class PluginRuntimeConnector: ctrl: stdio_client_controller.StdioClientController | ws_client_controller.WebSocketClientController + runtime_subprocess_on_windows: asyncio.subprocess.Process | None = None + + runtime_subprocess_on_windows_task: asyncio.Task | None = None + runtime_disconnect_callback: typing.Callable[ [PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None] ] @@ -58,7 +67,7 @@ class PluginRuntimeConnector: async def heartbeat_loop(self): while True: - await asyncio.sleep(10) + await asyncio.sleep(20) try: await self.ping_plugin_runtime() self.ap.logger.debug('Heartbeat to plugin runtime success.') @@ -71,7 +80,9 @@ class PluginRuntimeConnector: return async def new_connection_callback(connection: base_connection.Connection): - async def disconnect_callback(rchandler: handler.RuntimeConnectionHandler) -> bool: + async def disconnect_callback( + rchandler: handler.RuntimeConnectionHandler, + ) -> bool: if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime(): self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...') await self.runtime_disconnect_callback(self) @@ -98,7 +109,8 @@ class PluginRuntimeConnector: ) async def make_connection_failed_callback( - ctrl: ws_client_controller.WebSocketClientController, exc: Exception = None + ctrl: ws_client_controller.WebSocketClientController, + exc: Exception = None, ) -> None: if exc is not None: self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}') @@ -111,6 +123,46 @@ class PluginRuntimeConnector: make_connection_failed_callback=make_connection_failed_callback, ) task = self.ctrl.run(new_connection_callback) + elif platform.get_platform() == 'win32': + # Due to Windows's lack of supports for both stdio and subprocess: + # See also: https://docs.python.org/zh-cn/3.13/library/asyncio-platforms.html + # We have to launch runtime via cmd but communicate via ws. + self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws') + + if self.runtime_subprocess_on_windows is None: # only launch once + python_path = sys.executable + env = os.environ.copy() + self.runtime_subprocess_on_windows = await asyncio.create_subprocess_exec( + python_path, + '-m', + 'langbot_plugin.cli.__init__', + 'rt', + env=env, + ) + + # hold the process + self.runtime_subprocess_on_windows_task = asyncio.create_task(self.runtime_subprocess_on_windows.wait()) + + ws_url = 'ws://localhost:5400/control/ws' + + async def make_connection_failed_callback( + ctrl: ws_client_controller.WebSocketClientController, + exc: Exception = None, + ) -> None: + if exc is not None: + self.ap.logger.error(f'(windows) Failed to connect to plugin runtime({ws_url}): {exc}') + else: + self.ap.logger.error( + f'(windows) Failed to connect to plugin runtime({ws_url}), trying to reconnect...' + ) + await self.runtime_disconnect_callback(self) + + self.ctrl = ws_client_controller.WebSocketClientController( + ws_url=ws_url, + make_connection_failed_callback=make_connection_failed_callback, + ) + task = self.ctrl.run(new_connection_callback) + else: # stdio self.ap.logger.info('use stdio to connect to plugin runtime') # cmd: lbp rt -s @@ -150,6 +202,25 @@ class PluginRuntimeConnector: install_info['plugin_file_key'] = file_key del install_info['plugin_file'] self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') + elif install_source == PluginInstallSource.GITHUB: + # download and transfer file + try: + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=20, + ) as client: + response = await client.get( + install_info['asset_url'], + ) + response.raise_for_status() + file_bytes = response.content + file_key = await self.handler.send_file(file_bytes, 'lbpkg') + install_info['plugin_file_key'] = file_key + self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') + except Exception as e: + self.ap.logger.error(f'Failed to download file from GitHub: {e}') + raise Exception(f'Failed to download file from GitHub: {e}') async for ret in self.handler.install_plugin(install_source.value, install_info): current_action = ret.get('current_action', None) @@ -163,7 +234,10 @@ class PluginRuntimeConnector: task_context.trace(trace) async def upgrade_plugin( - self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None + self, + plugin_author: str, + plugin_name: str, + task_context: taskmgr.TaskContext | None = None, ) -> dict[str, Any]: async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name): current_action = ret.get('current_action', None) @@ -177,7 +251,11 @@ class PluginRuntimeConnector: task_context.trace(trace) async def delete_plugin( - self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None + self, + plugin_author: str, + plugin_name: str, + delete_data: bool = False, + task_context: taskmgr.TaskContext | None = None, ) -> dict[str, Any]: async for ret in self.handler.delete_plugin(plugin_author, plugin_name): current_action = ret.get('current_action', None) @@ -190,6 +268,12 @@ class PluginRuntimeConnector: if task_context is not None: task_context.trace(trace) + # Clean up plugin settings and binary storage if requested + if delete_data: + if task_context is not None: + task_context.trace('Cleaning up plugin configuration and storage...') + await self.handler.cleanup_plugin_data(plugin_author, plugin_name) + async def list_plugins(self) -> list[dict[str, Any]]: if not self.is_enable_plugin: return [] @@ -209,47 +293,62 @@ class PluginRuntimeConnector: async def emit_event( self, event: events.BaseEventModel, + bound_plugins: list[str] | None = None, ) -> context.EventContext: event_ctx = context.EventContext.from_event(event) if not self.is_enable_plugin: return event_ctx - event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=False)) + # Pass include_plugins to runtime for filtering + event_ctx_result = await self.handler.emit_event( + event_ctx.model_dump(serialize_as_any=False), include_plugins=bound_plugins + ) event_ctx = context.EventContext.model_validate(event_ctx_result['event_context']) return event_ctx - async def list_tools(self) -> list[ComponentManifest]: + async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]: if not self.is_enable_plugin: return [] - list_tools_data = await self.handler.list_tools() + # Pass include_plugins to runtime for filtering + list_tools_data = await self.handler.list_tools(include_plugins=bound_plugins) - return [ComponentManifest.model_validate(tool) for tool in list_tools_data] + tools = [ComponentManifest.model_validate(tool) for tool in list_tools_data] - async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]: + return tools + + async def call_tool( + self, tool_name: str, parameters: dict[str, Any], bound_plugins: list[str] | None = None + ) -> dict[str, Any]: if not self.is_enable_plugin: return {'error': 'Tool not found: plugin system is disabled'} - return await self.handler.call_tool(tool_name, parameters) + # Pass include_plugins to runtime for validation + return await self.handler.call_tool(tool_name, parameters, include_plugins=bound_plugins) - async def list_commands(self) -> list[ComponentManifest]: + async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]: if not self.is_enable_plugin: return [] - list_commands_data = await self.handler.list_commands() + # Pass include_plugins to runtime for filtering + list_commands_data = await self.handler.list_commands(include_plugins=bound_plugins) - return [ComponentManifest.model_validate(command) for command in list_commands_data] + commands = [ComponentManifest.model_validate(command) for command in list_commands_data] + + return commands async def execute_command( - self, command_ctx: command_context.ExecuteContext + self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: if not self.is_enable_plugin: yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command)) + return - gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True)) + # Pass include_plugins to runtime for validation + gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True), include_plugins=bound_plugins) async for ret in gen: cmd_ret = command_context.CommandReturn.model_validate(ret) @@ -257,6 +356,9 @@ class PluginRuntimeConnector: yield cmd_ret def dispose(self): + # No need to consider the shutdown on Windows + # for Windows can kill processes and subprocesses chainly + if self.is_enable_plugin and isinstance(self.ctrl, stdio_client_controller.StdioClientController): self.ap.logger.info('Terminating plugin runtime process...') self.ctrl.process.terminate() diff --git a/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py similarity index 87% rename from pkg/plugin/handler.py rename to src/langbot/pkg/plugin/handler.py index b138fd42..337539f5 100644 --- a/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -56,7 +56,9 @@ class RuntimeConnectionHandler(handler.Handler): .where(persistence_plugin.PluginSetting.plugin_name == plugin_name) ) - if result.first() is not None: + setting = result.first() + + if setting is not None: # delete plugin setting await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_plugin.PluginSetting) @@ -71,6 +73,10 @@ class RuntimeConnectionHandler(handler.Handler): plugin_name=plugin_name, install_source=install_source, install_info=install_info, + # inherit from existing setting + enabled=setting.enabled if setting is not None else True, + priority=setting.priority if setting is not None else 0, + config=setting.config if setting is not None else {}, # noqa: F821 ) ) @@ -292,7 +298,7 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(PluginToRuntimeAction.GET_LLM_MODELS) async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse: """Get llm models""" - llm_models = await self.ap.model_service.get_llm_models(include_secret=False) + llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False) return handler.ActionResponse.success( data={ 'llm_models': llm_models, @@ -430,6 +436,25 @@ class RuntimeConnectionHandler(handler.Handler): }, ) + @self.action(RuntimeToLangBotAction.GET_CONFIG_FILE) + async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse: + """Get a config file by file key""" + file_key = data['file_key'] + + try: + # Load file from storage + file_bytes = await self.ap.storage_mgr.storage_provider.load(file_key) + + return handler.ActionResponse.success( + data={ + 'file_base64': base64.b64encode(file_bytes).decode('utf-8'), + }, + ) + except Exception as e: + return handler.ActionResponse.error( + message=f'Failed to load config file {file_key}: {e}', + ) + async def ping(self) -> dict[str, Any]: """Ping the runtime""" return await self.call_action( @@ -529,23 +554,27 @@ class RuntimeConnectionHandler(handler.Handler): async def emit_event( self, event_context: dict[str, Any], + include_plugins: list[str] | None = None, ) -> dict[str, Any]: """Emit event""" result = await self.call_action( LangBotToRuntimeAction.EMIT_EVENT, { 'event_context': event_context, + 'include_plugins': include_plugins, }, timeout=60, ) return result - async def list_tools(self) -> list[dict[str, Any]]: + async def list_tools(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]: """List tools""" result = await self.call_action( LangBotToRuntimeAction.LIST_TOOLS, - {}, + { + 'include_plugins': include_plugins, + }, timeout=20, ) @@ -573,34 +602,59 @@ class RuntimeConnectionHandler(handler.Handler): 'mime_type': mime_type, } - async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]: + async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None: + """Cleanup plugin settings and binary storage""" + # Delete plugin settings + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_plugin.PluginSetting) + .where(persistence_plugin.PluginSetting.plugin_author == plugin_author) + .where(persistence_plugin.PluginSetting.plugin_name == plugin_name) + ) + + # Delete all binary storage for this plugin + owner = f'{plugin_author}/{plugin_name}' + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_bstorage.BinaryStorage) + .where(persistence_bstorage.BinaryStorage.owner_type == 'plugin') + .where(persistence_bstorage.BinaryStorage.owner == owner) + ) + + async def call_tool( + self, tool_name: str, parameters: dict[str, Any], include_plugins: list[str] | None = None + ) -> dict[str, Any]: """Call tool""" result = await self.call_action( LangBotToRuntimeAction.CALL_TOOL, { 'tool_name': tool_name, 'tool_parameters': parameters, + 'include_plugins': include_plugins, }, timeout=60, ) return result['tool_response'] - async def list_commands(self) -> list[dict[str, Any]]: + async def list_commands(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]: """List commands""" result = await self.call_action( LangBotToRuntimeAction.LIST_COMMANDS, - {}, + { + 'include_plugins': include_plugins, + }, timeout=10, ) return result['commands'] - async def execute_command(self, command_context: dict[str, Any]) -> typing.AsyncGenerator[dict[str, Any], None]: + async def execute_command( + self, command_context: dict[str, Any], include_plugins: list[str] | None = None + ) -> typing.AsyncGenerator[dict[str, Any], None]: """Execute command""" gen = self.call_action_generator( LangBotToRuntimeAction.EXECUTE_COMMAND, { 'command_context': command_context, + 'include_plugins': include_plugins, }, timeout=60, ) diff --git a/pkg/provider/__init__.py b/src/langbot/pkg/provider/__init__.py similarity index 100% rename from pkg/provider/__init__.py rename to src/langbot/pkg/provider/__init__.py diff --git a/pkg/provider/modelmgr/requesters/__init__.py b/src/langbot/pkg/provider/modelmgr/__init__.py similarity index 100% rename from pkg/provider/modelmgr/requesters/__init__.py rename to src/langbot/pkg/provider/modelmgr/__init__.py diff --git a/pkg/provider/modelmgr/entities.py b/src/langbot/pkg/provider/modelmgr/entities.py similarity index 100% rename from pkg/provider/modelmgr/entities.py rename to src/langbot/pkg/provider/modelmgr/entities.py diff --git a/pkg/provider/modelmgr/errors.py b/src/langbot/pkg/provider/modelmgr/errors.py similarity index 100% rename from pkg/provider/modelmgr/errors.py rename to src/langbot/pkg/provider/modelmgr/errors.py diff --git a/pkg/provider/modelmgr/modelmgr.py b/src/langbot/pkg/provider/modelmgr/modelmgr.py similarity index 91% rename from pkg/provider/modelmgr/modelmgr.py rename to src/langbot/pkg/provider/modelmgr/modelmgr.py index d649b41e..f0bec0a5 100644 --- a/pkg/provider/modelmgr/modelmgr.py +++ b/src/langbot/pkg/provider/modelmgr/modelmgr.py @@ -59,7 +59,7 @@ class ModelManager: try: await self.load_llm_model(llm_model) except provider_errors.RequesterNotFoundError as e: - self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping model {llm_model.uuid}') + self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping llm model {llm_model.uuid}') except Exception as e: self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}') @@ -67,7 +67,14 @@ class ModelManager: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel)) embedding_models = result.all() for embedding_model in embedding_models: - await self.load_embedding_model(embedding_model) + try: + await self.load_embedding_model(embedding_model) + except provider_errors.RequesterNotFoundError as e: + self.ap.logger.warning( + f'Requester {e.requester_name} not found, skipping embedding model {embedding_model.uuid}' + ) + except Exception as e: + self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}') async def init_runtime_llm_model( self, @@ -107,6 +114,9 @@ class ModelManager: elif isinstance(model_info, dict): model_info = persistence_model.EmbeddingModel(**model_info) + if model_info.requester not in self.requester_dict: + raise provider_errors.RequesterNotFoundError(model_info.requester) + requester_inst = self.requester_dict[model_info.requester](ap=self.ap, config=model_info.requester_config) await requester_inst.initialize() diff --git a/pkg/provider/modelmgr/requester.py b/src/langbot/pkg/provider/modelmgr/requester.py similarity index 100% rename from pkg/provider/modelmgr/requester.py rename to src/langbot/pkg/provider/modelmgr/requester.py diff --git a/pkg/provider/modelmgr/requester.yaml b/src/langbot/pkg/provider/modelmgr/requester.yaml similarity index 100% rename from pkg/provider/modelmgr/requester.yaml rename to src/langbot/pkg/provider/modelmgr/requester.yaml diff --git a/pkg/provider/modelmgr/requesters/302ai.png b/src/langbot/pkg/provider/modelmgr/requesters/302ai.png similarity index 100% rename from pkg/provider/modelmgr/requesters/302ai.png rename to src/langbot/pkg/provider/modelmgr/requesters/302ai.png diff --git a/pkg/provider/modelmgr/requesters/302aichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/302aichatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml new file mode 100644 index 00000000..4fc22be4 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: 302-ai-chat-completions + label: + en_US: 302.AI + zh_Hans: 302.AI + 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 + support_type: + - llm + - text-embedding + provider_category: maas +execution: + python: + path: ./302aichatcmpl.py + attr: AI302ChatCompletions diff --git a/pkg/provider/runners/__init__.py b/src/langbot/pkg/provider/modelmgr/requesters/__init__.py similarity index 100% rename from pkg/provider/runners/__init__.py rename to src/langbot/pkg/provider/modelmgr/requesters/__init__.py diff --git a/pkg/provider/modelmgr/requesters/anthropic.svg b/src/langbot/pkg/provider/modelmgr/requesters/anthropic.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/anthropic.svg rename to src/langbot/pkg/provider/modelmgr/requesters/anthropic.svg diff --git a/pkg/provider/modelmgr/requesters/anthropicmsgs.py b/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.py similarity index 100% rename from pkg/provider/modelmgr/requesters/anthropicmsgs.py rename to src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml b/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml new file mode 100644 index 00000000..0ef60d3e --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/anthropicmsgs.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: anthropic-messages + label: + en_US: Anthropic + zh_Hans: Anthropic + 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 + support_type: + - llm + provider_category: manufacturer +execution: + python: + path: ./anthropicmsgs.py + attr: AnthropicMessages diff --git a/pkg/provider/modelmgr/requesters/bailian.png b/src/langbot/pkg/provider/modelmgr/requesters/bailian.png similarity index 100% rename from pkg/provider/modelmgr/requesters/bailian.png rename to src/langbot/pkg/provider/modelmgr/requesters/bailian.png diff --git a/pkg/provider/modelmgr/requesters/bailianchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.py similarity index 88% rename from pkg/provider/modelmgr/requesters/bailianchatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.py index adeaf17f..c60165bb 100644 --- a/pkg/provider/modelmgr/requesters/bailianchatcmpl.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.py @@ -44,10 +44,10 @@ class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions): # 设置此次请求中的messages messages = req_messages.copy() - is_use_dashscope_call = False # 是否使用阿里原生库调用 + is_use_dashscope_call = False # 是否使用阿里原生库调用 is_enable_multi_model = True # 是否支持多轮对话 - use_time_num = 0 # 模型已调用次数,防止存在多文件时重复调用 - use_time_ids = [] # 已调用的ID列表 + use_time_num = 0 # 模型已调用次数,防止存在多文件时重复调用 + use_time_ids = [] # 已调用的ID列表 message_id = 0 # 记录消息序号 for msg in messages: @@ -67,20 +67,32 @@ class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions): me['video_url'] = {'url': me['file_url']} del me['file_url'] del me['file_name'] - use_time_num +=1 + use_time_num += 1 use_time_ids.append(message_id) is_enable_multi_model = False # 2. 语音文件识别, 无法通过openai的audio字段传递,暂时不支持 # https://bailian.console.aliyun.com/?tab=doc#/doc/?type=model&url=2979031 - elif file_type in ['aac', 'amr', 'aiff', 'flac', 'm4a', - 'mp3', 'mpeg', 'ogg', 'opus', 'wav', 'webm', 'wma']: + elif file_type in [ + 'aac', + 'amr', + 'aiff', + 'flac', + 'm4a', + 'mp3', + 'mpeg', + 'ogg', + 'opus', + 'wav', + 'webm', + 'wma', + ]: me['audio'] = me['file_url'] me['type'] = 'audio' del me['file_url'] del me['type'] del me['file_name'] is_use_dashscope_call = True - use_time_num +=1 + use_time_num += 1 use_time_ids.append(message_id) is_enable_multi_model = False message_id += 1 @@ -108,26 +120,26 @@ class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions): api_key=use_model.token_mgr.get_token(), model=use_model.model_entity.name, messages=messages, - result_format="message", + result_format='message', asr_options={ # "language": "zh", # 可选,若已知音频的语种,可通过该参数指定待识别语种,以提升识别准确率 - "enable_lid": True, - "enable_itn": False + 'enable_lid': True, + 'enable_itn': False, }, - stream=True + stream=True, ) content_length_list = [] previous_length = 0 # 记录上一次的内容长度 for res in response: - chunk = res["output"] + chunk = res['output'] # 解析 chunk 数据 if hasattr(chunk, 'choices') and chunk.choices: choice = chunk.choices[0] - delta_content = choice["message"].content[0]["text"] - finish_reason = choice["finish_reason"] + delta_content = choice['message'].content[0]['text'] + finish_reason = choice['finish_reason'] content_length_list.append(len(delta_content)) else: - delta_content = "" + delta_content = '' finish_reason = None # 跳过空的第一个 chunk(只有 role 没有内容) @@ -137,7 +149,7 @@ class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions): # 检查 content_length_list 是否有足够的数据 if len(content_length_list) >= 2: - now_content = delta_content[previous_length: content_length_list[-1]] + now_content = delta_content[previous_length : content_length_list[-1]] previous_length = content_length_list[-1] # 更新上一次的长度 else: now_content = delta_content # 第一次循环时直接使用 delta_content @@ -147,7 +159,7 @@ class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions): chunk_data = { 'role': role, 'content': now_content if now_content else None, - 'is_final': bool(finish_reason) and finish_reason != "null", + 'is_final': bool(finish_reason) and finish_reason != 'null', } # 移除 None 值 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml new file mode 100644 index 00000000..7c405232 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/bailianchatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: bailian-chat-completions + label: + en_US: Aliyun Bailian + zh_Hans: 阿里云百炼 + 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 + support_type: + - llm + provider_category: maas +execution: + python: + path: ./bailianchatcmpl.py + attr: BailianChatCompletions diff --git a/pkg/provider/modelmgr/requesters/chatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/chatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml new file mode 100644 index 00000000..4f588fb2 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: openai-chat-completions + label: + en_US: OpenAI + zh_Hans: OpenAI + 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 + support_type: + - llm + - text-embedding + provider_category: manufacturer +execution: + python: + path: ./chatcmpl.py + attr: OpenAIChatCompletions diff --git a/pkg/provider/modelmgr/requesters/compshare.png b/src/langbot/pkg/provider/modelmgr/requesters/compshare.png similarity index 100% rename from pkg/provider/modelmgr/requesters/compshare.png rename to src/langbot/pkg/provider/modelmgr/requesters/compshare.png diff --git a/pkg/provider/modelmgr/requesters/compsharechatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/compsharechatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml new file mode 100644 index 00000000..92fcafdc --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: compshare-chat-completions + label: + en_US: CompShare + zh_Hans: 优云智算 + 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 + support_type: + - llm + provider_category: maas +execution: + python: + path: ./compsharechatcmpl.py + attr: CompShareChatCompletions diff --git a/pkg/provider/modelmgr/requesters/deepseek.svg b/src/langbot/pkg/provider/modelmgr/requesters/deepseek.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/deepseek.svg rename to src/langbot/pkg/provider/modelmgr/requesters/deepseek.svg diff --git a/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/deepseekchatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml new file mode 100644 index 00000000..8ef1fcf9 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/deepseekchatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: deepseek-chat-completions + label: + en_US: DeepSeek + zh_Hans: DeepSeek + 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 + support_type: + - llm + provider_category: manufacturer +execution: + python: + path: ./deepseekchatcmpl.py + attr: DeepseekChatCompletions diff --git a/pkg/provider/modelmgr/requesters/gemini.svg b/src/langbot/pkg/provider/modelmgr/requesters/gemini.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/gemini.svg rename to src/langbot/pkg/provider/modelmgr/requesters/gemini.svg diff --git a/pkg/provider/modelmgr/requesters/geminichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/geminichatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml new file mode 100644 index 00000000..fdebe9b9 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/geminichatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: gemini-chat-completions + label: + en_US: Google Gemini + zh_Hans: Google Gemini + 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 + support_type: + - llm + provider_category: manufacturer +execution: + python: + path: ./geminichatcmpl.py + attr: GeminiChatCompletions diff --git a/pkg/provider/modelmgr/requesters/giteeai.svg b/src/langbot/pkg/provider/modelmgr/requesters/giteeai.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/giteeai.svg rename to src/langbot/pkg/provider/modelmgr/requesters/giteeai.svg diff --git a/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/giteeaichatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml new file mode 100644 index 00000000..e818bd7a --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/giteeaichatcmpl.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: gitee-ai-chat-completions + label: + en_US: Gitee AI + zh_Hans: Gitee AI + 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 + support_type: + - llm + - text-embedding + provider_category: maas +execution: + python: + path: ./giteeaichatcmpl.py + 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/pkg/provider/modelmgr/requesters/lmstudio.webp b/src/langbot/pkg/provider/modelmgr/requesters/lmstudio.webp similarity index 100% rename from pkg/provider/modelmgr/requesters/lmstudio.webp rename to src/langbot/pkg/provider/modelmgr/requesters/lmstudio.webp diff --git a/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/lmstudiochatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml new file mode 100644 index 00000000..81dc82cf --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/lmstudiochatcmpl.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: lmstudio-chat-completions + label: + en_US: LM Studio + zh_Hans: LM Studio + 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 + support_type: + - llm + - text-embedding + provider_category: self-hosted +execution: + python: + path: ./lmstudiochatcmpl.py + attr: LmStudioChatCompletions diff --git a/pkg/provider/modelmgr/requesters/modelscope.svg b/src/langbot/pkg/provider/modelmgr/requesters/modelscope.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/modelscope.svg rename to src/langbot/pkg/provider/modelmgr/requesters/modelscope.svg diff --git a/pkg/provider/modelmgr/requesters/modelscopechatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/modelscopechatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml new file mode 100644 index 00000000..8d22002d --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml @@ -0,0 +1,38 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: modelscope-chat-completions + label: + en_US: ModelScope + zh_Hans: 魔搭社区 + 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 + support_type: + - llm + provider_category: maas +execution: + python: + path: ./modelscopechatcmpl.py + attr: ModelScopeChatCompletions diff --git a/pkg/provider/modelmgr/requesters/moonshot.png b/src/langbot/pkg/provider/modelmgr/requesters/moonshot.png similarity index 100% rename from pkg/provider/modelmgr/requesters/moonshot.png rename to src/langbot/pkg/provider/modelmgr/requesters/moonshot.png diff --git a/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/moonshotchatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml new file mode 100644 index 00000000..7a7e3060 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/moonshotchatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: moonshot-chat-completions + label: + en_US: Moonshot + zh_Hans: 月之暗面 + 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 + support_type: + - llm + provider_category: manufacturer +execution: + python: + path: ./moonshotchatcmpl.py + attr: MoonshotChatCompletions diff --git a/pkg/provider/modelmgr/requesters/newapi.png b/src/langbot/pkg/provider/modelmgr/requesters/newapi.png similarity index 100% rename from pkg/provider/modelmgr/requesters/newapi.png rename to src/langbot/pkg/provider/modelmgr/requesters/newapi.png diff --git a/pkg/provider/modelmgr/requesters/newapichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/newapichatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml new file mode 100644 index 00000000..e0f44e99 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/newapichatcmpl.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: new-api-chat-completions + label: + en_US: New API + zh_Hans: New API + 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 + support_type: + - llm + - text-embedding + provider_category: maas +execution: + python: + path: ./newapichatcmpl.py + attr: NewAPIChatCompletions diff --git a/pkg/provider/modelmgr/requesters/ollama.svg b/src/langbot/pkg/provider/modelmgr/requesters/ollama.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/ollama.svg rename to src/langbot/pkg/provider/modelmgr/requesters/ollama.svg diff --git a/pkg/provider/modelmgr/requesters/ollamachat.py b/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.py similarity index 100% rename from pkg/provider/modelmgr/requesters/ollamachat.py rename to src/langbot/pkg/provider/modelmgr/requesters/ollamachat.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml b/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml new file mode 100644 index 00000000..a724f8f8 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/ollamachat.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: ollama-chat + label: + en_US: Ollama + zh_Hans: Ollama + 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 + support_type: + - llm + - text-embedding + provider_category: self-hosted +execution: + python: + path: ./ollamachat.py + attr: OllamaChatCompletions diff --git a/pkg/provider/modelmgr/requesters/openai.svg b/src/langbot/pkg/provider/modelmgr/requesters/openai.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/openai.svg rename to src/langbot/pkg/provider/modelmgr/requesters/openai.svg diff --git a/pkg/provider/modelmgr/requesters/openrouter.svg b/src/langbot/pkg/provider/modelmgr/requesters/openrouter.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/openrouter.svg rename to src/langbot/pkg/provider/modelmgr/requesters/openrouter.svg diff --git a/pkg/provider/modelmgr/requesters/openrouterchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/openrouterchatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml new file mode 100644 index 00000000..f1603200 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: openrouter-chat-completions + label: + en_US: OpenRouter + zh_Hans: OpenRouter + 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 + support_type: + - llm + - text-embedding + provider_category: maas +execution: + python: + path: ./openrouterchatcmpl.py + attr: OpenRouterChatCompletions diff --git a/pkg/provider/modelmgr/requesters/ppio.svg b/src/langbot/pkg/provider/modelmgr/requesters/ppio.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/ppio.svg rename to src/langbot/pkg/provider/modelmgr/requesters/ppio.svg diff --git a/pkg/provider/modelmgr/requesters/ppiochatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/ppiochatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml new file mode 100644 index 00000000..9e8eb1b0 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: ppio-chat-completions + label: + 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 + support_type: + - llm + - text-embedding + provider_category: maas +execution: + python: + path: ./ppiochatcmpl.py + attr: PPIOChatCompletions diff --git a/pkg/provider/modelmgr/requesters/qhaigc.png b/src/langbot/pkg/provider/modelmgr/requesters/qhaigc.png similarity index 100% rename from pkg/provider/modelmgr/requesters/qhaigc.png rename to src/langbot/pkg/provider/modelmgr/requesters/qhaigc.png diff --git a/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/qhaigcchatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml new file mode 100644 index 00000000..46ae1fad --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/qhaigcchatcmpl.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: qhaigc-chat-completions + label: + en_US: QH AI + zh_Hans: 启航 AI + 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 + support_type: + - llm + - text-embedding + provider_category: maas +execution: + python: + path: ./qhaigcchatcmpl.py + attr: QHAIGCChatCompletions diff --git a/pkg/provider/modelmgr/requesters/shengsuanyun.py b/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.py similarity index 100% rename from pkg/provider/modelmgr/requesters/shengsuanyun.py rename to src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.py diff --git a/pkg/provider/modelmgr/requesters/shengsuanyun.svg b/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/shengsuanyun.svg rename to src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.svg diff --git a/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml b/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml new file mode 100644 index 00000000..77cf682c --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/shengsuanyun.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: shengsuanyun-chat-completions + label: + en_US: ShengSuanYun + zh_Hans: 胜算云 + 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 + support_type: + - llm + - text-embedding + provider_category: maas +execution: + python: + path: ./shengsuanyun.py + attr: ShengSuanYunChatCompletions diff --git a/pkg/provider/modelmgr/requesters/siliconflow.svg b/src/langbot/pkg/provider/modelmgr/requesters/siliconflow.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/siliconflow.svg rename to src/langbot/pkg/provider/modelmgr/requesters/siliconflow.svg diff --git a/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/siliconflowchatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml new file mode 100644 index 00000000..28d3314a --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/siliconflowchatcmpl.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: siliconflow-chat-completions + label: + en_US: SiliconFlow + zh_Hans: 硅基流动 + 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 + support_type: + - llm + - text-embedding + provider_category: maas +execution: + python: + path: ./siliconflowchatcmpl.py + attr: SiliconFlowChatCompletions diff --git a/pkg/provider/modelmgr/requesters/tokenpony.svg b/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/tokenpony.svg rename to src/langbot/pkg/provider/modelmgr/requesters/tokenpony.svg diff --git a/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml b/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml new file mode 100644 index 00000000..f160bdea --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/tokenpony.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: tokenpony-chat-completions + label: + en_US: TokenPony + zh_Hans: 小马算力 + 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 + support_type: + - llm + - text-embedding + provider_category: maas +execution: + python: + path: ./tokenponychatcmpl.py + attr: TokenPonyChatCompletions diff --git a/pkg/provider/modelmgr/requesters/tokenponychatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/tokenponychatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/tokenponychatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/tokenponychatcmpl.py diff --git a/pkg/provider/modelmgr/requesters/volcark.svg b/src/langbot/pkg/provider/modelmgr/requesters/volcark.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/volcark.svg rename to src/langbot/pkg/provider/modelmgr/requesters/volcark.svg diff --git a/pkg/provider/modelmgr/requesters/volcarkchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/volcarkchatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml new file mode 100644 index 00000000..e5c82657 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/volcarkchatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: volcark-chat-completions + label: + en_US: Volc Engine Ark + zh_Hans: 火山方舟 + 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 + support_type: + - llm + provider_category: maas +execution: + python: + path: ./volcarkchatcmpl.py + attr: VolcArkChatCompletions diff --git a/pkg/provider/modelmgr/requesters/xai.svg b/src/langbot/pkg/provider/modelmgr/requesters/xai.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/xai.svg rename to src/langbot/pkg/provider/modelmgr/requesters/xai.svg diff --git a/pkg/provider/modelmgr/requesters/xaichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/xaichatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml new file mode 100644 index 00000000..2e721d70 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/xaichatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: xai-chat-completions + label: + en_US: xAI + zh_Hans: xAI + 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 + support_type: + - llm + provider_category: manufacturer +execution: + python: + path: ./xaichatcmpl.py + attr: XaiChatCompletions diff --git a/pkg/provider/modelmgr/requesters/zhipuai.svg b/src/langbot/pkg/provider/modelmgr/requesters/zhipuai.svg similarity index 100% rename from pkg/provider/modelmgr/requesters/zhipuai.svg rename to src/langbot/pkg/provider/modelmgr/requesters/zhipuai.svg diff --git a/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.py similarity index 100% rename from pkg/provider/modelmgr/requesters/zhipuaichatcmpl.py rename to src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.py diff --git a/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml new file mode 100644 index 00000000..a4ebb2ec --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/zhipuaichatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: zhipuai-chat-completions + label: + en_US: ZhipuAI + zh_Hans: 智谱 AI + 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 + support_type: + - llm + provider_category: manufacturer +execution: + python: + path: ./zhipuaichatcmpl.py + attr: ZhipuAIChatCompletions diff --git a/pkg/provider/modelmgr/token.py b/src/langbot/pkg/provider/modelmgr/token.py similarity index 100% rename from pkg/provider/modelmgr/token.py rename to src/langbot/pkg/provider/modelmgr/token.py diff --git a/pkg/provider/runner.py b/src/langbot/pkg/provider/runner.py similarity index 70% rename from pkg/provider/runner.py rename to src/langbot/pkg/provider/runner.py index 83acfe13..f89c079d 100644 --- a/pkg/provider/runner.py +++ b/src/langbot/pkg/provider/runner.py @@ -4,8 +4,6 @@ import abc import typing from ..core import app -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query preregistered_runners: list[typing.Type[RequestRunner]] = [] @@ -36,6 +34,8 @@ class RequestRunner(abc.ABC): self.pipeline_config = pipeline_config @abc.abstractmethod - async def run(self, query: core_entities.Query) -> typing.AsyncGenerator[llm_entities.Message | llm_entities.MessageChunk, None]: + async def run( + self, query: core_entities.Query + ) -> typing.AsyncGenerator[llm_entities.Message | llm_entities.MessageChunk, None]: """运行请求""" - pass \ No newline at end of file + pass diff --git a/pkg/provider/session/__init__.py b/src/langbot/pkg/provider/runners/__init__.py similarity index 100% rename from pkg/provider/session/__init__.py rename to src/langbot/pkg/provider/runners/__init__.py diff --git a/pkg/provider/runners/cozeapi.py b/src/langbot/pkg/provider/runners/cozeapi.py similarity index 76% rename from pkg/provider/runners/cozeapi.py rename to src/langbot/pkg/provider/runners/cozeapi.py index 6d4f02a1..26980f81 100644 --- a/pkg/provider/runners/cozeapi.py +++ b/src/langbot/pkg/provider/runners/cozeapi.py @@ -2,15 +2,15 @@ from __future__ import annotations import typing import json -import uuid import base64 -from .. import runner -from ...core import app +from langbot.pkg.provider import runner +from langbot.pkg.core import app import langbot_plugin.api.entities.builtin.provider.message as provider_message -from ...utils import image +from langbot.pkg.utils import image import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from libs.coze_server_api.client import AsyncCozeAPIClient +from langbot.libs.coze_server_api.client import AsyncCozeAPIClient + @runner.runner_class('coze-api') class CozeAPIRunner(runner.RequestRunner): @@ -19,17 +19,14 @@ class CozeAPIRunner(runner.RequestRunner): def __init__(self, ap: app.Application, pipeline_config: dict): self.pipeline_config = pipeline_config self.ap = ap - self.agent_token = pipeline_config["ai"]['coze-api']['api-key'] - self.bot_id = pipeline_config["ai"]['coze-api'].get('bot-id') - self.chat_timeout = pipeline_config["ai"]['coze-api'].get('timeout') - self.auto_save_history = pipeline_config["ai"]['coze-api'].get('auto_save_history') - self.api_base = pipeline_config["ai"]['coze-api'].get('api-base') + self.agent_token = pipeline_config['ai']['coze-api']['api-key'] + self.bot_id = pipeline_config['ai']['coze-api'].get('bot-id') + self.chat_timeout = pipeline_config['ai']['coze-api'].get('timeout') + self.auto_save_history = pipeline_config['ai']['coze-api'].get('auto_save_history') + self.api_base = pipeline_config['ai']['coze-api'].get('api-base') + + self.coze = AsyncCozeAPIClient(self.agent_token, self.api_base) - self.coze = AsyncCozeAPIClient( - self.agent_token, - self.api_base - ) - def _process_thinking_content( self, content: str, @@ -62,7 +59,7 @@ class CozeAPIRunner(runner.RequestRunner): if thinking_content: content = f'\n{thinking_content}\n\n{content}'.strip() return content, thinking_content - + async def _preprocess_user_message(self, query: pipeline_query.Query) -> list[dict]: """预处理用户消息,转换为Coze消息格式 @@ -70,44 +67,43 @@ class CozeAPIRunner(runner.RequestRunner): list[dict]: Coze消息列表 """ messages = [] - + if isinstance(query.user_message.content, list): # 多模态消息处理 content_parts = [] - + for ce in query.user_message.content: if ce.type == 'text': - content_parts.append({"type": "text", "text": ce.text}) + content_parts.append({'type': 'text', 'text': ce.text}) elif ce.type == 'image_base64': image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) file_bytes = base64.b64decode(image_b64) file_id = await self._get_file_id(file_bytes) - content_parts.append({"type": "image", "file_id": file_id}) + content_parts.append({'type': 'image', 'file_id': file_id}) elif ce.type == 'file': # 处理文件,上传到Coze file_id = await self._get_file_id(ce.file) - content_parts.append({"type": "file", "file_id": file_id}) - + content_parts.append({'type': 'file', 'file_id': file_id}) + # 创建多模态消息 if content_parts: - messages.append({ - "role": "user", - "content": json.dumps(content_parts), - "content_type": "object_string", - "meta_data": None - }) - + messages.append( + { + 'role': 'user', + 'content': json.dumps(content_parts), + 'content_type': 'object_string', + 'meta_data': None, + } + ) + elif isinstance(query.user_message.content, str): # 纯文本消息 - messages.append({ - "role": "user", - "content": query.user_message.content, - "content_type": "text", - "meta_data": None - }) - + messages.append( + {'role': 'user', 'content': query.user_message.content, 'content_type': 'text', 'meta_data': None} + ) + return messages - + async def _get_file_id(self, file) -> str: """上传文件到Coze服务 Args: @@ -122,21 +118,21 @@ class CozeAPIRunner(runner.RequestRunner): self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.Message, None]: """调用聊天助手(非流式) - + 注意:由于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) - + # 获取会话ID conversation_id = None - + # 收集完整内容 full_content = '' full_reasoning = '' - + try: # 调用Coze API流式接口 async for chunk in self.coze.chat_messages( @@ -146,53 +142,54 @@ class CozeAPIRunner(runner.RequestRunner): conversation_id=conversation_id, timeout=self.chat_timeout, auto_save_history=self.auto_save_history, - stream=True + stream=True, ): self.ap.logger.debug(f'coze-chat-stream: {chunk}') - + event_type = chunk.get('event') data = chunk.get('data', {}) - + # Removed debug print statement to avoid cluttering logs in production + if event_type == 'conversation.message.delta': # 收集内容 if 'content' in data: full_content += data.get('content', '') - + # 收集推理内容(如果有) if 'reasoning_content' in data: full_reasoning += data.get('reasoning_content', '') - - elif event_type == 'done': + + elif event_type.split('.')[-1] == 'done': # 本地部署coze时,结束event不为done # 保存会话ID if 'conversation_id' in data: conversation_id = data.get('conversation_id') - + elif event_type == 'error': # 处理错误 - error_msg = f"Coze API错误: {data.get('message', '未知错误')}" + error_msg = f'Coze API错误: {data.get("message", "未知错误")}' yield provider_message.Message( role='assistant', content=error_msg, ) return - + # 处理思维链内容 content, thinking_content = self._process_thinking_content(full_content) if full_reasoning: remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) if not remove_think: content = f'\n{full_reasoning}\n\n{content}'.strip() - + # 一次性返回完整内容 yield provider_message.Message( role='assistant', content=content, ) - + # 保存会话ID if conversation_id and query.session.using_conversation: query.session.using_conversation.uuid = conversation_id - + except Exception as e: self.ap.logger.error(f'Coze API错误: {str(e)}') yield provider_message.Message( @@ -200,12 +197,11 @@ class CozeAPIRunner(runner.RequestRunner): content=f'Coze API调用失败: {str(e)}', ) - async def _chat_messages_chunk( 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) @@ -220,8 +216,6 @@ class CozeAPIRunner(runner.RequestRunner): full_content = '' remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) - - try: # 调用Coze API流式接口 async for chunk in self.coze.chat_messages( @@ -231,22 +225,21 @@ class CozeAPIRunner(runner.RequestRunner): conversation_id=conversation_id, timeout=self.chat_timeout, auto_save_history=self.auto_save_history, - stream=True + stream=True, ): self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}') event_type = chunk.get('event') data = chunk.get('data', {}) - content = "" - + content = '' + if event_type == 'conversation.message.delta': message_idx += 1 # 处理内容增量 - if "reasoning_content" in data and not remove_think: - + if 'reasoning_content' in data and not remove_think: reasoning_content = data.get('reasoning_content', '') if reasoning_content and not start_reasoning: - content = f"\n" + content = '\n' start_reasoning = True content += reasoning_content @@ -254,11 +247,10 @@ class CozeAPIRunner(runner.RequestRunner): if data.get('content', ''): content += data.get('content', '') if not stop_reasoning and start_reasoning: - content = f"\n{content}" + content = f'\n{content}' stop_reasoning = True - - elif event_type == 'done': + elif event_type.split('.')[-1] == 'done': # 本地部署coze时,结束event不为done # 保存会话ID if 'conversation_id' in data: conversation_id = data.get('conversation_id') @@ -266,34 +258,22 @@ class CozeAPIRunner(runner.RequestRunner): query.session.using_conversation.uuid = conversation_id is_final = True - elif event_type == 'error': # 处理错误 - error_msg = f"Coze API错误: {data.get('message', '未知错误')}" - yield provider_message.MessageChunk( - role='assistant', - content=error_msg, - finish_reason='error' - ) + error_msg = f'Coze API错误: {data.get("message", "未知错误")}' + yield provider_message.MessageChunk(role='assistant', content=error_msg, finish_reason='error') return full_content += content if message_idx % 8 == 0 or is_final: if full_content: - yield provider_message.MessageChunk( - role='assistant', - content=full_content, - is_final=is_final - ) - + yield provider_message.MessageChunk(role='assistant', content=full_content, is_final=is_final) + except Exception as e: self.ap.logger.error(f'Coze API流式调用错误: {str(e)}') yield provider_message.MessageChunk( - role='assistant', - content=f'Coze API流式调用失败: {str(e)}', - finish_reason='error' + role='assistant', content=f'Coze API流式调用失败: {str(e)}', finish_reason='error' ) - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: """运行""" msg_seq = 0 @@ -306,7 +286,3 @@ class CozeAPIRunner(runner.RequestRunner): else: async for msg in self._chat_messages(query): yield msg - - - - diff --git a/pkg/provider/runners/dashscopeapi.py b/src/langbot/pkg/provider/runners/dashscopeapi.py similarity index 100% rename from pkg/provider/runners/dashscopeapi.py rename to src/langbot/pkg/provider/runners/dashscopeapi.py diff --git a/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py similarity index 94% rename from pkg/provider/runners/difysvapi.py rename to src/langbot/pkg/provider/runners/difysvapi.py index b98a9bc3..21fb471e 100644 --- a/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -6,12 +6,12 @@ import uuid import base64 -from .. import runner -from ...core import app +from langbot.pkg.provider import runner +from langbot.pkg.core import app import langbot_plugin.api.entities.builtin.provider.message as provider_message -from ...utils import image +from langbot.pkg.utils import image import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from libs.dify_service_api.v1 import client, errors +from langbot.libs.dify_service_api.v1 import client, errors @runner.runner_class('dify-service-api') @@ -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/pkg/provider/runners/langflowapi.py b/src/langbot/pkg/provider/runners/langflowapi.py similarity index 100% rename from pkg/provider/runners/langflowapi.py rename to src/langbot/pkg/provider/runners/langflowapi.py diff --git a/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py similarity index 91% rename from pkg/provider/runners/localagent.py rename to src/langbot/pkg/provider/runners/localagent.py index 7ab1e739..6375ca31 100644 --- a/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/pkg/provider/runners/n8nsvapi.py b/src/langbot/pkg/provider/runners/n8nsvapi.py similarity index 100% rename from pkg/provider/runners/n8nsvapi.py rename to src/langbot/pkg/provider/runners/n8nsvapi.py diff --git a/pkg/provider/runners/tboxapi.py b/src/langbot/pkg/provider/runners/tboxapi.py similarity index 93% rename from pkg/provider/runners/tboxapi.py rename to src/langbot/pkg/provider/runners/tboxapi.py index f0b1bd6a..0fb22a64 100644 --- a/pkg/provider/runners/tboxapi.py +++ b/src/langbot/pkg/provider/runners/tboxapi.py @@ -65,10 +65,8 @@ class TboxAPIRunner(runner.RequestRunner): with tempfile.NamedTemporaryFile(suffix=f'.{image_format}', delete=False) as tmp_file: tmp_file.write(file_bytes) tmp_file_path = tmp_file.name - file_upload_resp = self.tbox_client.upload_file( - tmp_file_path - ) - image_id = file_upload_resp.get("data", "") + file_upload_resp = self.tbox_client.upload_file(tmp_file_path) + image_id = file_upload_resp.get('data', '') image_ids.append(image_id) finally: # 清理临时文件 @@ -97,15 +95,12 @@ class TboxAPIRunner(runner.RequestRunner): files = None if image_ids: - files = [ - File(file_id=image_id, type=FileType.IMAGE) - for image_id in image_ids - ] + files = [File(file_id=image_id, type=FileType.IMAGE) for image_id in image_ids] # 发送对话请求 response = self.tbox_client.chat( app_id=self.app_id, # Tbox中智能体应用的ID - user_id=query.bot_uuid, # 用户ID + user_id=query.bot_uuid, # 用户ID query=plain_text, # 用户输入的文本信息 stream=is_stream, # 是否流式输出 conversation_id=conversation_id, # 会话ID,为None时Tbox会自动创建一个新会话 @@ -124,13 +119,13 @@ class TboxAPIRunner(runner.RequestRunner): ) def _process_non_stream_message(self, response: typing.Dict, query: pipeline_query.Query, remove_think: bool): - if response.get('errorCode') != "0": + if response.get('errorCode') != '0': raise TboxAPIError(f'Tbox API 请求失败: {response.get("errorMsg", "")}') payload = response.get('data', {}) conversation_id = payload.get('conversationId', '') query.session.using_conversation.uuid = conversation_id thinking_content = payload.get('reasoningContent', []) - result = "" + result = '' if thinking_content and not remove_think: result += f'\n{thinking_content[0].get("text", "")}\n\n' content = payload.get('result', []) @@ -138,7 +133,9 @@ class TboxAPIRunner(runner.RequestRunner): result += content[0].get('chunk', '') return result - def _process_stream_message(self, response: typing.Generator[dict], query: pipeline_query.Query, remove_think: bool): + def _process_stream_message( + self, response: typing.Generator[dict], query: pipeline_query.Query, remove_think: bool + ): idx_msg = 0 pending_content = '' conversation_id = None @@ -170,7 +167,7 @@ class TboxAPIRunner(runner.RequestRunner): payload = json.loads(chunk.get('payload', '{}')) if payload.get('ext_data', {}).get('text'): idx_msg += 1 - content = payload.get('ext_data', {}).get('text') + content = payload.get('ext_data', {}).get('text') if not think_start: think_start = True pending_content += f'\n{content}' diff --git a/pkg/provider/tools/__init__.py b/src/langbot/pkg/provider/session/__init__.py similarity index 100% rename from pkg/provider/tools/__init__.py rename to src/langbot/pkg/provider/session/__init__.py diff --git a/pkg/provider/session/sessionmgr.py b/src/langbot/pkg/provider/session/sessionmgr.py similarity index 100% rename from pkg/provider/session/sessionmgr.py rename to src/langbot/pkg/provider/session/sessionmgr.py diff --git a/pkg/provider/tools/loaders/__init__.py b/src/langbot/pkg/provider/tools/__init__.py similarity index 100% rename from pkg/provider/tools/loaders/__init__.py rename to src/langbot/pkg/provider/tools/__init__.py diff --git a/pkg/provider/tools/loader.py b/src/langbot/pkg/provider/tools/loader.py similarity index 91% rename from pkg/provider/tools/loader.py rename to src/langbot/pkg/provider/tools/loader.py index f3d65fd2..12bb8eb6 100644 --- a/pkg/provider/tools/loader.py +++ b/src/langbot/pkg/provider/tools/loader.py @@ -35,7 +35,7 @@ class ToolLoader(abc.ABC): pass @abc.abstractmethod - async def get_tools(self) -> list[resource_tool.LLMTool]: + async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: """获取所有工具""" pass diff --git a/pkg/rag/knowledge/services/__init__.py b/src/langbot/pkg/provider/tools/loaders/__init__.py similarity index 100% rename from pkg/rag/knowledge/services/__init__.py rename to src/langbot/pkg/provider/tools/loaders/__init__.py diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py new file mode 100644 index 00000000..2e564bf4 --- /dev/null +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import enum +import typing +from contextlib import AsyncExitStack +import traceback +import sqlalchemy +import asyncio + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.client.sse import sse_client + +from .. import loader +from ....core import app +import langbot_plugin.api.entities.builtin.resource.tool as resource_tool +from ....entity.persistence import mcp as persistence_mcp + + +class MCPSessionStatus(enum.Enum): + CONNECTING = 'connecting' + CONNECTED = 'connected' + ERROR = 'error' + + +class RuntimeMCPSession: + """运行时 MCP 会话""" + + ap: app.Application + + server_name: str + + server_uuid: str + + server_config: dict + + session: ClientSession + + exit_stack: AsyncExitStack + + functions: list[resource_tool.LLMTool] = [] + + enable: bool + + # connected: bool + status: MCPSessionStatus + + _lifecycle_task: asyncio.Task | None + + _shutdown_event: asyncio.Event + + _ready_event: asyncio.Event + + def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application): + self.server_name = server_name + self.server_uuid = server_config.get('uuid', '') + self.server_config = server_config + self.ap = ap + self.enable = enable + self.session = None + + self.exit_stack = AsyncExitStack() + self.functions = [] + + self.status = MCPSessionStatus.CONNECTING + + self._lifecycle_task = None + self._shutdown_event = asyncio.Event() + self._ready_event = asyncio.Event() + + async def _init_stdio_python_server(self): + server_params = StdioServerParameters( + command=self.server_config['command'], + args=self.server_config['args'], + env=self.server_config['env'], + ) + + stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) + + stdio, write = stdio_transport + + self.session = await self.exit_stack.enter_async_context(ClientSession(stdio, write)) + + await self.session.initialize() + + async def _init_sse_server(self): + sse_transport = await self.exit_stack.enter_async_context( + sse_client( + self.server_config['url'], + headers=self.server_config.get('headers', {}), + timeout=self.server_config.get('timeout', 10), + sse_read_timeout=self.server_config.get('ssereadtimeout', 30), + ) + ) + + sseio, write = sse_transport + + self.session = await self.exit_stack.enter_async_context(ClientSession(sseio, write)) + + await self.session.initialize() + + async def _lifecycle_loop(self): + """在后台任务中管理整个MCP会话的生命周期""" + try: + if self.server_config['mode'] == 'stdio': + await self._init_stdio_python_server() + elif self.server_config['mode'] == 'sse': + await self._init_sse_server() + else: + raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}') + + await self.refresh() + + self.status = MCPSessionStatus.CONNECTED + + # 通知start()方法连接已建立 + self._ready_event.set() + + # 等待shutdown信号 + await self._shutdown_event.wait() + + except Exception as e: + self.status = MCPSessionStatus.ERROR + self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}') + # 即使出错也要设置ready事件,让start()方法知道初始化已完成 + self._ready_event.set() + finally: + # 在同一个任务中清理所有资源 + try: + if self.exit_stack: + await self.exit_stack.aclose() + self.functions.clear() + self.session = None + except Exception as e: + self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}') + + async def start(self): + if not self.enable: + return + + # 创建后台任务来管理生命周期 + self._lifecycle_task = asyncio.create_task(self._lifecycle_loop()) + + # 等待连接建立或失败(带超时) + try: + await asyncio.wait_for(self._ready_event.wait(), timeout=30.0) + except asyncio.TimeoutError: + self.status = MCPSessionStatus.ERROR + raise Exception('Connection timeout after 30 seconds') + + # 检查是否有错误 + if self.status == MCPSessionStatus.ERROR: + raise Exception('Connection failed, please check URL') + + async def refresh(self): + self.functions.clear() + + tools = await self.session.list_tools() + + self.ap.logger.debug(f'Refresh MCP tools: {tools}') + + for tool in tools.tools: + + async def func(*, _tool=tool, **kwargs): + result = await self.session.call_tool(_tool.name, kwargs) + if result.isError: + raise Exception(result.content[0].text) + return result.content[0].text + + func.__name__ = tool.name + + self.functions.append( + resource_tool.LLMTool( + name=tool.name, + human_desc=tool.description, + description=tool.description, + parameters=tool.inputSchema, + func=func, + ) + ) + + def get_tools(self) -> list[resource_tool.LLMTool]: + return self.functions + + def get_runtime_info_dict(self) -> dict: + return { + 'status': self.status.value, + 'tool_count': len(self.get_tools()), + 'tools': [ + { + 'name': tool.name, + 'description': tool.description, + } + for tool in self.get_tools() + ], + } + + async def shutdown(self): + """关闭会话并清理资源""" + try: + # 设置shutdown事件,通知lifecycle任务退出 + self._shutdown_event.set() + + # 等待lifecycle任务完成(带超时) + if self._lifecycle_task and not self._lifecycle_task.done(): + try: + await asyncio.wait_for(self._lifecycle_task, timeout=5.0) + except asyncio.TimeoutError: + self.ap.logger.warning(f'MCP session {self.server_name} shutdown timeout, cancelling task') + self._lifecycle_task.cancel() + try: + await self._lifecycle_task + except asyncio.CancelledError: + pass + + self.ap.logger.info(f'MCP session {self.server_name} shutdown complete') + except Exception as e: + self.ap.logger.error(f'Error shutting down MCP session {self.server_name}: {e}\n{traceback.format_exc()}') + + +# @loader.loader_class('mcp') +class MCPLoader(loader.ToolLoader): + """MCP 工具加载器。 + + 在此加载器中管理所有与 MCP Server 的连接。 + """ + + sessions: dict[str, RuntimeMCPSession] + + _last_listed_functions: list[resource_tool.LLMTool] + + _hosted_mcp_tasks: list[asyncio.Task] + + def __init__(self, ap: app.Application): + super().__init__(ap) + self.sessions = {} + self._last_listed_functions = [] + self._hosted_mcp_tasks = [] + + async def initialize(self): + await self.load_mcp_servers_from_db() + + async def load_mcp_servers_from_db(self): + self.ap.logger.info('Loading MCP servers from db...') + + self.sessions = {} + + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) + servers = result.all() + + for server in servers: + config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + + task = asyncio.create_task(self.host_mcp_server(config)) + self._hosted_mcp_tasks.append(task) + + async def host_mcp_server(self, server_config: dict): + self.ap.logger.debug(f'Loading MCP server {server_config}') + try: + session = await self.load_mcp_server(server_config) + self.sessions[server_config['name']] = session + except Exception as e: + self.ap.logger.error( + f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' + ) + return + + self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})') + try: + await session.start() + except Exception as e: + self.ap.logger.error( + f'Failed to start MCP server {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' + ) + return + + self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})') + + async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession: + """加载 MCP 服务器到运行时 + + Args: + server_config: 服务器配置字典,必须包含: + - name: 服务器名称 + - mode: 连接模式 (stdio/sse) + - enable: 是否启用 + - extra_args: 额外的配置参数 (可选) + """ + + name = server_config['name'] + uuid = server_config['uuid'] + mode = server_config['mode'] + enable = server_config['enable'] + extra_args = server_config.get('extra_args', {}) + + mixed_config = { + 'name': name, + 'uuid': uuid, + 'mode': mode, + 'enable': enable, + **extra_args, + } + + session = RuntimeMCPSession(name, mixed_config, enable, self.ap) + + return session + + async def get_tools(self, bound_mcp_servers: list[str] | None = None) -> list[resource_tool.LLMTool]: + all_functions = [] + + for session in self.sessions.values(): + # If bound_mcp_servers is specified, only include tools from those servers + if bound_mcp_servers is not None: + if session.server_uuid in bound_mcp_servers: + all_functions.extend(session.get_tools()) + else: + # If no bound servers specified, include all tools + all_functions.extend(session.get_tools()) + + self._last_listed_functions = all_functions + + return all_functions + + async def has_tool(self, name: str) -> bool: + """检查工具是否存在""" + for session in self.sessions.values(): + for function in session.get_tools(): + if function.name == name: + return True + return False + + async def invoke_tool(self, name: str, parameters: dict) -> typing.Any: + """执行工具调用""" + for session in self.sessions.values(): + for function in session.get_tools(): + if function.name == name: + self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}') + try: + result = await function.func(**parameters) + self.ap.logger.debug(f'MCP tool {name} executed successfully') + return result + except Exception as e: + self.ap.logger.error(f'Error invoking MCP tool {name}: {e}\n{traceback.format_exc()}') + raise + + raise ValueError(f'Tool not found: {name}') + + async def remove_mcp_server(self, server_name: str): + """移除 MCP 服务器""" + if server_name not in self.sessions: + self.ap.logger.warning(f'MCP server {server_name} not found in sessions, skipping removal') + return + + session = self.sessions.pop(server_name) + await session.shutdown() + self.ap.logger.info(f'Removed MCP server: {server_name}') + + def get_session(self, server_name: str) -> RuntimeMCPSession | None: + """获取指定名称的 MCP 会话""" + return self.sessions.get(server_name) + + def has_session(self, server_name: str) -> bool: + """检查是否存在指定名称的 MCP 会话""" + return server_name in self.sessions + + def get_all_server_names(self) -> list[str]: + """获取所有已加载的 MCP 服务器名称""" + return list(self.sessions.keys()) + + def get_server_tool_count(self, server_name: str) -> int: + """获取指定服务器的工具数量""" + session = self.get_session(server_name) + return len(session.get_tools()) if session else 0 + + def get_all_servers_info(self) -> dict[str, dict]: + """获取所有服务器的信息""" + info = {} + for server_name, session in self.sessions.items(): + info[server_name] = { + 'name': server_name, + 'mode': session.server_config.get('mode'), + 'enable': session.enable, + 'tools_count': len(session.get_tools()), + 'tool_names': [f.name for f in session.get_tools()], + } + return info + + async def shutdown(self): + """关闭所有工具""" + self.ap.logger.info('Shutting down all MCP sessions...') + for server_name, session in list(self.sessions.items()): + try: + await session.shutdown() + self.ap.logger.debug(f'Shutdown MCP session: {server_name}') + except Exception as e: + self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}') + self.sessions.clear() + self.ap.logger.info('All MCP sessions shutdown complete') diff --git a/pkg/provider/tools/loaders/plugin.py b/src/langbot/pkg/provider/tools/loaders/plugin.py similarity index 88% rename from pkg/provider/tools/loaders/plugin.py rename to src/langbot/pkg/provider/tools/loaders/plugin.py index 94296470..bd020626 100644 --- a/pkg/provider/tools/loaders/plugin.py +++ b/src/langbot/pkg/provider/tools/loaders/plugin.py @@ -7,18 +7,18 @@ from .. import loader import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -@loader.loader_class('plugin-tool-loader') +# @loader.loader_class('plugin-tool-loader') class PluginToolLoader(loader.ToolLoader): """插件工具加载器。 本加载器中不存储工具信息,仅负责从插件系统中获取工具信息。 """ - async def get_tools(self) -> list[resource_tool.LLMTool]: + async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: # 从插件系统获取工具(内容函数) all_functions: list[resource_tool.LLMTool] = [] - for tool in await self.ap.plugin_connector.list_tools(): + for tool in await self.ap.plugin_connector.list_tools(bound_plugins): tool_obj = resource_tool.LLMTool( name=tool.metadata.name, human_desc=tool.metadata.description.en_US, diff --git a/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py similarity index 65% rename from pkg/provider/tools/toolmgr.py rename to src/langbot/pkg/provider/tools/toolmgr.py index 43960aba..f6b18a89 100644 --- a/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -3,9 +3,9 @@ from __future__ import annotations import typing from ...core import app -from . import loader as tools_loader -from ...utils import importutil -from . import loaders +from langbot.pkg.utils import importutil +from langbot.pkg.provider.tools import loaders +from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, plugin as plugin_loader import langbot_plugin.api.entities.builtin.resource.tool as resource_tool importutil.import_modules_in_pkg(loaders) @@ -16,25 +16,26 @@ class ToolManager: ap: app.Application - loaders: list[tools_loader.ToolLoader] + plugin_tool_loader: plugin_loader.PluginToolLoader + mcp_tool_loader: mcp_loader.MCPLoader def __init__(self, ap: app.Application): self.ap = ap - self.all_functions = [] - self.loaders = [] async def initialize(self): - for loader_cls in tools_loader.preregistered_loaders: - loader_inst = loader_cls(self.ap) - await loader_inst.initialize() - self.loaders.append(loader_inst) + self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap) + await self.plugin_tool_loader.initialize() + self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap) + await self.mcp_tool_loader.initialize() - async def get_all_tools(self) -> list[resource_tool.LLMTool]: + async def get_all_tools( + self, bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None + ) -> list[resource_tool.LLMTool]: """获取所有函数""" all_functions: list[resource_tool.LLMTool] = [] - for loader in self.loaders: - all_functions.extend(await loader.get_tools()) + all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins)) + all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers)) return all_functions @@ -93,13 +94,14 @@ class ToolManager: async def execute_func_call(self, name: str, parameters: dict) -> typing.Any: """执行函数调用""" - for loader in self.loaders: - if await loader.has_tool(name): - return await loader.invoke_tool(name, parameters) + if await self.plugin_tool_loader.has_tool(name): + return await self.plugin_tool_loader.invoke_tool(name, parameters) + elif await self.mcp_tool_loader.has_tool(name): + return await self.mcp_tool_loader.invoke_tool(name, parameters) else: raise ValueError(f'未找到工具: {name}') async def shutdown(self): """关闭所有工具""" - for loader in self.loaders: - await loader.shutdown() + await self.plugin_tool_loader.shutdown() + await self.mcp_tool_loader.shutdown() diff --git a/pkg/rag/knowledge/kbmgr.py b/src/langbot/pkg/rag/knowledge/kbmgr.py similarity index 96% rename from pkg/rag/knowledge/kbmgr.py rename to src/langbot/pkg/rag/knowledge/kbmgr.py index ed242696..17e2af32 100644 --- a/pkg/rag/knowledge/kbmgr.py +++ b/src/langbot/pkg/rag/knowledge/kbmgr.py @@ -4,13 +4,13 @@ import uuid import zipfile import io from .services import parser, chunker -from pkg.core import app -from pkg.rag.knowledge.services.embedder import Embedder -from pkg.rag.knowledge.services.retriever import Retriever +from langbot.pkg.core import app +from langbot.pkg.rag.knowledge.services.embedder import Embedder +from langbot.pkg.rag.knowledge.services.retriever import Retriever import sqlalchemy -from ...entity.persistence import rag as persistence_rag -from pkg.core import taskmgr -from ...entity.rag import retriever as retriever_entities +from langbot.pkg.entity.persistence import rag as persistence_rag +from langbot.pkg.core import taskmgr +from langbot.pkg.entity.rag import retriever as retriever_entities class RuntimeKnowledgeBase: diff --git a/pkg/storage/__init__.py b/src/langbot/pkg/rag/knowledge/services/__init__.py similarity index 100% rename from pkg/storage/__init__.py rename to src/langbot/pkg/rag/knowledge/services/__init__.py diff --git a/pkg/rag/knowledge/services/base_service.py b/src/langbot/pkg/rag/knowledge/services/base_service.py similarity index 100% rename from pkg/rag/knowledge/services/base_service.py rename to src/langbot/pkg/rag/knowledge/services/base_service.py diff --git a/pkg/rag/knowledge/services/chunker.py b/src/langbot/pkg/rag/knowledge/services/chunker.py similarity index 94% rename from pkg/rag/knowledge/services/chunker.py rename to src/langbot/pkg/rag/knowledge/services/chunker.py index 19b1f296..0cb16816 100644 --- a/pkg/rag/knowledge/services/chunker.py +++ b/src/langbot/pkg/rag/knowledge/services/chunker.py @@ -2,8 +2,8 @@ from __future__ import annotations import json from typing import List -from pkg.rag.knowledge.services import base_service -from pkg.core import app +from langbot.pkg.rag.knowledge.services import base_service +from langbot.pkg.core import app from langchain_text_splitters import RecursiveCharacterTextSplitter diff --git a/pkg/rag/knowledge/services/embedder.py b/src/langbot/pkg/rag/knowledge/services/embedder.py similarity index 86% rename from pkg/rag/knowledge/services/embedder.py rename to src/langbot/pkg/rag/knowledge/services/embedder.py index a0ae3d49..c8a1c3d3 100644 --- a/pkg/rag/knowledge/services/embedder.py +++ b/src/langbot/pkg/rag/knowledge/services/embedder.py @@ -1,10 +1,10 @@ from __future__ import annotations import uuid from typing import List -from pkg.rag.knowledge.services.base_service import BaseService -from ....entity.persistence import rag as persistence_rag -from ....core import app -from ....provider.modelmgr.requester import RuntimeEmbeddingModel +from langbot.pkg.rag.knowledge.services.base_service import BaseService +from langbot.pkg.entity.persistence import rag as persistence_rag +from langbot.pkg.core import app +from langbot.pkg.provider.modelmgr.requester import RuntimeEmbeddingModel import sqlalchemy diff --git a/pkg/rag/knowledge/services/parser.py b/src/langbot/pkg/rag/knowledge/services/parser.py similarity index 99% rename from pkg/rag/knowledge/services/parser.py rename to src/langbot/pkg/rag/knowledge/services/parser.py index 004dbdaa..50410738 100644 --- a/pkg/rag/knowledge/services/parser.py +++ b/src/langbot/pkg/rag/knowledge/services/parser.py @@ -9,7 +9,7 @@ import markdown from bs4 import BeautifulSoup import re import asyncio # Import asyncio for async operations -from pkg.core import app +from langbot.pkg.core import app class FileParser: diff --git a/pkg/rag/knowledge/services/retriever.py b/src/langbot/pkg/rag/knowledge/services/retriever.py similarity index 100% rename from pkg/rag/knowledge/services/retriever.py rename to src/langbot/pkg/rag/knowledge/services/retriever.py diff --git a/pkg/storage/providers/__init__.py b/src/langbot/pkg/storage/__init__.py similarity index 100% rename from pkg/storage/providers/__init__.py rename to src/langbot/pkg/storage/__init__.py diff --git a/src/langbot/pkg/storage/mgr.py b/src/langbot/pkg/storage/mgr.py new file mode 100644 index 00000000..2f263f15 --- /dev/null +++ b/src/langbot/pkg/storage/mgr.py @@ -0,0 +1,30 @@ +from __future__ import annotations + + +from ..core import app +from . import provider +from .providers import localstorage, s3storage + + +class StorageMgr: + """Storage manager""" + + ap: app.Application + + storage_provider: provider.StorageProvider + + def __init__(self, ap: app.Application): + self.ap = 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/pkg/storage/provider.py b/src/langbot/pkg/storage/provider.py similarity index 85% rename from pkg/storage/provider.py rename to src/langbot/pkg/storage/provider.py index 0111c617..09d8d93e 100644 --- a/pkg/storage/provider.py +++ b/src/langbot/pkg/storage/provider.py @@ -42,3 +42,10 @@ class StorageProvider(abc.ABC): key: str, ): pass + + @abc.abstractmethod + async def delete_dir_recursive( + self, + dir_path: str, + ): + pass diff --git a/pkg/utils/__init__.py b/src/langbot/pkg/storage/providers/__init__.py similarity index 100% rename from pkg/utils/__init__.py rename to src/langbot/pkg/storage/providers/__init__.py diff --git a/pkg/storage/providers/localstorage.py b/src/langbot/pkg/storage/providers/localstorage.py similarity index 70% rename from pkg/storage/providers/localstorage.py rename to src/langbot/pkg/storage/providers/localstorage.py index 84ce8a0b..d21f5427 100644 --- a/pkg/storage/providers/localstorage.py +++ b/src/langbot/pkg/storage/providers/localstorage.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import aiofiles +import shutil from ...core import app @@ -22,6 +23,8 @@ class LocalStorageProvider(provider.StorageProvider): key: str, value: bytes, ): + if not os.path.exists(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key))): + os.makedirs(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key))) async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'wb') as f: await f.write(value) @@ -43,3 +46,11 @@ class LocalStorageProvider(provider.StorageProvider): key: str, ): os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}')) + + async def delete_dir_recursive( + self, + dir_path: str, + ): + # 直接删除整个目录 + if os.path.exists(os.path.join(LOCAL_STORAGE_PATH, dir_path)): + shutil.rmtree(os.path.join(LOCAL_STORAGE_PATH, dir_path)) 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/pkg/vector/__init__.py b/src/langbot/pkg/utils/__init__.py similarity index 100% rename from pkg/vector/__init__.py rename to src/langbot/pkg/utils/__init__.py diff --git a/pkg/utils/constants.py b/src/langbot/pkg/utils/constants.py similarity index 60% rename from pkg/utils/constants.py rename to src/langbot/pkg/utils/constants.py index aa557005..dba385cd 100644 --- a/pkg/utils/constants.py +++ b/src/langbot/pkg/utils/constants.py @@ -1,6 +1,8 @@ -semantic_version = 'v4.3.9' +import langbot -required_database_version = 8 +semantic_version = f'v{langbot.__version__}' + +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/pkg/utils/funcschema.py b/src/langbot/pkg/utils/funcschema.py similarity index 100% rename from pkg/utils/funcschema.py rename to src/langbot/pkg/utils/funcschema.py diff --git a/pkg/utils/image.py b/src/langbot/pkg/utils/image.py similarity index 100% rename from pkg/utils/image.py rename to src/langbot/pkg/utils/image.py diff --git a/pkg/utils/importutil.py b/src/langbot/pkg/utils/importutil.py similarity index 58% rename from pkg/utils/importutil.py rename to src/langbot/pkg/utils/importutil.py index 1933d611..a35052a6 100644 --- a/pkg/utils/importutil.py +++ b/src/langbot/pkg/utils/importutil.py @@ -1,5 +1,5 @@ import importlib -import importlib.util +import importlib.resources import os import typing @@ -25,7 +25,7 @@ def import_dot_style_dir(dot_sep_path: str): return import_dir(os.path.join(*sec)) -def import_dir(path: str): +def import_dir(path: str, path_prefix: str = 'langbot.'): for file in os.listdir(path): if file.endswith('.py') and file != '__init__.py': full_path = os.path.join(path, file) @@ -33,10 +33,17 @@ def import_dir(path: str): rel_path = rel_path[1:] rel_path = rel_path.replace('/', '.')[:-3] rel_path = rel_path.replace('\\', '.') - importlib.import_module(rel_path) + importlib.import_module(f'{path_prefix}{rel_path}') -if __name__ == '__main__': - from pkg.platform import types +def read_resource_file(resource_path: str) -> str: + with importlib.resources.files('langbot').joinpath(resource_path).open('r', encoding='utf-8') as f: + return f.read() - import_modules_in_pkg(types) + +def read_resource_file_bytes(resource_path: str) -> bytes: + return importlib.resources.files('langbot').joinpath(resource_path).read_bytes() + + +def list_resource_files(resource_path: str) -> list[str]: + return [f.name for f in importlib.resources.files('langbot').joinpath(resource_path).iterdir()] diff --git a/pkg/utils/logcache.py b/src/langbot/pkg/utils/logcache.py similarity index 100% rename from pkg/utils/logcache.py rename to src/langbot/pkg/utils/logcache.py diff --git a/src/langbot/pkg/utils/paths.py b/src/langbot/pkg/utils/paths.py new file mode 100644 index 00000000..5553c154 --- /dev/null +++ b/src/langbot/pkg/utils/paths.py @@ -0,0 +1,92 @@ +"""Utility functions for finding package resources""" + +import os +from pathlib import Path + + +_is_source_install = None + + +def _check_if_source_install() -> bool: + """ + Check if we're running from source directory or an installed package. + Cached to avoid repeated file I/O. + """ + global _is_source_install + + if _is_source_install is not None: + return _is_source_install + + # Check if main.py exists in current directory with LangBot marker + if os.path.exists('main.py'): + try: + with open('main.py', 'r', encoding='utf-8') as f: + # Only read first 500 chars to check for marker + content = f.read(500) + if 'LangBot/main.py' in content: + _is_source_install = True + return True + except (IOError, OSError, UnicodeDecodeError): + # If we can't read the file, assume not a source install + pass + + _is_source_install = False + return False + + +def get_frontend_path() -> str: + """ + Get the path to the frontend build files. + + Returns the path to web/out directory, handling both: + - Development mode: running from source directory + - Package mode: installed via pip/uvx + """ + # First, check if we're running from source directory + if _check_if_source_install() and os.path.exists('web/out'): + return 'web/out' + + # Second, check current directory for web/out (in case user is in source dir) + if os.path.exists('web/out'): + return 'web/out' + + # Third, find it relative to the package installation + # Get the directory where this file is located + # paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root + pkg_dir = Path(__file__).parent.parent.parent + frontend_path = pkg_dir / 'web' / 'out' + if frontend_path.exists(): + return str(frontend_path) + + # Return the default path (will be checked by caller) + return 'web/out' + + +def get_resource_path(resource: str) -> str: + """ + Get the path to a resource file. + + Args: + resource: Relative path to resource (e.g., 'templates/config.yaml') + + Returns: + Absolute path to the resource + """ + # First, check if resource exists in current directory (source install) + if _check_if_source_install() and os.path.exists(resource): + return resource + + # Second, check current directory anyway + if os.path.exists(resource): + return resource + + # Third, find it relative to package directory + # Get the directory where this file is located + # paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root + pkg_dir = Path(__file__).parent.parent.parent + resource_path = pkg_dir / resource + if resource_path.exists(): + return str(resource_path) + + # Return the original path + return resource diff --git a/pkg/utils/pkgmgr.py b/src/langbot/pkg/utils/pkgmgr.py similarity index 100% rename from pkg/utils/pkgmgr.py rename to src/langbot/pkg/utils/pkgmgr.py diff --git a/pkg/utils/platform.py b/src/langbot/pkg/utils/platform.py similarity index 100% rename from pkg/utils/platform.py rename to src/langbot/pkg/utils/platform.py diff --git a/pkg/utils/proxy.py b/src/langbot/pkg/utils/proxy.py similarity index 100% rename from pkg/utils/proxy.py rename to src/langbot/pkg/utils/proxy.py diff --git a/pkg/utils/version.py b/src/langbot/pkg/utils/version.py similarity index 98% rename from pkg/utils/version.py rename to src/langbot/pkg/utils/version.py index 3a2748fc..60df3f32 100644 --- a/pkg/utils/version.py +++ b/src/langbot/pkg/utils/version.py @@ -38,7 +38,7 @@ class VersionManager: rls_list = rls_list_resp.json() return rls_list except Exception as e: - self.ap.logger.warning(f"获取发行列表失败: {e}") + self.ap.logger.warning(f'获取发行列表失败: {e}') pass return [] diff --git a/pkg/vector/vdbs/__init__.py b/src/langbot/pkg/vector/__init__.py similarity index 100% rename from pkg/vector/vdbs/__init__.py rename to src/langbot/pkg/vector/__init__.py diff --git a/pkg/vector/mgr.py b/src/langbot/pkg/vector/mgr.py similarity index 100% rename from pkg/vector/mgr.py rename to src/langbot/pkg/vector/mgr.py diff --git a/pkg/vector/vdb.py b/src/langbot/pkg/vector/vdb.py similarity index 100% rename from pkg/vector/vdb.py rename to src/langbot/pkg/vector/vdb.py diff --git a/templates/__init__.py b/src/langbot/pkg/vector/vdbs/__init__.py similarity index 100% rename from templates/__init__.py rename to src/langbot/pkg/vector/vdbs/__init__.py diff --git a/pkg/vector/vdbs/chroma.py b/src/langbot/pkg/vector/vdbs/chroma.py similarity index 96% rename from pkg/vector/vdbs/chroma.py rename to src/langbot/pkg/vector/vdbs/chroma.py index 41ab7d36..94227c75 100644 --- a/pkg/vector/vdbs/chroma.py +++ b/src/langbot/pkg/vector/vdbs/chroma.py @@ -2,8 +2,8 @@ from __future__ import annotations import asyncio from typing import Any from chromadb import PersistentClient -from pkg.vector.vdb import VectorDatabase -from pkg.core import app +from langbot.pkg.vector.vdb import VectorDatabase +from langbot.pkg.core import app import chromadb import chromadb.errors diff --git a/pkg/vector/vdbs/qdrant.py b/src/langbot/pkg/vector/vdbs/qdrant.py similarity index 98% rename from pkg/vector/vdbs/qdrant.py rename to src/langbot/pkg/vector/vdbs/qdrant.py index 85a1ad81..a6fbd4ab 100644 --- a/pkg/vector/vdbs/qdrant.py +++ b/src/langbot/pkg/vector/vdbs/qdrant.py @@ -3,8 +3,8 @@ from __future__ import annotations from typing import Any, Dict, List from qdrant_client import AsyncQdrantClient, models -from pkg.core import app -from pkg.vector.vdb import VectorDatabase +from langbot.pkg.core import app +from langbot.pkg.vector.vdb import VectorDatabase class QdrantVectorDatabase(VectorDatabase): diff --git a/src/langbot/templates/__init__.py b/src/langbot/templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/components.yaml b/src/langbot/templates/components.yaml similarity index 66% rename from components.yaml rename to src/langbot/templates/components.yaml index 5d8e75d2..a95235aa 100644 --- a/components.yaml +++ b/src/langbot/templates/components.yaml @@ -7,16 +7,9 @@ metadata: zh_Hans: 内置组件 spec: components: - ComponentTemplate: - fromFiles: - - pkg/provider/modelmgr/requester.yaml MessagePlatformAdapter: fromDirs: - path: pkg/platform/sources/ LLMAPIRequester: fromDirs: - path: pkg/provider/modelmgr/requesters/ - Plugin: - fromDirs: - - path: plugins/ - maxDepth: 2 diff --git a/templates/config.yaml b/src/langbot/templates/config.yaml similarity index 81% rename from templates/config.yaml rename to src/langbot/templates/config.yaml index b81b04dc..28c4d57b 100644 --- a/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -10,8 +10,6 @@ command: concurrency: pipeline: 20 session: 1 -mcp: - servers: [] proxy: http: '' https: '' @@ -37,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/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json similarity index 98% rename from templates/default-pipeline-config.json rename to src/langbot/templates/default-pipeline-config.json index c5398e76..efbb9c3f 100644 --- a/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/templates/legacy/command.json b/src/langbot/templates/legacy/command.json similarity index 100% rename from templates/legacy/command.json rename to src/langbot/templates/legacy/command.json diff --git a/templates/legacy/pipeline.json b/src/langbot/templates/legacy/pipeline.json similarity index 100% rename from templates/legacy/pipeline.json rename to src/langbot/templates/legacy/pipeline.json diff --git a/templates/legacy/platform.json b/src/langbot/templates/legacy/platform.json similarity index 100% rename from templates/legacy/platform.json rename to src/langbot/templates/legacy/platform.json diff --git a/templates/legacy/provider.json b/src/langbot/templates/legacy/provider.json similarity index 100% rename from templates/legacy/provider.json rename to src/langbot/templates/legacy/provider.json diff --git a/templates/legacy/system.json b/src/langbot/templates/legacy/system.json similarity index 100% rename from templates/legacy/system.json rename to src/langbot/templates/legacy/system.json diff --git a/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml similarity index 93% rename from templates/metadata/pipeline/ai.yaml rename to src/langbot/templates/metadata/pipeline/ai.yaml index e4d16a95..7a13b2b1 100644 --- a/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/templates/metadata/pipeline/output.yaml b/src/langbot/templates/metadata/pipeline/output.yaml similarity index 100% rename from templates/metadata/pipeline/output.yaml rename to src/langbot/templates/metadata/pipeline/output.yaml diff --git a/templates/metadata/pipeline/safety.yaml b/src/langbot/templates/metadata/pipeline/safety.yaml similarity index 100% rename from templates/metadata/pipeline/safety.yaml rename to src/langbot/templates/metadata/pipeline/safety.yaml diff --git a/templates/metadata/pipeline/trigger.yaml b/src/langbot/templates/metadata/pipeline/trigger.yaml similarity index 100% rename from templates/metadata/pipeline/trigger.yaml rename to src/langbot/templates/metadata/pipeline/trigger.yaml diff --git a/templates/metadata/sensitive-words.json b/src/langbot/templates/metadata/sensitive-words.json similarity index 100% rename from templates/metadata/sensitive-words.json rename to src/langbot/templates/metadata/sensitive-words.json diff --git a/tests/unit_tests/config/__init__.py b/tests/unit_tests/config/__init__.py new file mode 100644 index 00000000..b5afb6d9 --- /dev/null +++ b/tests/unit_tests/config/__init__.py @@ -0,0 +1 @@ +# Config unit tests diff --git a/tests/unit_tests/config/test_env_override.py b/tests/unit_tests/config/test_env_override.py new file mode 100644 index 00000000..d20988e9 --- /dev/null +++ b/tests/unit_tests/config/test_env_override.py @@ -0,0 +1,332 @@ +""" +Tests for environment variable override functionality in YAML config +""" + +import os +import pytest +from typing import Any + + +def _apply_env_overrides_to_config(cfg: dict) -> dict: + """Apply environment variable overrides to data/config.yaml + + Environment variables should be uppercase and use __ (double underscore) + to represent nested keys. For example: + - CONCURRENCY__PIPELINE overrides concurrency.pipeline + - PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url + + Arrays and dict types are ignored. + + Args: + cfg: Configuration dictionary + + Returns: + Updated configuration dictionary + """ + def convert_value(value: str, original_value: Any) -> Any: + """Convert string value to appropriate type based on original value + + Args: + value: String value from environment variable + original_value: Original value to infer type from + + Returns: + Converted value (falls back to string if conversion fails) + """ + if isinstance(original_value, bool): + return value.lower() in ('true', '1', 'yes', 'on') + elif isinstance(original_value, int): + try: + return int(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + elif isinstance(original_value, float): + try: + return float(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + else: + return value + + # Process environment variables + for env_key, env_value in os.environ.items(): + # Check if the environment variable is uppercase and contains __ + if not env_key.isupper(): + continue + if '__' not in env_key: + continue + + # Convert environment variable name to config path + # e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline'] + keys = [key.lower() for key in env_key.split('__')] + + # Navigate to the target value and validate the path + current = cfg + + for i, key in enumerate(keys): + if not isinstance(current, dict) or key not in current: + break + + if i == len(keys) - 1: + # At the final key - check if it's a scalar value + if isinstance(current[key], (dict, list)): + # Skip dict and list types + pass + else: + # Valid scalar value - convert and set it + converted_value = convert_value(env_value, current[key]) + current[key] = converted_value + else: + # Navigate deeper + current = current[key] + + return cfg + + +class TestEnvOverrides: + """Test environment variable override functionality""" + + def test_simple_string_override(self): + """Test overriding a simple string value""" + cfg = { + 'api': { + 'port': 5300 + } + } + + # Set environment variable + os.environ['API__PORT'] = '8080' + + result = _apply_env_overrides_to_config(cfg) + + assert result['api']['port'] == 8080 + + # Cleanup + del os.environ['API__PORT'] + + def test_nested_key_override(self): + """Test overriding nested keys with __ delimiter""" + cfg = { + 'concurrency': { + 'pipeline': 20, + 'session': 1 + } + } + + os.environ['CONCURRENCY__PIPELINE'] = '50' + + result = _apply_env_overrides_to_config(cfg) + + assert result['concurrency']['pipeline'] == 50 + assert result['concurrency']['session'] == 1 # Unchanged + + del os.environ['CONCURRENCY__PIPELINE'] + + def test_deep_nested_override(self): + """Test overriding deeply nested keys""" + cfg = { + 'system': { + 'jwt': { + 'expire': 604800, + 'secret': '' + } + } + } + + os.environ['SYSTEM__JWT__EXPIRE'] = '86400' + os.environ['SYSTEM__JWT__SECRET'] = 'my_secret_key' + + result = _apply_env_overrides_to_config(cfg) + + assert result['system']['jwt']['expire'] == 86400 + assert result['system']['jwt']['secret'] == 'my_secret_key' + + del os.environ['SYSTEM__JWT__EXPIRE'] + del os.environ['SYSTEM__JWT__SECRET'] + + def test_underscore_in_key(self): + """Test keys with underscores like runtime_ws_url""" + cfg = { + 'plugin': { + 'enable': True, + 'runtime_ws_url': 'ws://localhost:5400/control/ws' + } + } + + os.environ['PLUGIN__RUNTIME_WS_URL'] = 'ws://newhost:6000/ws' + + result = _apply_env_overrides_to_config(cfg) + + assert result['plugin']['runtime_ws_url'] == 'ws://newhost:6000/ws' + + del os.environ['PLUGIN__RUNTIME_WS_URL'] + + def test_boolean_conversion(self): + """Test boolean value conversion""" + cfg = { + 'plugin': { + 'enable': True, + 'enable_marketplace': False + } + } + + os.environ['PLUGIN__ENABLE'] = 'false' + os.environ['PLUGIN__ENABLE_MARKETPLACE'] = 'true' + + result = _apply_env_overrides_to_config(cfg) + + assert result['plugin']['enable'] is False + assert result['plugin']['enable_marketplace'] is True + + del os.environ['PLUGIN__ENABLE'] + del os.environ['PLUGIN__ENABLE_MARKETPLACE'] + + def test_ignore_dict_type(self): + """Test that dict types are ignored""" + cfg = { + 'database': { + 'use': 'sqlite', + 'sqlite': { + 'path': 'data/langbot.db' + } + } + } + + # Try to override a dict value - should be ignored + os.environ['DATABASE__SQLITE'] = 'new_value' + + result = _apply_env_overrides_to_config(cfg) + + # Should remain a dict, not overridden + assert isinstance(result['database']['sqlite'], dict) + assert result['database']['sqlite']['path'] == 'data/langbot.db' + + del os.environ['DATABASE__SQLITE'] + + def test_ignore_list_type(self): + """Test that list/array types are ignored""" + cfg = { + 'admins': ['admin1', 'admin2'], + 'command': { + 'enable': True, + 'prefix': ['!', '!'] + } + } + + # Try to override list values - should be ignored + os.environ['ADMINS'] = 'admin3' + os.environ['COMMAND__PREFIX'] = '?' + + result = _apply_env_overrides_to_config(cfg) + + # Should remain lists, not overridden + assert isinstance(result['admins'], list) + assert result['admins'] == ['admin1', 'admin2'] + assert isinstance(result['command']['prefix'], list) + assert result['command']['prefix'] == ['!', '!'] + + del os.environ['ADMINS'] + del os.environ['COMMAND__PREFIX'] + + def test_lowercase_env_var_ignored(self): + """Test that lowercase environment variables are ignored""" + cfg = { + 'api': { + 'port': 5300 + } + } + + os.environ['api__port'] = '8080' + + result = _apply_env_overrides_to_config(cfg) + + # Should not be overridden + assert result['api']['port'] == 5300 + + del os.environ['api__port'] + + def test_no_double_underscore_ignored(self): + """Test that env vars without __ are ignored""" + cfg = { + 'api': { + 'port': 5300 + } + } + + os.environ['APIPORT'] = '8080' + + result = _apply_env_overrides_to_config(cfg) + + # Should not be overridden + assert result['api']['port'] == 5300 + + del os.environ['APIPORT'] + + def test_nonexistent_key_ignored(self): + """Test that env vars for non-existent keys are ignored""" + cfg = { + 'api': { + 'port': 5300 + } + } + + os.environ['API__NONEXISTENT'] = 'value' + + result = _apply_env_overrides_to_config(cfg) + + # Should not create new key + assert 'nonexistent' not in result['api'] + + del os.environ['API__NONEXISTENT'] + + def test_integer_conversion(self): + """Test integer value conversion""" + cfg = { + 'concurrency': { + 'pipeline': 20 + } + } + + os.environ['CONCURRENCY__PIPELINE'] = '100' + + result = _apply_env_overrides_to_config(cfg) + + assert result['concurrency']['pipeline'] == 100 + assert isinstance(result['concurrency']['pipeline'], int) + + del os.environ['CONCURRENCY__PIPELINE'] + + def test_multiple_overrides(self): + """Test multiple environment variable overrides at once""" + cfg = { + 'api': { + 'port': 5300 + }, + 'concurrency': { + 'pipeline': 20, + 'session': 1 + }, + 'plugin': { + 'enable': False + } + } + + os.environ['API__PORT'] = '8080' + os.environ['CONCURRENCY__PIPELINE'] = '50' + os.environ['PLUGIN__ENABLE'] = 'true' + + result = _apply_env_overrides_to_config(cfg) + + assert result['api']['port'] == 8080 + assert result['concurrency']['pipeline'] == 50 + assert result['plugin']['enable'] is True + + del os.environ['API__PORT'] + del os.environ['CONCURRENCY__PIPELINE'] + del os.environ['PLUGIN__ENABLE'] + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/tests/unit_tests/pipeline/conftest.py b/tests/unit_tests/pipeline/conftest.py index f6935395..40b2e930 100644 --- a/tests/unit_tests/pipeline/conftest.py +++ b/tests/unit_tests/pipeline/conftest.py @@ -10,16 +10,13 @@ This file provides infrastructure for all pipeline tests, including: from __future__ import annotations import pytest -from unittest.mock import AsyncMock, MagicMock, Mock -from typing import Any +from unittest.mock import AsyncMock, Mock import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.platform.message as platform_message -import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.provider.session as provider_session -import langbot_plugin.api.entities.builtin.provider.message as provider_message -from pkg.pipeline import entities as pipeline_entities +from langbot.pkg.pipeline import entities as pipeline_entities class MockApplication: @@ -203,7 +200,7 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter): variables={}, resp_messages=[], resp_message_chain=None, - current_stage_name=None + current_stage_name=None, ) return query diff --git a/tests/unit_tests/pipeline/test_bansess.py b/tests/unit_tests/pipeline/test_bansess.py index 2483d484..394fb8bc 100644 --- a/tests/unit_tests/pipeline/test_bansess.py +++ b/tests/unit_tests/pipeline/test_bansess.py @@ -5,7 +5,6 @@ Tests the actual BanSessionCheckStage implementation from pkg.pipeline.bansess """ import pytest -from unittest.mock import Mock from importlib import import_module import langbot_plugin.api.entities.builtin.provider.session as provider_session @@ -13,9 +12,8 @@ import langbot_plugin.api.entities.builtin.provider.session as provider_session def get_modules(): """Lazy import to ensure proper initialization order""" # Import pipelinemgr first to trigger proper stage registration - pipelinemgr = import_module('pkg.pipeline.pipelinemgr') - bansess = import_module('pkg.pipeline.bansess.bansess') - entities = import_module('pkg.pipeline.entities') + bansess = import_module('langbot.pkg.pipeline.bansess.bansess') + entities = import_module('langbot.pkg.pipeline.entities') return bansess, entities @@ -26,14 +24,7 @@ async def test_whitelist_allow(mock_app, sample_query): sample_query.launcher_type = provider_session.LauncherTypes.PERSON sample_query.launcher_id = '12345' - sample_query.pipeline_config = { - 'trigger': { - 'access-control': { - 'mode': 'whitelist', - 'whitelist': ['person_12345'] - } - } - } + sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['person_12345']}}} stage = bansess.BanSessionCheckStage(mock_app) await stage.initialize(sample_query.pipeline_config) @@ -51,14 +42,7 @@ async def test_whitelist_deny(mock_app, sample_query): sample_query.launcher_type = provider_session.LauncherTypes.PERSON sample_query.launcher_id = '99999' - sample_query.pipeline_config = { - 'trigger': { - 'access-control': { - 'mode': 'whitelist', - 'whitelist': ['person_12345'] - } - } - } + sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['person_12345']}}} stage = bansess.BanSessionCheckStage(mock_app) await stage.initialize(sample_query.pipeline_config) @@ -75,14 +59,7 @@ async def test_blacklist_allow(mock_app, sample_query): sample_query.launcher_type = provider_session.LauncherTypes.PERSON sample_query.launcher_id = '12345' - sample_query.pipeline_config = { - 'trigger': { - 'access-control': { - 'mode': 'blacklist', - 'blacklist': ['person_99999'] - } - } - } + sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'blacklist', 'blacklist': ['person_99999']}}} stage = bansess.BanSessionCheckStage(mock_app) await stage.initialize(sample_query.pipeline_config) @@ -99,14 +76,7 @@ async def test_blacklist_deny(mock_app, sample_query): sample_query.launcher_type = provider_session.LauncherTypes.PERSON sample_query.launcher_id = '12345' - sample_query.pipeline_config = { - 'trigger': { - 'access-control': { - 'mode': 'blacklist', - 'blacklist': ['person_12345'] - } - } - } + sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'blacklist', 'blacklist': ['person_12345']}}} stage = bansess.BanSessionCheckStage(mock_app) await stage.initialize(sample_query.pipeline_config) @@ -123,14 +93,7 @@ async def test_wildcard_group(mock_app, sample_query): sample_query.launcher_type = provider_session.LauncherTypes.GROUP sample_query.launcher_id = '12345' - sample_query.pipeline_config = { - 'trigger': { - 'access-control': { - 'mode': 'whitelist', - 'whitelist': ['group_*'] - } - } - } + sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['group_*']}}} stage = bansess.BanSessionCheckStage(mock_app) await stage.initialize(sample_query.pipeline_config) @@ -147,14 +110,7 @@ async def test_wildcard_person(mock_app, sample_query): sample_query.launcher_type = provider_session.LauncherTypes.PERSON sample_query.launcher_id = '12345' - sample_query.pipeline_config = { - 'trigger': { - 'access-control': { - 'mode': 'whitelist', - 'whitelist': ['person_*'] - } - } - } + sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['person_*']}}} stage = bansess.BanSessionCheckStage(mock_app) await stage.initialize(sample_query.pipeline_config) @@ -172,14 +128,7 @@ async def test_user_id_wildcard(mock_app, sample_query): sample_query.launcher_type = provider_session.LauncherTypes.PERSON sample_query.launcher_id = '12345' sample_query.sender_id = '67890' - sample_query.pipeline_config = { - 'trigger': { - 'access-control': { - 'mode': 'whitelist', - 'whitelist': ['*_67890'] - } - } - } + sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['*_67890']}}} stage = bansess.BanSessionCheckStage(mock_app) await stage.initialize(sample_query.pipeline_config) diff --git a/tests/unit_tests/pipeline/test_pipelinemgr.py b/tests/unit_tests/pipeline/test_pipelinemgr.py index b7ba2675..95c6d968 100644 --- a/tests/unit_tests/pipeline/test_pipelinemgr.py +++ b/tests/unit_tests/pipeline/test_pipelinemgr.py @@ -5,23 +5,22 @@ PipelineManager unit tests import pytest from unittest.mock import AsyncMock, Mock from importlib import import_module -import sqlalchemy def get_pipelinemgr_module(): - return import_module('pkg.pipeline.pipelinemgr') + return import_module('langbot.pkg.pipeline.pipelinemgr') def get_stage_module(): - return import_module('pkg.pipeline.stage') + return import_module('langbot.pkg.pipeline.stage') def get_entities_module(): - return import_module('pkg.pipeline.entities') + return import_module('langbot.pkg.pipeline.entities') def get_persistence_pipeline_module(): - return import_module('pkg.entity.persistence.pipeline') + return import_module('langbot.pkg.entity.persistence.pipeline') @pytest.mark.asyncio @@ -54,6 +53,7 @@ async def test_load_pipeline(mock_app): pipeline_entity.uuid = 'test-uuid' pipeline_entity.stages = [] pipeline_entity.config = {'test': 'config'} + pipeline_entity.extensions_preferences = {'plugins': []} await manager.load_pipeline(pipeline_entity) @@ -77,6 +77,7 @@ async def test_get_pipeline_by_uuid(mock_app): pipeline_entity.uuid = 'test-uuid' pipeline_entity.stages = [] pipeline_entity.config = {} + pipeline_entity.extensions_preferences = {'plugins': []} await manager.load_pipeline(pipeline_entity) @@ -106,6 +107,7 @@ async def test_remove_pipeline(mock_app): pipeline_entity.uuid = 'test-uuid' pipeline_entity.stages = [] pipeline_entity.config = {} + pipeline_entity.extensions_preferences = {'plugins': []} await manager.load_pipeline(pipeline_entity) assert len(manager.pipelines) == 1 @@ -134,6 +136,7 @@ async def test_runtime_pipeline_execute(mock_app, sample_query): # Make it look like ResultType.CONTINUE from unittest.mock import MagicMock + CONTINUE = MagicMock() CONTINUE.__eq__ = lambda self, other: True # Always equal for comparison mock_result.result_type = CONTINUE @@ -147,6 +150,7 @@ async def test_runtime_pipeline_execute(mock_app, sample_query): # Create pipeline entity pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) pipeline_entity.config = sample_query.pipeline_config + pipeline_entity.extensions_preferences = {'plugins': []} # Create runtime pipeline runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [stage_container]) diff --git a/tests/unit_tests/pipeline/test_ratelimit.py b/tests/unit_tests/pipeline/test_ratelimit.py index 18e399fe..77649f70 100644 --- a/tests/unit_tests/pipeline/test_ratelimit.py +++ b/tests/unit_tests/pipeline/test_ratelimit.py @@ -13,10 +13,9 @@ import langbot_plugin.api.entities.builtin.provider.session as provider_session def get_modules(): """Lazy import to ensure proper initialization order""" # Import pipelinemgr first to trigger proper stage registration - pipelinemgr = import_module('pkg.pipeline.pipelinemgr') - ratelimit = import_module('pkg.pipeline.ratelimit.ratelimit') - entities = import_module('pkg.pipeline.entities') - algo_module = import_module('pkg.pipeline.ratelimit.algo') + ratelimit = import_module('langbot.pkg.pipeline.ratelimit.ratelimit') + entities = import_module('langbot.pkg.pipeline.entities') + algo_module = import_module('langbot.pkg.pipeline.ratelimit.algo') return ratelimit, entities, algo_module @@ -44,11 +43,7 @@ async def test_require_access_allowed(mock_app, sample_query): assert result.result_type == entities.ResultType.CONTINUE assert result.new_query == sample_query - mock_algo.require_access.assert_called_once_with( - sample_query, - 'person', - '12345' - ) + mock_algo.require_access.assert_called_once_with(sample_query, 'person', '12345') @pytest.mark.asyncio @@ -102,8 +97,4 @@ async def test_release_access(mock_app, sample_query): assert result.result_type == entities.ResultType.CONTINUE assert result.new_query == sample_query - mock_algo.release_access.assert_called_once_with( - sample_query, - 'person', - '12345' - ) + mock_algo.release_access.assert_called_once_with(sample_query, 'person', '12345') diff --git a/tests/unit_tests/pipeline/test_resprule.py b/tests/unit_tests/pipeline/test_resprule.py index 69df165b..63dfbfd0 100644 --- a/tests/unit_tests/pipeline/test_resprule.py +++ b/tests/unit_tests/pipeline/test_resprule.py @@ -14,11 +14,11 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message def get_modules(): """Lazy import to ensure proper initialization order""" # Import pipelinemgr first to trigger proper stage registration - pipelinemgr = import_module('pkg.pipeline.pipelinemgr') - resprule = import_module('pkg.pipeline.resprule.resprule') - entities = import_module('pkg.pipeline.entities') - rule = import_module('pkg.pipeline.resprule.rule') - rule_entities = import_module('pkg.pipeline.resprule.entities') + # pipelinemgr = import_module('langbot.pkg.pipeline.pipelinemgr') + resprule = import_module('langbot.pkg.pipeline.resprule.resprule') + entities = import_module('langbot.pkg.pipeline.entities') + rule = import_module('langbot.pkg.pipeline.resprule.rule') + rule_entities = import_module('langbot.pkg.pipeline.resprule.entities') return resprule, entities, rule, rule_entities @@ -28,11 +28,7 @@ async def test_person_message_skip(mock_app, sample_query): resprule, entities, rule, rule_entities = get_modules() sample_query.launcher_type = provider_session.LauncherTypes.PERSON - sample_query.pipeline_config = { - 'trigger': { - 'group-respond-rules': {} - } - } + sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}} stage = resprule.GroupRespondRuleCheckStage(mock_app) await stage.initialize(sample_query.pipeline_config) @@ -50,18 +46,13 @@ async def test_group_message_no_match(mock_app, sample_query): sample_query.launcher_type = provider_session.LauncherTypes.GROUP sample_query.launcher_id = '12345' - sample_query.pipeline_config = { - 'trigger': { - 'group-respond-rules': {} - } - } + sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}} # Create mock rule matcher that doesn't match mock_rule = Mock(spec=rule.GroupRespondRule) - mock_rule.match = AsyncMock(return_value=rule_entities.RuleJudgeResult( - matching=False, - replacement=sample_query.message_chain - )) + mock_rule.match = AsyncMock( + return_value=rule_entities.RuleJudgeResult(matching=False, replacement=sample_query.message_chain) + ) stage = resprule.GroupRespondRuleCheckStage(mock_app) await stage.initialize(sample_query.pipeline_config) @@ -81,23 +72,14 @@ async def test_group_message_match(mock_app, sample_query): sample_query.launcher_type = provider_session.LauncherTypes.GROUP sample_query.launcher_id = '12345' - sample_query.pipeline_config = { - 'trigger': { - 'group-respond-rules': {} - } - } + sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}} # Create new message chain after rule processing - new_chain = platform_message.MessageChain([ - platform_message.Plain(text='Processed message') - ]) + new_chain = platform_message.MessageChain([platform_message.Plain(text='Processed message')]) # Create mock rule matcher that matches mock_rule = Mock(spec=rule.GroupRespondRule) - mock_rule.match = AsyncMock(return_value=rule_entities.RuleJudgeResult( - matching=True, - replacement=new_chain - )) + mock_rule.match = AsyncMock(return_value=rule_entities.RuleJudgeResult(matching=True, replacement=new_chain)) stage = resprule.GroupRespondRuleCheckStage(mock_app) await stage.initialize(sample_query.pipeline_config) @@ -115,27 +97,21 @@ async def test_group_message_match(mock_app, sample_query): async def test_atbot_rule_match(mock_app, sample_query): """Test AtBotRule removes At component""" resprule, entities, rule, rule_entities = get_modules() - atbot_module = import_module('pkg.pipeline.resprule.rules.atbot') + atbot_module = import_module('langbot.pkg.pipeline.resprule.rules.atbot') sample_query.launcher_type = provider_session.LauncherTypes.GROUP sample_query.adapter.bot_account_id = '999' # Create message chain with At component - message_chain = platform_message.MessageChain([ - platform_message.At(target='999'), - platform_message.Plain(text='Hello bot') - ]) + message_chain = platform_message.MessageChain( + [platform_message.At(target='999'), platform_message.Plain(text='Hello bot')] + ) sample_query.message_chain = message_chain atbot_rule = atbot_module.AtBotRule(mock_app) await atbot_rule.initialize() - result = await atbot_rule.match( - str(message_chain), - message_chain, - {}, - sample_query - ) + result = await atbot_rule.match(str(message_chain), message_chain, {}, sample_query) assert result.matching is True # At component should be removed @@ -147,25 +123,18 @@ async def test_atbot_rule_match(mock_app, sample_query): async def test_atbot_rule_no_match(mock_app, sample_query): """Test AtBotRule when no At component present""" resprule, entities, rule, rule_entities = get_modules() - atbot_module = import_module('pkg.pipeline.resprule.rules.atbot') + atbot_module = import_module('langbot.pkg.pipeline.resprule.rules.atbot') sample_query.launcher_type = provider_session.LauncherTypes.GROUP sample_query.adapter.bot_account_id = '999' # Create message chain without At component - message_chain = platform_message.MessageChain([ - platform_message.Plain(text='Hello') - ]) + message_chain = platform_message.MessageChain([platform_message.Plain(text='Hello')]) sample_query.message_chain = message_chain atbot_rule = atbot_module.AtBotRule(mock_app) await atbot_rule.initialize() - result = await atbot_rule.match( - str(message_chain), - message_chain, - {}, - sample_query - ) + result = await atbot_rule.match(str(message_chain), message_chain, {}, sample_query) assert result.matching is False 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..c5811e3c --- /dev/null +++ b/tests/unit_tests/storage/test_storage_provider_selection.py @@ -0,0 +1,92 @@ +""" +Tests for storage manager and provider selection +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from langbot.pkg.storage.mgr import StorageMgr +from langbot.pkg.storage.providers.localstorage import LocalStorageProvider +from langbot.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/package.json b/web/package.json index abdc435b..36e6acf3 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.0.1", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-context-menu": "^2.2.15", "@radix-ui/react-dialog": "^1.1.14", 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 ff3a70b9..48f7c05a 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -117,7 +117,6 @@ export default function BotForm({ useEffect(() => { setBotFormValues(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 复制到剪贴板的辅助函数 - 使用页面上的真实input元素 @@ -614,7 +613,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/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 6d13e99c..dd2178f2 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -11,18 +11,23 @@ import { FormMessage, } from '@/components/ui/form'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { extractI18nObject } from '@/i18n/I18nProvider'; export default function DynamicFormComponent({ itemConfigList, onSubmit, initialValues, + onFileUploaded, }: { itemConfigList: IDynamicFormItemSchema[]; onSubmit?: (val: object) => unknown; initialValues?: Record; + onFileUploaded?: (fileKey: string) => void; }) { + const isInitialMount = useRef(true); + const previousInitialValues = useRef(initialValues); + // 根据 itemConfigList 动态生成 zod schema const formSchema = z.object( itemConfigList.reduce( @@ -53,6 +58,12 @@ export default function DynamicFormComponent({ case 'knowledge-base-selector': fieldSchema = z.string(); break; + case 'knowledge-base-multi-selector': + fieldSchema = z.array(z.string()); + break; + case 'bot-selector': + fieldSchema = z.string(); + break; case 'prompt-editor': fieldSchema = z.array( z.object({ @@ -97,9 +108,24 @@ export default function DynamicFormComponent({ }); // 当 initialValues 变化时更新表单值 + // 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单 useEffect(() => { console.log('initialValues', initialValues); - if (initialValues) { + + // 首次挂载时,使用 initialValues 初始化表单 + if (isInitialMount.current) { + isInitialMount.current = false; + previousInitialValues.current = initialValues; + return; + } + + // 检查 initialValues 是否真的发生了实质性变化 + // 使用 JSON.stringify 进行深度比较 + const hasRealChange = + JSON.stringify(previousInitialValues.current) !== + JSON.stringify(initialValues); + + if (initialValues && hasRealChange) { // 合并默认值和初始值 const mergedValues = itemConfigList.reduce( (acc, item) => { @@ -112,6 +138,8 @@ export default function DynamicFormComponent({ Object.entries(mergedValues).forEach(([key, value]) => { form.setValue(key as keyof FormValues, value); }); + + previousInitialValues.current = initialValues; } }, [initialValues, form, itemConfigList]); @@ -149,7 +177,11 @@ export default function DynamicFormComponent({ {config.required && *} - + {config.description && (

diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 60062c22..8c8c24bd 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -1,6 +1,7 @@ import { DynamicFormItemType, IDynamicFormItemSchema, + IFileConfig, } from '@/app/infra/entities/form/dynamic'; import { Input } from '@/components/ui/input'; import { @@ -16,7 +17,7 @@ import { ControllerRenderProps } from 'react-hook-form'; import { Button } from '@/components/ui/button'; import { useEffect, useState } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; -import { LLMModel } from '@/app/infra/entities/api'; +import { LLMModel, Bot } from '@/app/infra/entities/api'; import { KnowledgeBase } from '@/app/infra/entities/api'; import { toast } from 'sonner'; import { @@ -27,19 +28,65 @@ import { import { useTranslation } from 'react-i18next'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Plus, X } from 'lucide-react'; export default function DynamicFormItemComponent({ config, field, + onFileUploaded, }: { config: IDynamicFormItemSchema; // eslint-disable-next-line @typescript-eslint/no-explicit-any field: ControllerRenderProps; + onFileUploaded?: (fileKey: string) => void; }) { const [llmModels, setLlmModels] = useState([]); 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 => { + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + + if (file.size > MAX_FILE_SIZE) { + toast.error(t('plugins.fileUpload.tooLarge')); + return null; + } + + try { + setUploading(true); + const response = await httpClient.uploadPluginConfigFile(file); + toast.success(t('plugins.fileUpload.success')); + + // 通知父组件文件已上传 + onFileUploaded?.(response.file_key); + + return { + file_key: response.file_key, + mimetype: file.type, + }; + } catch (error) { + toast.error( + t('plugins.fileUpload.failed') + ': ' + (error as Error).message, + ); + return null; + } finally { + setUploading(false); + } + }; + useEffect(() => { if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) { httpClient @@ -48,20 +95,36 @@ export default function DynamicFormItemComponent({ setLlmModels(resp.models); }) .catch((err) => { - toast.error('获取 LLM 模型列表失败:' + err.message); + toast.error('Failed to get LLM model list: ' + err.message); }); } }, [config.type]); useEffect(() => { - if (config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR) { + if ( + config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR || + config.type === DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR + ) { httpClient .getKnowledgeBases() .then((resp) => { setKnowledgeBases(resp.bases); }) .catch((err) => { - toast.error('获取知识库列表失败:' + err.message); + toast.error('Failed to get knowledge base list: ' + err.message); + }); + } + }, [config.type]); + + useEffect(() => { + if (config.type === DynamicFormItemType.BOT_SELECTOR) { + httpClient + .getBots() + .then((resp) => { + setBots(resp.bots); + }) + .catch((err) => { + toast.error('Failed to get bot list: ' + err.message); }); } }, [config.type]); @@ -80,6 +143,9 @@ export default function DynamicFormItemComponent({ case DynamicFormItemType.STRING: return ; + case DynamicFormItemType.TEXT: + return