mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
141 Commits
v4.6.0
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e0275ee08 | ||
|
|
3fa7389ba9 | ||
|
|
84a3b1f465 | ||
|
|
377d011da2 | ||
|
|
e0d72969e3 | ||
|
|
a65b7ad413 | ||
|
|
45df44e01b | ||
|
|
d8addb105a | ||
|
|
f17ccad665 | ||
|
|
120ceb0b55 | ||
|
|
8a6f80a181 | ||
|
|
b19e468668 | ||
|
|
aeac79e1b3 | ||
|
|
b89a240250 | ||
|
|
13f42857f5 | ||
|
|
61f3f31edc | ||
|
|
3663d9dc10 | ||
|
|
89ec86c530 | ||
|
|
d9ba2a17ff | ||
|
|
c4ea6188f9 | ||
|
|
5d9f6ec763 | ||
|
|
b73847f1a6 | ||
|
|
d6e1e79f07 | ||
|
|
525008b8b2 | ||
|
|
bbf77bac4c | ||
|
|
fc6e414be4 | ||
|
|
e60cb6ad0e | ||
|
|
c90f2d6a12 | ||
|
|
fe8a738cd7 | ||
|
|
604cc53973 | ||
|
|
195b694ecc | ||
|
|
d21f23beee | ||
|
|
558587883b | ||
|
|
2e6a1daf4f | ||
|
|
1fc5e75f93 | ||
|
|
a332206ba3 | ||
|
|
8e620dc635 | ||
|
|
c9a21ebace | ||
|
|
a05cdcac50 | ||
|
|
ecfb2bfb34 | ||
|
|
e17dba0a98 | ||
|
|
6b138943ce | ||
|
|
eb0e6aff68 | ||
|
|
4d0095626a | ||
|
|
aa0a501ade | ||
|
|
68ef7bd2c4 | ||
|
|
61dc5de085 | ||
|
|
63bdd71e22 | ||
|
|
9ea5b50802 | ||
|
|
1cd586634d | ||
|
|
45bedbe70e | ||
|
|
f7f1dde7b5 | ||
|
|
ba06555078 | ||
|
|
840fa39979 | ||
|
|
b295416e6c | ||
|
|
914f77ff37 | ||
|
|
b0b7b914d8 | ||
|
|
12713aad45 | ||
|
|
02e12cc1e4 | ||
|
|
61f08f3218 | ||
|
|
75c2a063cc | ||
|
|
b4773c4e48 | ||
|
|
fb73da8735 | ||
|
|
679e549b1d | ||
|
|
898144e9f4 | ||
|
|
b99c5561fc | ||
|
|
b2f4b91979 | ||
|
|
4528000fc4 | ||
|
|
96e40eaf25 | ||
|
|
197258ae91 | ||
|
|
19f417174c | ||
|
|
9c82eeddeb | ||
|
|
f11e01b549 | ||
|
|
863b26c3fa | ||
|
|
b788858f9e | ||
|
|
de8a7df6c2 | ||
|
|
ba5b481617 | ||
|
|
07ad846e96 | ||
|
|
30945aafdd | ||
|
|
24c15b4479 | ||
|
|
1d4c5bbdf1 | ||
|
|
57fcec011d | ||
|
|
455e3db28d | ||
|
|
8caab43b00 | ||
|
|
7479545339 | ||
|
|
10ee30695a | ||
|
|
a9a262eaae | ||
|
|
a8594b76cd | ||
|
|
11ee0fef5d | ||
|
|
9a9ba34717 | ||
|
|
312e47bf46 | ||
|
|
628865fd06 | ||
|
|
806a03cd53 | ||
|
|
24bd90fcf6 | ||
|
|
d2765577c8 | ||
|
|
60ca688bcb | ||
|
|
76d8eea41d | ||
|
|
635c3a04d8 | ||
|
|
dde97abe38 | ||
|
|
90a22d894d | ||
|
|
88ef9cd6ae | ||
|
|
e3595b5c57 | ||
|
|
ce82f87e43 | ||
|
|
854b291c5a | ||
|
|
9780fd059c | ||
|
|
adc65f66eb | ||
|
|
ae772074a1 | ||
|
|
16c1e9edd1 | ||
|
|
3ab9ffb7b7 | ||
|
|
82e2123fe7 | ||
|
|
7a65f3d2f4 | ||
|
|
b5b5d499e5 | ||
|
|
173f9e9c30 | ||
|
|
a610c72067 | ||
|
|
d210a49fae | ||
|
|
b015c248ea | ||
|
|
4a559ea770 | ||
|
|
e306751863 | ||
|
|
2f51f5f33e | ||
|
|
74a2a61fc1 | ||
|
|
b6c0345b3e | ||
|
|
6421a6f5cb | ||
|
|
daf56e5dc2 | ||
|
|
cb7c9af25c | ||
|
|
45e61befac | ||
|
|
ea50ba10e6 | ||
|
|
5c4a727e74 | ||
|
|
867f05c4ad | ||
|
|
b06b32306f | ||
|
|
dbfcb70f8d | ||
|
|
e64d56c4ac | ||
|
|
8f0da7943c | ||
|
|
e62ff7e520 | ||
|
|
86e951916e | ||
|
|
6bf08466de | ||
|
|
5e36dd480d | ||
|
|
0e2cd8c018 | ||
|
|
b4f92eba38 | ||
|
|
905e48c8ed | ||
|
|
10ec79312e | ||
|
|
24f779ff95 |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.github
|
||||
.venv
|
||||
.vscode
|
||||
.data
|
||||
.temp
|
||||
web/.next
|
||||
web/node_modules
|
||||
web/.env
|
||||
5
.github/workflows/build-docker-image.yml
vendored
5
.github/workflows/build-docker-image.yml
vendored
@@ -3,7 +3,6 @@ on:
|
||||
## 发布release的时候会自动构建
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
publish-docker-image:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -42,7 +41,7 @@ jobs:
|
||||
run: docker buildx create --name mybuilder --use
|
||||
- name: Build for Release # only relase, exlude pre-release
|
||||
if: ${{ github.event.release.prerelease == false }}
|
||||
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
|
||||
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
|
||||
- name: Build for Pre-release # no update for latest tag
|
||||
if: ${{ github.event.release.prerelease == true }}
|
||||
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push
|
||||
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push
|
||||
60
.github/workflows/lint.yml
vendored
Normal file
60
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
ruff:
|
||||
name: Ruff Lint & Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --dev
|
||||
|
||||
- name: Run ruff check
|
||||
run: uv run ruff check src
|
||||
|
||||
- name: Run ruff format
|
||||
run: uv run ruff format src --check
|
||||
|
||||
frontend:
|
||||
name: Frontend Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '25'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: pnpm install
|
||||
|
||||
- name: Run lint
|
||||
working-directory: web
|
||||
run: pnpm lint
|
||||
2
.github/workflows/run-tests.yml
vendored
2
.github/workflows/run-tests.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
python-version: ['3.11', '3.12', '3.13']
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,7 +42,6 @@ botpy.log*
|
||||
test.py
|
||||
/web_ui
|
||||
.venv/
|
||||
uv.lock
|
||||
/test
|
||||
plugins.bak
|
||||
coverage.xml
|
||||
|
||||
22
AGENTS.md
22
AGENTS.md
@@ -8,16 +8,17 @@ LangBot is a open-source LLM native instant messaging bot development platform,
|
||||
|
||||
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.
|
||||
- `./src/langbot`: The main python package of the project, below are the main modules in this package:
|
||||
- `./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
|
||||
|
||||
@@ -69,6 +70,7 @@ Plugin Runtime automatically starts each installed plugin and interacts through
|
||||
- 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.
|
||||
- If you changed the definition of database entities, please update the migration file in `src/langbot/pkg/persistence/migrations/` and update the constants.py file in `src/langbot/pkg/utils/constants.py` with the new migration number.
|
||||
|
||||
## Some Principles
|
||||
|
||||
|
||||
@@ -20,4 +20,4 @@ RUN apt update \
|
||||
&& uv sync \
|
||||
&& touch /.dockerenv
|
||||
|
||||
CMD [ "uv", "run", "main.py" ]
|
||||
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
||||
27
README.md
27
README.md
@@ -1,33 +1,35 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>使用 LangBot 快速构建、调试、部署即时通信机器人。</h3>
|
||||
|
||||
[English](README_EN.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://qm.qq.com/q/JLi38whHum)
|
||||
[](https://qm.qq.com/q/DxZZcNxM1W)
|
||||
[](https://deepwiki.com/langbot-app/LangBot)
|
||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">项目主页</a> |
|
||||
<a href="https://docs.langbot.app/zh/insight/features.html">规格特性</a> |
|
||||
<a href="https://docs.langbot.app/zh/insight/guide.html">部署文档</a> |
|
||||
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">插件介绍</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交插件</a>
|
||||
|
||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API 集成</a> |
|
||||
<a href="https://space.langbot.app">插件市场</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot 是一个开源的大语言模型原生即时通信机器人开发平台,旨在提供开箱即用的 IM 机器人开发体验,具有 Agent、RAG、MCP 等多种 LLM 应用功能,适配全球主流即时通信平台,并提供丰富的 API 接口,支持自定义开发。
|
||||
|
||||
## 📦 开始使用
|
||||
|
||||
@@ -83,11 +85,15 @@ docker compose up -d
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态、流式输出能力,自带 RAG(知识库)实现,并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)等 LLMOps 平台。
|
||||
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram 等平台。
|
||||
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-zh-rounded.png" />
|
||||
|
||||
|
||||
- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态、流式输出能力,自带 RAG(知识库)实现,并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)等 LLMOps 平台。
|
||||
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram、KOOK、Slack、LINE 等平台。
|
||||
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。
|
||||
- 🧩 插件扩展、活跃社区:高稳定性、高安全性的生产级插件系统,支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
|
||||
- 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件。
|
||||
- 😻 Web 管理面板:提供先进的 WebUI 管理面板,用最直观的方式配置、管理、监控机器人。
|
||||
- 📊 生产级特性:支持多流水线配置,不同机器人用于不同应用场景。具有全面的监控和异常处理能力。已被多家企业采用。
|
||||
|
||||
详细规格特性请访问[文档](https://docs.langbot.app/zh/insight/features.html)。
|
||||
|
||||
@@ -108,6 +114,7 @@ docker compose up -d
|
||||
| 微信公众号 | ✅ | |
|
||||
| 飞书 | ✅ | |
|
||||
| 钉钉 | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
|
||||
22
README_EN.md
22
README_EN.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>Quickly build, debug, and ship IM bots with LangBot.</h3>
|
||||
|
||||
English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -15,15 +17,16 @@ English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語]
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">Home</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features.html">Features</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Deployment</a> |
|
||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Submit Plugin</a>
|
||||
<a href="https://docs.langbot.app/en/tags/readme.html">API Integration</a> |
|
||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot is an open-source LLM native instant messaging robot development platform, aiming to provide out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, adapting to global instant messaging platforms, and providing rich API interfaces, supporting custom development.
|
||||
|
||||
## 📦 Getting Started
|
||||
|
||||
@@ -79,11 +82,15 @@ Click the Star and Watch button in the upper right corner of the repository to g
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
|
||||
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc.
|
||||
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) etc. LLMOps platforms.
|
||||
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
|
||||
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods.
|
||||
- 🧩 Plugin Extension, Active Community: High stability, high security production-level plugin system; Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
|
||||
- 😻 Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
|
||||
- 📊 Production-grade Features: Supports multiple pipeline configurations, different bots can be used for different scenarios. Has comprehensive monitoring and exception handling capabilities.
|
||||
|
||||
For more detailed specifications, please refer to the [documentation](https://docs.langbot.app/en/insight/features.html).
|
||||
|
||||
@@ -107,6 +114,7 @@ Or visit the demo environment: https://demo.langbot.dev/
|
||||
| Personal WeChat | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
22
README_ES.md
22
README_ES.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>Cree, depure y despliegue bots de mensajería instantánea rápidamente con LangBot.</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / Español / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -15,15 +17,16 @@
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">Inicio</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features.html">Características</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Despliegue</a> |
|
||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Enviar Plugin</a>
|
||||
<a href="https://docs.langbot.app/en/tags/readme.html">Integración API</a> |
|
||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot es una plataforma de desarrollo de robots de mensajería instantánea nativa de LLM de código abierto, con el objetivo de proporcionar una experiencia de desarrollo de robots de mensajería instantánea lista para usar, con funciones de aplicación LLM como Agent, RAG, MCP, adaptándose a plataformas de mensajería instantánea globales y proporcionando interfaces API ricas, compatible con desarrollo personalizado.
|
||||
|
||||
## 📦 Comenzar
|
||||
|
||||
@@ -79,11 +82,15 @@ Haga clic en los botones Star y Watch en la esquina superior derecha del reposit
|
||||
|
||||
## ✨ Características
|
||||
|
||||
- 💬 Chat con LLM / Agent: Compatible con múltiples LLMs, adaptado para chats grupales y privados; Admite conversaciones de múltiples rondas, llamadas a herramientas, capacidades multimodales y de salida en streaming. Implementación RAG (base de conocimientos) incorporada, e integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
|
||||
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, etc.
|
||||
- 🛠️ Alta Estabilidad, Rico en Funciones: Control de acceso nativo, limitación de velocidad, filtrado de palabras sensibles, etc.; Fácil de usar, admite múltiples métodos de despliegue. Compatible con múltiples configuraciones de pipeline, diferentes bots para diferentes escenarios.
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 Chat con LLM / Agent: Compatible con múltiples LLMs, adaptado para chats grupales y privados; Admite conversaciones de múltiples rondas, llamadas a herramientas, capacidades multimodales y de salida en streaming. Implementación RAG (base de conocimientos) incorporada, e integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) etc. LLMOps platforms.
|
||||
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
|
||||
- 🛠️ Alta Estabilidad, Rico en Funciones: Control de acceso nativo, limitación de velocidad, filtrado de palabras sensibles, etc.; Fácil de usar, admite múltiples métodos de despliegue.
|
||||
- 🧩 Extensión de Plugin, Comunidad Activa: Sistema de plugin de alta estabilidad, alta seguridad de nivel de producción; Compatible con mecanismos de plugin impulsados por eventos, extensión de componentes, etc.; Integración del protocolo [MCP](https://modelcontextprotocol.io/) de Anthropic; Actualmente cuenta con cientos de plugins.
|
||||
- 😻 Interfaz Web: Admite la gestión de instancias de LangBot a través del navegador. No es necesario escribir archivos de configuración manualmente.
|
||||
- 📊 Características de Nivel de Producción: Compatible con múltiples configuraciones de pipeline, diferentes bots para diferentes escenarios. Cuenta con capacidades completas de monitoreo y manejo de excepciones.
|
||||
|
||||
Para especificaciones más detalladas, consulte la [documentación](https://docs.langbot.app/en/insight/features.html).
|
||||
|
||||
@@ -107,6 +114,7 @@ O visite el entorno de demostración: https://demo.langbot.dev/
|
||||
| WeChat Personal | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
23
README_FR.md
23
README_FR.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>Créez, déboguez et déployez rapidement des bots de messagerie instantanée avec LangBot.</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / Français / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -15,16 +17,16 @@
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">Accueil</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Déploiement</a> |
|
||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Soumettre un Plugin</a>
|
||||
<a href="https://docs.langbot.app/en/tags/readme.html">Intégration API</a> |
|
||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot est une plateforme de développement de robots de messagerie instantanée native LLM open source, visant à fournir une expérience de développement de robots de messagerie instantanée prête à l'emploi, avec des fonctionnalités d'application LLM telles qu'Agent, RAG, MCP, s'adaptant aux plateformes de messagerie instantanée mondiales et fournissant des interfaces API riches, prenant en charge le développement personnalisé.
|
||||
|
||||
## 📦 Commencer
|
||||
|
||||
#### Démarrage Rapide
|
||||
@@ -79,11 +81,15 @@ Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt p
|
||||
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
- 💬 Chat avec LLM / Agent : Prend en charge plusieurs LLM, adapté aux chats de groupe et privés ; Prend en charge les conversations multi-tours, les appels d'outils, les capacités multimodales et de sortie en streaming. Implémentation RAG (base de connaissances) intégrée, et intégration profonde avec [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
|
||||
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, etc.
|
||||
- 🛠️ Haute Stabilité, Riche en Fonctionnalités : Contrôle d'accès natif, limitation de débit, filtrage de mots sensibles, etc. ; Facile à utiliser, prend en charge plusieurs méthodes de déploiement. Prend en charge plusieurs configurations de pipeline, différents bots pour différents scénarios.
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 Chat avec LLM / Agent : Prend en charge plusieurs LLM, adapté aux chats de groupe et privés ; Prend en charge les conversations multi-tours, les appels d'outils, les capacités multimodales et de sortie en streaming. Implémentation RAG (base de connaissances) intégrée, et intégration profonde avec [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) etc. LLMOps platforms.
|
||||
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
|
||||
- 🛠️ Haute Stabilité, Riche en Fonctionnalités : Contrôle d'accès natif, limitation de débit, filtrage de mots sensibles, etc. ; Facile à utiliser, prend en charge plusieurs méthodes de déploiement.
|
||||
- 🧩 Extension de Plugin, Communauté Active : Système de plugin de haute stabilité, haute sécurité de niveau production; Prend en charge les mécanismes de plugin pilotés par événements, l'extension de composants, etc. ; Intégration du protocole [MCP](https://modelcontextprotocol.io/) d'Anthropic ; Dispose actuellement de centaines de plugins.
|
||||
- 😻 Interface Web : Prend en charge la gestion des instances LangBot via le navigateur. Pas besoin d'écrire manuellement les fichiers de configuration.
|
||||
- 📊 Fonctionnalités de Niveau Production : Prend en charge plusieurs configurations de pipeline, différents bots pour différents scénarios. Dispose de capacités complètes de surveillance et de gestion des exceptions.
|
||||
|
||||
Pour des spécifications plus détaillées, veuillez consulter la [documentation](https://docs.langbot.app/en/insight/features.html).
|
||||
|
||||
@@ -107,6 +113,7 @@ Ou visitez l'environnement de démonstration : https://demo.langbot.dev/
|
||||
| WeChat Personnel | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
25
README_JP.md
25
README_JP.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>LangBotでIMボットを素早く構築、デバッグ、デプロイ。</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / 日本語 / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -15,16 +17,16 @@
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">ホーム</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">デプロイ</a> |
|
||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">プラグイン</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">プラグインの提出</a>
|
||||
<a href="https://docs.langbot.app/ja/insight/features.html">機能仕様</a> |
|
||||
<a href="https://docs.langbot.app/ja/insight/guide.html">デプロイ</a> |
|
||||
<a href="https://docs.langbot.app/ja/tags/readme.html">API統合</a> |
|
||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot は、エージェント、RAG、MCP などの LLM アプリケーション機能を備えた、オープンソースの LLM ネイティブのインスタントメッセージングロボット開発プラットフォームです。世界中のインスタントメッセージングプラットフォームに適応し、豊富な API インターフェースを提供し、カスタム開発をサポートします。
|
||||
|
||||
## 📦 始め方
|
||||
|
||||
#### クイックスタート
|
||||
@@ -79,11 +81,15 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
|
||||
|
||||
## ✨ 機能
|
||||
|
||||
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG(知識ベース)を組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) などの LLMOps プラットフォームと深く統合。
|
||||
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram など、複数のプラットフォームをサポートしています。
|
||||
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG(知識ベース)を組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)などの LLMOps プラットフォームと深く統合。
|
||||
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram、KOOK、Slack、LINE など、複数のプラットフォームをサポートしています。
|
||||
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。
|
||||
- 🧩 プラグイン拡張、活発なコミュニティ: 高い安定性、高いセキュリティの生産レベルのプラグインシステム;イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
|
||||
- 😻 Web UI: ブラウザを通じてLangBotインスタンスを管理することをサポート。
|
||||
- 📊 生産レベルの機能: 複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。包括的な監視と例外処理機能を備えています。
|
||||
|
||||
詳細な仕様については、[ドキュメント](https://docs.langbot.app/en/insight/features.html)を参照してください。
|
||||
|
||||
@@ -107,6 +113,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
|
||||
| 個人WeChat | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
23
README_KO.md
23
README_KO.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>LangBot으로 IM 봇을 빠르게 구축, 디버그 및 배포하세요.</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / 한국어 / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -15,16 +17,16 @@
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">홈</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features.html">기능 사양</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">배포</a> |
|
||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">플러그인</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">플러그인 제출</a>
|
||||
<a href="https://docs.langbot.app/en/tags/readme.html">API 통합</a> |
|
||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot은 오픈 소스 LLM 네이티브 인스턴트 메시징 로봇 개발 플랫폼으로, Agent, RAG, MCP 등 다양한 LLM 애플리케이션 기능을 갖춘 즉시 사용 가능한 IM 로봇 개발 경험을 제공하며, 글로벌 인스턴트 메시징 플랫폼에 적응하고 풍부한 API 인터페이스를 제공하여 맞춤형 개발을 지원합니다.
|
||||
|
||||
## 📦 시작하기
|
||||
|
||||
#### 빠른 시작
|
||||
@@ -79,11 +81,15 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
|
||||
|
||||
## ✨ 기능
|
||||
|
||||
- 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 등의 LLMOps 플랫폼과 깊이 통합됩니다.
|
||||
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram 등을 지원합니다.
|
||||
- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다. 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다.
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)등의 LLMOps 플랫폼과 깊이 통합됩니다.
|
||||
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE 등을 지원합니다.
|
||||
- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다.
|
||||
- 🧩 플러그인 확장, 활발한 커뮤니티: 고안정성, 고보안 생산 수준의 플러그인 시스템; 이벤트 기반, 컴포넌트 확장 등의 플러그인 메커니즘을 지원; Anthropic [MCP 프로토콜](https://modelcontextprotocol.io/) 통합; 현재 수백 개의 플러그인이 있습니다.
|
||||
- 😻 웹 UI: 브라우저를 통해 LangBot 인스턴스 관리를 지원합니다. 구성 파일을 수동으로 작성할 필요가 없습니다.
|
||||
- 📊 생산 수준의 기능: 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다. 포괄적인 모니터링 및 예외 처리 기능을 갖추고 있습니다.
|
||||
|
||||
더 자세한 사양은 [문서](https://docs.langbot.app/en/insight/features.html)를 참조하세요.
|
||||
|
||||
@@ -105,6 +111,7 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| 개인 WeChat | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
23
README_RU.md
23
README_RU.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>Быстро создавайте, отлаживайте и развертывайте IM-ботов с LangBot.</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / Русский / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -15,16 +17,16 @@
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">Главная</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features.html">Характеристики</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Развертывание</a> |
|
||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Плагин</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Отправить плагин</a>
|
||||
<a href="https://docs.langbot.app/en/tags/readme.html">Интеграция API</a> |
|
||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot — это платформа разработки ботов для мгновенных сообщений на основе LLM с открытым исходным кодом, целью которой является предоставление готового к использованию опыта разработки ботов для IM, с функциями приложений LLM, такими как Agent, RAG, MCP, адаптацией к глобальным платформам мгновенных сообщений и предоставлением богатых API-интерфейсов, поддерживающих пользовательскую разработку.
|
||||
|
||||
## 📦 Начало работы
|
||||
|
||||
#### Быстрый старт
|
||||
@@ -79,11 +81,15 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
|
||||
|
||||
## ✨ Функции
|
||||
|
||||
- 💬 Чат с LLM / Agent: Поддержка нескольких LLM, адаптация к групповым и личным чатам; Поддержка многораундовых разговоров, вызовов инструментов, мультимодальных возможностей и потоковой передачи. Встроенная реализация RAG (база знаний) и глубокая интеграция с [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) 등의 LLMOps 플랫포트폼과 깊이 통합됩니다.
|
||||
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram и т.д.
|
||||
- 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания. Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев.
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 Чат с LLM / Agent: Поддержка нескольких LLM, адаптация к групповым и личным чатам; Поддержка многораундовых разговоров, вызовов инструментов, мультимодальных возможностей и потоковой передачи. Встроенная реализация RAG (база знаний) и глубокая интеграция с [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) и др. LLMOps платформами.
|
||||
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE и т.д.
|
||||
- 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания.
|
||||
- 🧩 Расширение плагинов, активное сообщество: Высокая стабильность, высокая безопасность уровня производства; Поддержка механизмов плагинов, управляемых событиями, расширения компонентов и т.д.; Интеграция протокола [MCP](https://modelcontextprotocol.io/) от Anthropic; В настоящее время сотни плагинов.
|
||||
- 😻 Веб-интерфейс: Поддержка управления экземплярами LangBot через браузер. Нет необходимости вручную писать конфигурационные файлы.
|
||||
- 📊 Функции уровня производства: Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев. Имеет комплексные возможности мониторинга и обработки исключений.
|
||||
|
||||
Для более подробных спецификаций обратитесь к [документации](https://docs.langbot.app/en/insight/features.html).
|
||||
|
||||
@@ -105,6 +111,7 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| Личный WeChat | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
25
README_TW.md
25
README_TW.md
@@ -1,10 +1,12 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center"><a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>使用 LangBot 快速建構、除錯和部署 IM 機器人。</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -15,16 +17,16 @@
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">主頁</a> |
|
||||
<a href="https://docs.langbot.app/zh/insight/features.html">規格特性</a> |
|
||||
<a href="https://docs.langbot.app/zh/insight/guide.html">部署文件</a> |
|
||||
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">外掛介紹</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交外掛</a>
|
||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API 整合</a> |
|
||||
<a href="https://space.langbot.app">外掛市場</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot 是一個開源的大語言模型原生即時通訊機器人開發平台,旨在提供開箱即用的 IM 機器人開發體驗,具有 Agent、RAG、MCP 等多種 LLM 應用功能,適配全球主流即時通訊平台,並提供豐富的 API 介面,支援自定義開發。
|
||||
|
||||
## 📦 開始使用
|
||||
|
||||
#### 快速部署
|
||||
@@ -79,11 +81,15 @@ docker compose up -d
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 💬 大模型對話、Agent:支援多種大模型,適配群聊和私聊;具有多輪對話、工具調用、多模態、流式輸出能力,自帶 RAG(知識庫)實現,並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 等 LLMOps 平台。
|
||||
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram 等平台。
|
||||
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 大模型對話、Agent:支援多種大模型,適配群聊和私聊;具有多輪對話、工具調用、多模態、流式輸出能力,自帶 RAG(知識庫)實現,並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)等 LLMOps 平台。
|
||||
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram、KOOK、Slack、LINE 等平台。
|
||||
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。
|
||||
- 🧩 外掛擴展、活躍社群:高穩定性、高安全性的生產級外掛系統;支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
|
||||
- 😻 Web 管理面板:支援通過瀏覽器管理 LangBot 實例,不再需要手動編寫配置文件。
|
||||
- 😻 Web 管理面板:提供先進的 WebUI 管理面板,用最直觀的方式配置、管理、監控機器人。
|
||||
- 📊 生產級特性:支援多流水線配置,不同機器人用於不同應用場景。具有全面的監控和異常處理能力。
|
||||
|
||||
詳細規格特性請訪問[文件](https://docs.langbot.app/zh/insight/features.html)。
|
||||
|
||||
@@ -105,6 +111,7 @@ docker compose up -d
|
||||
| 企微對外客服 | ✅ | |
|
||||
| 企微智能機器人 | ✅ | |
|
||||
| 微信公眾號 | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
23
README_VI.md
23
README_VI.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>Xây dựng, gỡ lỗi và triển khai bot IM nhanh chóng với LangBot.</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / Tiếng Việt
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -15,16 +17,16 @@
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">Trang chủ</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features.html">Tính năng</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Triển khai</a> |
|
||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Gửi Plugin</a>
|
||||
<a href="https://docs.langbot.app/en/tags/readme.html">Tích hợp API</a> |
|
||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot là một nền tảng phát triển robot nhắn tin tức thời gốc LLM mã nguồn mở, nhằm mục đích cung cấp trải nghiệm phát triển robot IM sẵn sàng sử dụng, với các chức năng ứng dụng LLM như Agent, RAG, MCP, thích ứng với các nền tảng nhắn tin tức thời toàn cầu và cung cấp giao diện API phong phú, hỗ trợ phát triển tùy chỉnh.
|
||||
|
||||
## 📦 Bắt đầu
|
||||
|
||||
#### Khởi động Nhanh
|
||||
@@ -79,11 +81,15 @@ Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu t
|
||||
|
||||
## ✨ Tính năng
|
||||
|
||||
- 💬 Chat với LLM / Agent: Hỗ trợ nhiều LLM, thích ứng với chat nhóm và chat riêng tư; Hỗ trợ các cuộc trò chuyện nhiều vòng, gọi công cụ, khả năng đa phương thức và đầu ra streaming. Triển khai RAG (cơ sở kiến thức) tích hợp sẵn và tích hợp sâu với [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) v.v. LLMOps platforms.
|
||||
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, v.v.
|
||||
- 🛠️ Độ ổn định Cao, Tính năng Phong phú: Kiểm soát truy cập gốc, giới hạn tốc độ, lọc từ nhạy cảm, v.v.; Dễ sử dụng, hỗ trợ nhiều phương pháp triển khai. Hỗ trợ nhiều cấu hình pipeline, các bot khác nhau cho các kịch bản khác nhau.
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 Chat với LLM / Agent: Hỗ trợ nhiều LLM, thích ứng với chat nhóm và chat riêng tư; Hỗ trợ các cuộc trò chuyện nhiều vòng, gọi công cụ, khả năng đa phương thức và đầu ra streaming. Triển khai RAG (cơ sở kiến thức) tích hợp sẵn và tích hợp sâu với [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) v.v. LLMOps platforms.
|
||||
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, v.v.
|
||||
- 🛠️ Độ ổn định Cao, Tính năng Phong phú: Kiểm soát truy cập gốc, giới hạn tốc độ, lọc từ nhạy cảm, v.v.; Dễ sử dụng, hỗ trợ nhiều phương pháp triển khai.
|
||||
- 🧩 Mở rộng Plugin, Cộng đồng Hoạt động: Hỗ trợ các cơ chế plugin hướng sự kiện, mở rộng thành phần, v.v.; Tích hợp giao thức [MCP](https://modelcontextprotocol.io/) của Anthropic; Hiện có hàng trăng plugin.
|
||||
- 😻 Giao diện Web: Hỗ trợ quản lý các phiên bản LangBot thông qua trình duyệt. Không cần viết tệp cấu hình thủ công.
|
||||
- 📊 Tính năng Cấp sản xuất: Hỗ trợ nhiều cấu hình pipeline, các bot khác nhau cho các kịch bản khác nhau. Có khả năng giám sát toàn diện và xử lý ngoại lệ.
|
||||
|
||||
Để biết thêm thông số kỹ thuật chi tiết, vui lòng tham khảo [tài liệu](https://docs.langbot.app/en/insight/features.html).
|
||||
|
||||
@@ -105,6 +111,7 @@ Hoặc truy cập môi trường demo: https://demo.langbot.dev/
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| WeChat Cá nhân | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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:
|
||||
@@ -15,17 +14,15 @@ services:
|
||||
restart: on-failure
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
command: ["uv", "run", "-m", "langbot_plugin.cli.__init__", "rt"]
|
||||
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"]
|
||||
networks:
|
||||
- langbot_network
|
||||
|
||||
langbot:
|
||||
image: rockchin/langbot:latest
|
||||
container_name: langbot
|
||||
platform: linux/amd64 # For Apple Silicon compatibility
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./plugins:/app/plugins
|
||||
restart: on-failure
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
259
docs/SEEKDB_INTEGRATION.md
Normal file
259
docs/SEEKDB_INTEGRATION.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# SeekDB Vector Database Integration
|
||||
|
||||
This document describes how to use OceanBase SeekDB as the vector database backend for LangBot's knowledge base feature.
|
||||
|
||||
## What is SeekDB?
|
||||
|
||||
**OceanBase SeekDB** is an AI-native search database that unifies relational, vector, text, JSON and GIS in a single engine, enabling hybrid search and in-database AI workflows. It's developed by OceanBase and released under Apache 2.0 license.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Hybrid Search**: Combine vector search, full-text search and relational query in a single statement
|
||||
- **Multi-Model Support**: Support relational, vector, text, JSON and GIS in a single engine
|
||||
- **Lightweight**: Requires as little as 1 CPU core and 2 GB of memory
|
||||
- **Multiple Deployment Modes**: Supports both embedded mode and client/server mode
|
||||
- **MySQL Compatible**: Powered by OceanBase engine with full ACID compliance and MySQL compatibility
|
||||
|
||||
## Installation
|
||||
|
||||
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
|
||||
|
||||
If you need to install it manually:
|
||||
|
||||
```bash
|
||||
pip install pyseekdb
|
||||
```
|
||||
|
||||
## ⚠️ Platform Compatibility
|
||||
|
||||
### Embedded Mode
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||
|
||||
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
|
||||
|
||||
### Server Mode (Docker)
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Linux | ✅ Supported | Full Docker support |
|
||||
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||
| Windows | ⚠️ Untested | Should work but not yet tested |
|
||||
|
||||
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
|
||||
- Using ChromaDB or Qdrant as alternatives
|
||||
- Connecting to a remote SeekDB server on Linux if available
|
||||
|
||||
### Server Mode (Remote Connection)
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
|
||||
|
||||
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Embedded Mode (Recommended for Development)
|
||||
|
||||
Embedded mode runs SeekDB directly within the LangBot process, storing data locally. This is the simplest setup and requires no external services.
|
||||
|
||||
Edit your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
vdb:
|
||||
use: seekdb
|
||||
seekdb:
|
||||
mode: embedded
|
||||
path: './data/seekdb' # Path to store SeekDB data
|
||||
database: 'langbot' # Database name
|
||||
```
|
||||
|
||||
### Server Mode (For Production)
|
||||
|
||||
Server mode connects to a remote SeekDB server or OceanBase server. This is recommended for production deployments.
|
||||
|
||||
#### SeekDB Server
|
||||
|
||||
```yaml
|
||||
vdb:
|
||||
use: seekdb
|
||||
seekdb:
|
||||
mode: server
|
||||
host: 'localhost'
|
||||
port: 2881
|
||||
database: 'langbot'
|
||||
user: 'root'
|
||||
password: '' # Can also use SEEKDB_PASSWORD env var
|
||||
```
|
||||
|
||||
#### OceanBase Server
|
||||
|
||||
If you're using OceanBase with seekdb capabilities:
|
||||
|
||||
```yaml
|
||||
vdb:
|
||||
use: seekdb
|
||||
seekdb:
|
||||
mode: server
|
||||
host: 'localhost'
|
||||
port: 2881
|
||||
tenant: 'sys' # OceanBase tenant name
|
||||
database: 'langbot'
|
||||
user: 'root'
|
||||
password: ''
|
||||
```
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|-----------|----------|--------------|-------------|
|
||||
| `mode` | No | `embedded` | Deployment mode: `embedded` or `server` |
|
||||
| `path` | No | `./data/seekdb` | Data directory for embedded mode |
|
||||
| `database` | No | `langbot` | Database name |
|
||||
| `host` | No | `localhost` | Server host (server mode only) |
|
||||
| `port` | No | `2881` | Server port (server mode only) |
|
||||
| `user` | No | `root` | Username (server mode only) |
|
||||
| `password` | No | `''` | Password (server mode only) |
|
||||
| `tenant` | No | None | OceanBase tenant (optional, server mode only) |
|
||||
|
||||
## Usage
|
||||
|
||||
Once configured, SeekDB will be used automatically for all knowledge base operations in LangBot:
|
||||
|
||||
1. **Creating Knowledge Bases**: Vectors will be stored in SeekDB collections
|
||||
2. **Adding Documents**: Document embeddings will be indexed in SeekDB
|
||||
3. **Searching**: Vector similarity search will use SeekDB's efficient indexing
|
||||
4. **Deleting**: Document removal will delete vectors from SeekDB
|
||||
|
||||
No code changes are required - just update your configuration!
|
||||
|
||||
## Architecture Details
|
||||
|
||||
### Implementation
|
||||
|
||||
The SeekDB adapter is implemented in `src/langbot/pkg/vector/vdbs/seekdb.py` and follows the same `VectorDatabase` interface as Chroma and Qdrant adapters.
|
||||
|
||||
Key methods:
|
||||
- `add_embeddings()`: Add vectors with metadata to a collection
|
||||
- `search()`: Perform vector similarity search
|
||||
- `delete_by_file_id()`: Delete vectors by file ID metadata
|
||||
- `get_or_create_collection()`: Manage collections
|
||||
- `delete_collection()`: Remove entire collections
|
||||
|
||||
### Vector Storage
|
||||
|
||||
- Collections are created with HNSW (Hierarchical Navigable Small World) index
|
||||
- Default distance metric: Cosine similarity
|
||||
- Default vector dimension: 384 (adjusts automatically based on embeddings)
|
||||
- Metadata is stored alongside vectors for filtering
|
||||
|
||||
## Advantages Over Other Vector Databases
|
||||
|
||||
### vs. ChromaDB
|
||||
- ✅ Better MySQL compatibility
|
||||
- ✅ Hybrid search capabilities (vector + full-text + SQL)
|
||||
- ✅ Production-grade distributed mode support
|
||||
- ✅ Lightweight embedded mode
|
||||
|
||||
### vs. Qdrant
|
||||
- ✅ SQL query support
|
||||
- ✅ MySQL ecosystem integration
|
||||
- ✅ Simpler deployment (no Docker required for embedded mode)
|
||||
- ✅ Multi-model data support (not just vectors)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Import Error
|
||||
|
||||
If you see: `ImportError: pyseekdb is not installed`
|
||||
|
||||
Solution:
|
||||
```bash
|
||||
pip install pyseekdb
|
||||
```
|
||||
|
||||
### Embedded Mode Error on macOS/Windows
|
||||
|
||||
**Error**:
|
||||
```
|
||||
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
||||
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
|
||||
```
|
||||
|
||||
**Cause**: `pylibseekdb` is only available on Linux platforms.
|
||||
|
||||
**Solution**: Use server mode instead:
|
||||
1. Deploy SeekDB on a Linux server or VM
|
||||
2. Configure LangBot to use server mode:
|
||||
```yaml
|
||||
vdb:
|
||||
use: seekdb
|
||||
seekdb:
|
||||
mode: server
|
||||
host: 'your-seekdb-server-ip'
|
||||
port: 2881
|
||||
database: 'langbot'
|
||||
user: 'root'
|
||||
password: ''
|
||||
```
|
||||
|
||||
**Alternative**: Use ChromaDB or Qdrant, which work on all platforms:
|
||||
```yaml
|
||||
vdb:
|
||||
use: chroma # or qdrant
|
||||
```
|
||||
|
||||
### Docker Container Fails on macOS
|
||||
|
||||
**Symptoms**:
|
||||
```bash
|
||||
docker run -d -p 2881:2881 oceanbase/seekdb:latest
|
||||
# Container exits immediately with code 30
|
||||
```
|
||||
|
||||
**Error in logs**:
|
||||
```
|
||||
[ERROR] Code: Agent.SeekDB.Not.Exists
|
||||
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
|
||||
```
|
||||
|
||||
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
|
||||
|
||||
**Status**: Under investigation by OceanBase team.
|
||||
|
||||
**Workaround Options**:
|
||||
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
|
||||
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
|
||||
3. **Wait for fix**: Monitor the GitHub issue for updates
|
||||
|
||||
### Connection Error (Server Mode)
|
||||
|
||||
If SeekDB server is not reachable, check:
|
||||
1. Server is running: `ps aux | grep observer`
|
||||
2. Port is accessible: `nc -zv localhost 2881`
|
||||
3. Credentials are correct in config
|
||||
4. Firewall allows connections on port 2881
|
||||
|
||||
### Performance Issues
|
||||
|
||||
For large datasets:
|
||||
- Use server mode instead of embedded mode
|
||||
- Ensure adequate memory allocation
|
||||
- Consider using OceanBase distributed mode for very large scale
|
||||
- Adjust HNSW index parameters if needed
|
||||
|
||||
## Resources
|
||||
|
||||
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
||||
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
||||
- OceanBase Documentation: https://oceanbase.ai
|
||||
- LangBot Documentation: https://docs.langbot.app
|
||||
|
||||
## License
|
||||
|
||||
SeekDB is licensed under Apache License 2.0.
|
||||
@@ -1,10 +1,10 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.6.0"
|
||||
description = "Easy-to-use global IM bot platform designed for LLM era"
|
||||
version = "4.8.1"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
requires-python = ">=3.10.1,<4.0"
|
||||
requires-python = ">=3.11,<4.0"
|
||||
dependencies = [
|
||||
"aiocqhttp>=1.4.4",
|
||||
"aiofiles>=24.1.0",
|
||||
@@ -23,7 +23,7 @@ dependencies = [
|
||||
"pynacl>=1.5.0", # Required for Discord voice support
|
||||
"gewechat-client>=0.1.5",
|
||||
"lark-oapi>=1.4.15",
|
||||
"mcp>=1.8.1",
|
||||
"mcp>=1.25.0",
|
||||
"nakuru-project-idk>=0.0.2.1",
|
||||
"ollama>=0.4.8",
|
||||
"openai>1.0.0",
|
||||
@@ -63,11 +63,14 @@ dependencies = [
|
||||
"langchain-text-splitters>=0.0.1",
|
||||
"chromadb>=0.4.24",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.2.0",
|
||||
"pyseekdb==1.0.0b7",
|
||||
"langbot-plugin==0.2.5",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
"boto3>=1.35.0",
|
||||
"pymilvus>=2.6.4",
|
||||
"pgvector>=0.4.1",
|
||||
]
|
||||
keywords = [
|
||||
"bot",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.6.0'
|
||||
__version__ = '4.8.1'
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Callable
|
||||
import dingtalk_stream # type: ignore
|
||||
import websockets
|
||||
from .EchoHandler import EchoTextHandler
|
||||
from .dingtalkevent import DingTalkEvent
|
||||
import httpx
|
||||
@@ -36,6 +39,7 @@ class DingTalkClient:
|
||||
self.access_token_expiry_time = ''
|
||||
self.markdown_card = markdown_card
|
||||
self.logger = logger
|
||||
self._stopped = False # Flag to control the event loop
|
||||
|
||||
async def get_access_token(self):
|
||||
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
||||
@@ -170,6 +174,9 @@ class DingTalkClient:
|
||||
"""
|
||||
处理消息事件。
|
||||
"""
|
||||
# Skip message handling if stopped
|
||||
if self._stopped:
|
||||
return
|
||||
msg_type = event.conversation
|
||||
if msg_type in self._message_handlers:
|
||||
for handler in self._message_handlers[msg_type]:
|
||||
@@ -378,4 +385,70 @@ class DingTalkClient:
|
||||
|
||||
async def start(self):
|
||||
"""启动 WebSocket 连接,监听消息"""
|
||||
await self.client.start()
|
||||
self._stopped = False
|
||||
self.client.pre_start()
|
||||
|
||||
while not self._stopped:
|
||||
try:
|
||||
connection = self.client.open_connection()
|
||||
|
||||
if not connection:
|
||||
if self.logger:
|
||||
await self.logger.error('DingTalk: open connection failed')
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
|
||||
uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket']))
|
||||
async with websockets.connect(uri) as websocket:
|
||||
self.client.websocket = websocket
|
||||
keepalive_task = asyncio.create_task(self._keepalive(websocket))
|
||||
try:
|
||||
async for raw_message in websocket:
|
||||
if self._stopped:
|
||||
break
|
||||
json_message = json.loads(raw_message)
|
||||
asyncio.create_task(self.client.background_task(json_message))
|
||||
finally:
|
||||
keepalive_task.cancel()
|
||||
try:
|
||||
await keepalive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
# Properly exit when task is cancelled
|
||||
break
|
||||
except websockets.exceptions.ConnectionClosedError as e:
|
||||
if self._stopped:
|
||||
break
|
||||
if self.logger:
|
||||
await self.logger.error(f'DingTalk: connection closed, reconnecting... error={e}')
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
except Exception as e:
|
||||
if self._stopped:
|
||||
break
|
||||
if self.logger:
|
||||
await self.logger.error(f'DingTalk: unknown exception, reconnecting... error={e}')
|
||||
await asyncio.sleep(3)
|
||||
continue
|
||||
|
||||
async def _keepalive(self, ws, ping_interval=60):
|
||||
"""Keep WebSocket connection alive"""
|
||||
while not self._stopped:
|
||||
await asyncio.sleep(ping_interval)
|
||||
try:
|
||||
await ws.ping()
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
break
|
||||
|
||||
async def stop(self):
|
||||
"""停止 WebSocket 连接"""
|
||||
self._stopped = True
|
||||
# Close WebSocket connection if exists
|
||||
if self.client.websocket:
|
||||
try:
|
||||
await self.client.websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Clear message handlers to prevent stale callbacks
|
||||
self._message_handlers = {'example': []}
|
||||
|
||||
@@ -23,12 +23,21 @@ xml_template = """
|
||||
|
||||
|
||||
class OAClient:
|
||||
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None, unified_mode: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
EncodingAESKey: str,
|
||||
AppID: str,
|
||||
Appsecret: str,
|
||||
logger: None,
|
||||
unified_mode: bool = False,
|
||||
api_base_url: str = 'https://api.weixin.qq.com',
|
||||
):
|
||||
self.token = token
|
||||
self.aes = EncodingAESKey
|
||||
self.appid = AppID
|
||||
self.appsecret = Appsecret
|
||||
self.base_url = 'https://api.weixin.qq.com'
|
||||
self.base_url = api_base_url
|
||||
self.access_token = ''
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
@@ -208,12 +217,13 @@ class OAClientForLongerResponse:
|
||||
LoadingMessage: str,
|
||||
logger: None,
|
||||
unified_mode: bool = False,
|
||||
api_base_url: str = 'https://api.weixin.qq.com',
|
||||
):
|
||||
self.token = token
|
||||
self.aes = EncodingAESKey
|
||||
self.appid = AppID
|
||||
self.appsecret = Appsecret
|
||||
self.base_url = 'https://api.weixin.qq.com'
|
||||
self.base_url = api_base_url
|
||||
self.access_token = ''
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
|
||||
@@ -85,7 +85,6 @@ class QQOfficialClient:
|
||||
req: Quart Request 对象
|
||||
"""
|
||||
try:
|
||||
|
||||
body = await req.get_data()
|
||||
|
||||
print(f'[QQ Official] Received request, body length: {len(body)}')
|
||||
@@ -96,7 +95,6 @@ class QQOfficialClient:
|
||||
|
||||
payload = json.loads(body)
|
||||
|
||||
|
||||
if payload.get('op') == 13:
|
||||
validation_data = payload.get('d')
|
||||
if not validation_data:
|
||||
@@ -276,21 +274,21 @@ class QQOfficialClient:
|
||||
seed = bot_secret
|
||||
while len(seed) < target_size:
|
||||
seed *= 2
|
||||
return seed[:target_size].encode("utf-8")
|
||||
return seed[:target_size].encode('utf-8')
|
||||
|
||||
async def verify(self, validation_payload: dict):
|
||||
seed = await self.repeat_seed(self.secret)
|
||||
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed)
|
||||
|
||||
event_ts = validation_payload.get("event_ts", "")
|
||||
plain_token = validation_payload.get("plain_token", "")
|
||||
event_ts = validation_payload.get('event_ts', '')
|
||||
plain_token = validation_payload.get('plain_token', '')
|
||||
msg = event_ts + plain_token
|
||||
|
||||
# sign
|
||||
signature = private_key.sign(msg.encode()).hex()
|
||||
|
||||
response = {
|
||||
"plain_token": plain_token,
|
||||
"signature": signature,
|
||||
'plain_token': plain_token,
|
||||
'signature': signature,
|
||||
}
|
||||
return response
|
||||
|
||||
@@ -394,7 +394,6 @@ class WecomBotClient:
|
||||
"""
|
||||
try:
|
||||
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
|
||||
await self.logger.info(f'{req.method} {req.url} {str(req.args)}')
|
||||
|
||||
if req.method == 'GET':
|
||||
return await self._handle_get_callback(req)
|
||||
@@ -458,32 +457,174 @@ class WecomBotClient:
|
||||
async def get_message(self, msg_json):
|
||||
message_data = {}
|
||||
|
||||
msg_type = msg_json.get('msgtype', '')
|
||||
if msg_type:
|
||||
message_data['msgtype'] = msg_type
|
||||
|
||||
if msg_json.get('chattype', '') == 'single':
|
||||
message_data['type'] = 'single'
|
||||
elif msg_json.get('chattype', '') == 'group':
|
||||
message_data['type'] = 'group'
|
||||
|
||||
if msg_json.get('msgtype') == 'text':
|
||||
max_inline_file_size = 5 * 1024 * 1024 # avoid decoding very large payloads by default
|
||||
|
||||
async def _safe_download(url: str):
|
||||
if not url:
|
||||
return None
|
||||
return await self.download_url_to_base64(url, self.EnCodingAESKey)
|
||||
|
||||
if msg_type == 'text':
|
||||
message_data['content'] = msg_json.get('text', {}).get('content')
|
||||
elif msg_json.get('msgtype') == 'image':
|
||||
elif msg_type == 'markdown':
|
||||
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
|
||||
'content', ''
|
||||
)
|
||||
elif msg_type == 'image':
|
||||
picurl = msg_json.get('image', {}).get('url', '')
|
||||
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
||||
message_data['picurl'] = base64
|
||||
elif msg_json.get('msgtype') == 'mixed':
|
||||
base64_data = await _safe_download(picurl)
|
||||
if base64_data:
|
||||
message_data['picurl'] = base64_data
|
||||
message_data['images'] = [base64_data]
|
||||
elif msg_type == 'voice':
|
||||
voice_info = msg_json.get('voice', {}) or {}
|
||||
download_url = voice_info.get('url')
|
||||
message_data['voice'] = {
|
||||
'url': download_url,
|
||||
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||
}
|
||||
# 企业微信智能转写文本(如果已有)直接复用,避免重复转写
|
||||
if voice_info.get('content'):
|
||||
message_data['content'] = voice_info.get('content')
|
||||
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
||||
voice_base64 = await _safe_download(download_url)
|
||||
if voice_base64:
|
||||
message_data['voice']['base64'] = voice_base64
|
||||
elif msg_type == 'video':
|
||||
video_info = msg_json.get('video', {}) or {}
|
||||
download_url = video_info.get('url')
|
||||
video_data = {
|
||||
'url': download_url,
|
||||
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||
'filename': video_info.get('filename') or video_info.get('name'),
|
||||
}
|
||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
video_base64 = await _safe_download(download_url)
|
||||
if video_base64:
|
||||
video_data['base64'] = video_base64
|
||||
message_data['video'] = video_data
|
||||
elif msg_type == 'file':
|
||||
file_info = msg_json.get('file', {}) or {}
|
||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||
file_data = {
|
||||
'filename': file_info.get('filename') or file_info.get('name'),
|
||||
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||
'download_url': download_url,
|
||||
'extra': file_info,
|
||||
}
|
||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
file_base64 = await _safe_download(download_url)
|
||||
if file_base64:
|
||||
file_data['base64'] = file_base64
|
||||
message_data['file'] = file_data
|
||||
elif msg_type == 'link':
|
||||
message_data['link'] = msg_json.get('link', {})
|
||||
if not message_data.get('content'):
|
||||
title = message_data['link'].get('title', '')
|
||||
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
|
||||
message_data['content'] = '\n'.join(filter(None, [title, desc]))
|
||||
elif msg_type == 'mixed':
|
||||
items = msg_json.get('mixed', {}).get('msg_item', [])
|
||||
texts = []
|
||||
picurl = None
|
||||
images = []
|
||||
files = []
|
||||
voices = []
|
||||
videos = []
|
||||
links = []
|
||||
for item in items:
|
||||
if item.get('msgtype') == 'text':
|
||||
item_type = item.get('msgtype')
|
||||
if item_type == 'text':
|
||||
texts.append(item.get('text', {}).get('content', ''))
|
||||
elif item.get('msgtype') == 'image' and picurl is None:
|
||||
picurl = item.get('image', {}).get('url')
|
||||
elif item_type == 'image':
|
||||
img_url = item.get('image', {}).get('url')
|
||||
base64_data = await _safe_download(img_url)
|
||||
if base64_data:
|
||||
images.append(base64_data)
|
||||
elif item_type == 'file':
|
||||
file_info = item.get('file', {}) or {}
|
||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||
file_data = {
|
||||
'filename': file_info.get('filename') or file_info.get('name'),
|
||||
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||
'download_url': download_url,
|
||||
'extra': file_info,
|
||||
}
|
||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
file_base64 = await _safe_download(download_url)
|
||||
if file_base64:
|
||||
file_data['base64'] = file_base64
|
||||
files.append(file_data)
|
||||
elif item_type == 'voice':
|
||||
voice_info = item.get('voice', {}) or {}
|
||||
download_url = voice_info.get('url')
|
||||
voice_data = {
|
||||
'url': download_url,
|
||||
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||
}
|
||||
if voice_info.get('content'):
|
||||
texts.append(voice_info.get('content'))
|
||||
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
voice_base64 = await _safe_download(download_url)
|
||||
if voice_base64:
|
||||
voice_data['base64'] = voice_base64
|
||||
voices.append(voice_data)
|
||||
elif item_type == 'video':
|
||||
video_info = item.get('video', {}) or {}
|
||||
download_url = video_info.get('url')
|
||||
video_data = {
|
||||
'url': download_url,
|
||||
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||
'filename': video_info.get('filename') or video_info.get('name'),
|
||||
}
|
||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
video_base64 = await _safe_download(download_url)
|
||||
if video_base64:
|
||||
video_data['base64'] = video_base64
|
||||
videos.append(video_data)
|
||||
elif item_type == 'link':
|
||||
links.append(item.get('link', {}))
|
||||
|
||||
if texts:
|
||||
message_data['content'] = ''.join(texts) # 拼接所有 text
|
||||
if picurl:
|
||||
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
||||
message_data['picurl'] = base64 # 只保留第一个 image
|
||||
message_data['content'] = ' '.join(texts) # 拼接所有 text
|
||||
if images:
|
||||
message_data['images'] = images
|
||||
message_data['picurl'] = images[0] # 只保留第一个 image
|
||||
if files:
|
||||
message_data['files'] = files
|
||||
message_data['file'] = files[0]
|
||||
if voices:
|
||||
message_data['voices'] = voices
|
||||
message_data['voice'] = voices[0]
|
||||
if videos:
|
||||
message_data['videos'] = videos
|
||||
message_data['video'] = videos[0]
|
||||
if links:
|
||||
message_data['link'] = links[0]
|
||||
if items:
|
||||
message_data['attachments'] = items
|
||||
else:
|
||||
message_data['raw_msg'] = msg_json
|
||||
|
||||
# Extract user information
|
||||
from_info = msg_json.get('from', {})
|
||||
|
||||
@@ -17,6 +17,13 @@ class WecomBotEvent(dict):
|
||||
"""
|
||||
return self.get('type', '')
|
||||
|
||||
@property
|
||||
def msgtype(self) -> str:
|
||||
"""
|
||||
消息 msgtype
|
||||
"""
|
||||
return self.get('msgtype', '')
|
||||
|
||||
@property
|
||||
def userid(self) -> str:
|
||||
"""
|
||||
@@ -29,7 +36,12 @@ class WecomBotEvent(dict):
|
||||
"""
|
||||
用户名称
|
||||
"""
|
||||
return self.get('username', '') or self.get('from', {}).get('alias', '') or self.get('from', {}).get('name', '') or self.userid
|
||||
return (
|
||||
self.get('username', '')
|
||||
or self.get('from', {}).get('alias', '')
|
||||
or self.get('from', {}).get('name', '')
|
||||
or self.userid
|
||||
)
|
||||
|
||||
@property
|
||||
def chatname(self) -> str:
|
||||
@@ -52,6 +64,55 @@ class WecomBotEvent(dict):
|
||||
"""
|
||||
return self.get('picurl', '')
|
||||
|
||||
@property
|
||||
def images(self):
|
||||
"""
|
||||
图片列表(兼容 mixed)
|
||||
"""
|
||||
return self.get('images', [])
|
||||
|
||||
@property
|
||||
def file(self):
|
||||
"""
|
||||
文件信息
|
||||
"""
|
||||
return self.get('file', {})
|
||||
|
||||
@property
|
||||
def voice(self):
|
||||
"""
|
||||
语音信息
|
||||
"""
|
||||
return self.get('voice', {})
|
||||
|
||||
@property
|
||||
def video(self):
|
||||
"""
|
||||
视频信息
|
||||
"""
|
||||
return self.get('video', {})
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
"""
|
||||
链接消息信息
|
||||
"""
|
||||
return self.get('link', {})
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
"""
|
||||
位置信息
|
||||
"""
|
||||
return self.get('location', {})
|
||||
|
||||
@property
|
||||
def attachments(self):
|
||||
"""
|
||||
原始 mixed 中的附件项
|
||||
"""
|
||||
return self.get('attachments', [])
|
||||
|
||||
@property
|
||||
def chatid(self) -> str:
|
||||
"""
|
||||
@@ -65,7 +126,7 @@ class WecomBotEvent(dict):
|
||||
消息id
|
||||
"""
|
||||
return self.get('msgid', '')
|
||||
|
||||
|
||||
@property
|
||||
def ai_bot_id(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -22,13 +22,14 @@ class WecomClient:
|
||||
contacts_secret: str,
|
||||
logger: None,
|
||||
unified_mode: bool = False,
|
||||
api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin',
|
||||
):
|
||||
self.corpid = corpid
|
||||
self.secret = secret
|
||||
self.access_token_for_contacts = ''
|
||||
self.token = token
|
||||
self.aes = EncodingAESKey
|
||||
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
|
||||
self.base_url = api_base_url
|
||||
self.access_token = ''
|
||||
self.secret_for_contacts = contacts_secret
|
||||
self.logger = logger
|
||||
@@ -56,7 +57,7 @@ class WecomClient:
|
||||
return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())
|
||||
|
||||
async def get_access_token(self, secret):
|
||||
url = f'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={secret}'
|
||||
url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
data = response.json()
|
||||
@@ -139,12 +140,64 @@ class WecomClient:
|
||||
await self.logger.error(f'发送图片失败:{data}')
|
||||
raise Exception('Failed to send image: ' + str(data))
|
||||
|
||||
async def send_voice(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 + '/message/send?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'msgtype': 'voice',
|
||||
'agentid': agent_id,
|
||||
'voice': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
'safe': 0,
|
||||
'enable_id_trans': 0,
|
||||
'enable_duplicate_check': 0,
|
||||
'duplicate_check_interval': 1800,
|
||||
}
|
||||
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_voice(user_id, agent_id, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送语音失败:{data}')
|
||||
raise Exception('Failed to send voice: ' + str(data))
|
||||
|
||||
async def send_file(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 + '/message/send?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'msgtype': 'file',
|
||||
'agentid': agent_id,
|
||||
'file': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
'safe': 0,
|
||||
'enable_id_trans': 0,
|
||||
'enable_duplicate_check': 0,
|
||||
'duplicate_check_interval': 1800,
|
||||
}
|
||||
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_file(user_id, agent_id, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送文件失败:{data}')
|
||||
raise Exception('Failed to send file: ' + str(data))
|
||||
|
||||
async def send_private_msg(self, user_id: str, agent_id: int, content: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'msgtype': 'text',
|
||||
@@ -287,7 +340,7 @@ class WecomClient:
|
||||
return ext
|
||||
return 'jpg' # 默认返回jpg
|
||||
|
||||
async def upload_to_work(self, image: platform_message.Image):
|
||||
async def upload_image_to_work(self, image: platform_message.Image):
|
||||
"""
|
||||
获取 media_id
|
||||
"""
|
||||
@@ -304,7 +357,7 @@ class WecomClient:
|
||||
file_bytes = await f.read()
|
||||
file_name = image.path.split('/')[-1]
|
||||
elif image.url:
|
||||
file_bytes = await self.download_image_to_bytes(image.url)
|
||||
file_bytes = await self.download_media_to_bytes(image.url)
|
||||
file_name = image.url.split('/')[-1]
|
||||
elif image.base64:
|
||||
try:
|
||||
@@ -339,7 +392,7 @@ class WecomClient:
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_to_work(image)
|
||||
media_id = await self.upload_image_to_work(image)
|
||||
if data.get('errcode', 0) != 0:
|
||||
await self.logger.error(f'上传图片失败:{data}')
|
||||
raise Exception('failed to upload file')
|
||||
@@ -347,13 +400,128 @@ class WecomClient:
|
||||
media_id = data.get('media_id')
|
||||
return media_id
|
||||
|
||||
async def download_image_to_bytes(self, url: str) -> bytes:
|
||||
async def upload_voice_to_work(self, voice: platform_message.Voice):
|
||||
"""
|
||||
上传语音文件到企业微信
|
||||
"""
|
||||
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 + '&type=file'
|
||||
file_bytes = None
|
||||
file_name = 'voice.mp3'
|
||||
|
||||
if voice.path:
|
||||
async with aiofiles.open(voice.path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
file_name = voice.path.split('/')[-1]
|
||||
elif voice.url:
|
||||
file_bytes = await self.download_media_to_bytes(voice.url)
|
||||
file_name = voice.url.split('/')[-1]
|
||||
elif voice.base64:
|
||||
try:
|
||||
base64_data = voice.base64
|
||||
if ',' in base64_data:
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
|
||||
padded_base64 = base64_data + '=' * padding
|
||||
file_bytes = base64.b64decode(padded_base64)
|
||||
except binascii.Error as e:
|
||||
raise ValueError(f'Invalid base64 string: {str(e)}')
|
||||
else:
|
||||
await self.logger.error('Voice对象出错')
|
||||
raise ValueError('voice对象出错')
|
||||
|
||||
boundary = '-------------------------acebdf13572468'
|
||||
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||
body = (
|
||||
(
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
|
||||
f'Content-Type: application/octet-stream\r\n\r\n'
|
||||
).encode('utf-8')
|
||||
+ file_bytes
|
||||
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
|
||||
)
|
||||
|
||||
# print(body)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, content=body)
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_voice_to_work(voice)
|
||||
if data.get('errcode', 0) != 0:
|
||||
await self.logger.error(f'上传语音文件失败:{data}')
|
||||
raise Exception('failed to upload file')
|
||||
media_id = data.get('media_id')
|
||||
return media_id
|
||||
|
||||
async def upload_file_to_work(self, file: platform_message.File):
|
||||
"""
|
||||
上传文件到企业微信
|
||||
"""
|
||||
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 + '&type=file'
|
||||
file_bytes = None
|
||||
file_name = 'file.txt'
|
||||
if file.path:
|
||||
async with aiofiles.open(file.path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
file_name = file.path.split('/')[-1]
|
||||
elif file.url:
|
||||
file_bytes = await self.download_media_to_bytes(file.url)
|
||||
file_name = file.url.split('/')[-1]
|
||||
elif file.base64:
|
||||
try:
|
||||
base64_data = file.base64
|
||||
if ',' in base64_data:
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
|
||||
padded_base64 = base64_data + '=' * padding
|
||||
file_bytes = base64.b64decode(padded_base64)
|
||||
except binascii.Error as e:
|
||||
raise ValueError(f'Invalid base64 string: {str(e)}')
|
||||
else:
|
||||
await self.logger.error('File对象出错')
|
||||
raise ValueError('file对象出错')
|
||||
boundary = '-------------------------acebdf13572468'
|
||||
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||
body = (
|
||||
(
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
|
||||
f'Content-Type: application/octet-stream\r\n\r\n'
|
||||
).encode('utf-8')
|
||||
+ file_bytes
|
||||
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, content=body)
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_file_to_work(file)
|
||||
if data.get('errcode', 0) != 0:
|
||||
await self.logger.error(f'上传文件失败:{data}')
|
||||
raise Exception('failed to upload file')
|
||||
media_id = data.get('media_id')
|
||||
return media_id
|
||||
|
||||
async def download_media_to_bytes(self, url: str) -> bytes:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
# 进行media_id的获取
|
||||
async def get_media_id(self, image: platform_message.Image):
|
||||
media_id = await self.upload_to_work(image=image)
|
||||
async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File):
|
||||
if isinstance(media, platform_message.Image):
|
||||
media_id = await self.upload_image_to_work(image=media)
|
||||
elif isinstance(media, platform_message.Voice):
|
||||
media_id = await self.upload_voice_to_work(voice=media)
|
||||
elif isinstance(media, platform_message.File):
|
||||
media_id = await self.upload_file_to_work(file=media)
|
||||
else:
|
||||
raise ValueError('Unsupported media type')
|
||||
return media_id
|
||||
|
||||
@@ -13,13 +13,22 @@ import aiofiles
|
||||
|
||||
|
||||
class WecomCSClient:
|
||||
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None, unified_mode: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
corpid: str,
|
||||
secret: str,
|
||||
token: str,
|
||||
EncodingAESKey: str,
|
||||
logger: None,
|
||||
unified_mode: bool = False,
|
||||
api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin',
|
||||
):
|
||||
self.corpid = corpid
|
||||
self.secret = secret
|
||||
self.access_token_for_contacts = ''
|
||||
self.token = token
|
||||
self.aes = EncodingAESKey
|
||||
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
|
||||
self.base_url = api_base_url
|
||||
self.access_token = ''
|
||||
self.logger = logger
|
||||
self.unified_mode = unified_mode
|
||||
@@ -66,7 +75,7 @@ class WecomCSClient:
|
||||
return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())
|
||||
|
||||
async def get_access_token(self, secret):
|
||||
url = f'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={secret}'
|
||||
url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
data = response.json()
|
||||
@@ -172,7 +181,7 @@ class WecomCSClient:
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = f'https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={self.access_token}'
|
||||
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||
|
||||
payload = {
|
||||
'touser': external_userid,
|
||||
|
||||
325
src/langbot/pkg/api/http/controller/groups/monitoring.py
Normal file
325
src/langbot/pkg/api/http/controller/groups/monitoring.py
Normal file
@@ -0,0 +1,325 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
def parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None:
|
||||
"""Parse ISO 8601 datetime string, handling 'Z' suffix for UTC timezone"""
|
||||
if not datetime_str:
|
||||
return None
|
||||
# Replace 'Z' with '+00:00' for Python 3.10 compatibility
|
||||
if datetime_str.endswith('Z'):
|
||||
datetime_str = datetime_str[:-1] + '+00:00'
|
||||
dt = datetime.datetime.fromisoformat(datetime_str)
|
||||
# Convert to UTC and remove timezone info to match database storage (which stores UTC as naive datetime)
|
||||
if dt.tzinfo is not None:
|
||||
# Convert to UTC and remove timezone info
|
||||
dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
@group.group_class('monitoring', '/api/v1/monitoring')
|
||||
class MonitoringRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/overview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_overview() -> str:
|
||||
"""Get overview metrics"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
metrics = await self.ap.monitoring_service.get_overview_metrics(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
return self.success(data=metrics)
|
||||
|
||||
@self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_messages() -> str:
|
||||
"""Get message logs"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
messages, total = await self.ap.monitoring_service.get_messages(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'messages': messages,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/llm-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_llm_calls() -> str:
|
||||
"""Get LLM call records"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
llm_calls, total = await self.ap.monitoring_service.get_llm_calls(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'llm_calls': llm_calls,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_embedding_calls() -> str:
|
||||
"""Get embedding call records"""
|
||||
# Parse query parameters
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
knowledge_base_id = quart.request.args.get('knowledgeBaseId')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
knowledge_base_id=knowledge_base_id if knowledge_base_id else None,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'embedding_calls': embedding_calls,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_sessions() -> str:
|
||||
"""Get session information"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
is_active_str = quart.request.args.get('isActive')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
# Parse is_active
|
||||
is_active = None
|
||||
if is_active_str:
|
||||
is_active = is_active_str.lower() == 'true'
|
||||
|
||||
sessions, total = await self.ap.monitoring_service.get_sessions(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
is_active=is_active,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'sessions': sessions,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_errors() -> str:
|
||||
"""Get error logs"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
errors, total = await self.ap.monitoring_service.get_errors(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'errors': errors,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/data', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_all_data() -> str:
|
||||
"""Get all monitoring data in a single request"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
limit = int(quart.request.args.get('limit', 50))
|
||||
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
# Get overview metrics
|
||||
overview = await self.ap.monitoring_service.get_overview_metrics(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
# Get messages
|
||||
messages, messages_total = await self.ap.monitoring_service.get_messages(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
# Get LLM calls
|
||||
llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
# Get sessions
|
||||
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
is_active=None,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
# Get errors
|
||||
errors, errors_total = await self.ap.monitoring_service.get_errors(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
# Get embedding calls
|
||||
embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'overview': overview,
|
||||
'messages': messages,
|
||||
'llmCalls': llm_calls,
|
||||
'embeddingCalls': embedding_calls,
|
||||
'sessions': sessions,
|
||||
'errors': errors,
|
||||
'totalCount': {
|
||||
'messages': messages_total,
|
||||
'llmCalls': llm_calls_total,
|
||||
'embeddingCalls': embedding_calls_total,
|
||||
'sessions': sessions_total,
|
||||
'errors': errors_total,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_session_analysis(session_id: str) -> str:
|
||||
"""Get detailed analysis for a specific session"""
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(session_id)
|
||||
|
||||
# Always return success with the analysis data
|
||||
# The frontend will handle the 'found: false' case
|
||||
return self.success(data=analysis)
|
||||
|
||||
@self.route('/messages/<message_id>/details', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_message_details(message_id: str) -> str:
|
||||
"""Get detailed information for a specific message"""
|
||||
details = await self.ap.monitoring_service.get_message_details(message_id)
|
||||
|
||||
if not details.get('found'):
|
||||
return self.error(message=f'Message {message_id} not found', code=404)
|
||||
|
||||
return self.success(data=details)
|
||||
@@ -49,6 +49,14 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<pipeline_uuid>/copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(pipeline_uuid: str) -> str:
|
||||
try:
|
||||
new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid)
|
||||
return self.success(data={'uuid': new_uuid})
|
||||
except ValueError as e:
|
||||
return self.http_status(404, -1, str(e))
|
||||
|
||||
@self.route(
|
||||
'/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
)
|
||||
|
||||
@@ -9,12 +9,15 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
if provider_uuid:
|
||||
return self.success(
|
||||
data={'models': await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)}
|
||||
)
|
||||
return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
|
||||
model_uuid = await self.ap.llm_model_service.create_llm_model(json_data)
|
||||
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@@ -52,12 +55,19 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
if provider_uuid:
|
||||
return self.success(
|
||||
data={
|
||||
'models': await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
||||
provider_uuid
|
||||
)
|
||||
}
|
||||
)
|
||||
return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
|
||||
model_uuid = await self.ap.embedding_models_service.create_embedding_model(json_data)
|
||||
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('models/providers', '/api/v1/provider/providers')
|
||||
class ModelProvidersRouterGroup(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':
|
||||
providers = await self.ap.provider_service.get_providers()
|
||||
# Add model counts
|
||||
for provider in providers:
|
||||
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
||||
provider['llm_count'] = counts['llm_count']
|
||||
provider['embedding_count'] = counts['embedding_count']
|
||||
return self.success(data={'providers': providers})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
provider_uuid = await self.ap.provider_service.create_provider(json_data)
|
||||
return self.success(data={'uuid': provider_uuid})
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
)
|
||||
async def _(provider_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
provider = await self.ap.provider_service.get_provider(provider_uuid)
|
||||
if provider is None:
|
||||
return self.http_status(404, -1, 'provider not found')
|
||||
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
||||
provider['llm_count'] = counts['llm_count']
|
||||
provider['embedding_count'] = counts['embedding_count']
|
||||
return self.success(data={'provider': provider})
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.provider_service.update_provider(provider_uuid, json_data)
|
||||
return self.success()
|
||||
elif quart.request.method == 'DELETE':
|
||||
try:
|
||||
await self.ap.provider_service.delete_provider(provider_uuid)
|
||||
return self.success()
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
@@ -17,11 +17,13 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
'enable_marketplace', True
|
||||
),
|
||||
'cloud_service_url': (
|
||||
self.ap.instance_config.data.get('plugin', {}).get(
|
||||
'cloud_service_url', 'https://space.langbot.app'
|
||||
)
|
||||
if 'cloud_service_url' in self.ap.instance_config.data.get('plugin', {})
|
||||
else 'https://space.langbot.app'
|
||||
self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app')
|
||||
),
|
||||
'allow_modify_login_info': self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
),
|
||||
'disable_models_service': self.ap.instance_config.data.get('space', {}).get(
|
||||
'disable_models_service', False
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import quart
|
||||
import argon2
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
from .. import group
|
||||
from .....entity.errors import account as account_errors
|
||||
|
||||
|
||||
@group.group_class('user', '/api/v1/user')
|
||||
@@ -33,6 +35,8 @@ class UserRouterGroup(group.RouterGroup):
|
||||
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
|
||||
except argon2.exceptions.VerifyMismatchError:
|
||||
return self.fail(1, 'Invalid username or password')
|
||||
except ValueError as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
return self.success(data={'token': token})
|
||||
|
||||
@@ -70,6 +74,13 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
# Check if password change is allowed
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
json_data = await quart.request.json
|
||||
|
||||
current_password = json_data['current_password']
|
||||
@@ -83,3 +94,169 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, str(e))
|
||||
|
||||
return self.success(data={'user': user_email})
|
||||
|
||||
# Space OAuth endpoints (redirect flow)
|
||||
|
||||
@self.route('/space/authorize-url', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Get Space OAuth authorization URL for redirect"""
|
||||
redirect_uri = quart.request.args.get('redirect_uri', '')
|
||||
state = quart.request.args.get('state', '')
|
||||
|
||||
if not redirect_uri:
|
||||
return self.fail(1, 'Missing redirect_uri parameter')
|
||||
|
||||
try:
|
||||
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
|
||||
return self.success(data={'authorize_url': authorize_url})
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Handle OAuth callback - exchange code for tokens and authenticate"""
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
|
||||
if not code:
|
||||
return self.fail(1, 'Missing authorization code')
|
||||
|
||||
try:
|
||||
# Exchange code for tokens
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
|
||||
if not access_token:
|
||||
return self.fail(1, 'Failed to get access token from Space')
|
||||
|
||||
# Authenticate and create/update local user
|
||||
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
||||
access_token, refresh_token, expires_in
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'token': jwt_token,
|
||||
'user': user_obj.user,
|
||||
}
|
||||
)
|
||||
except account_errors.AccountEmailMismatchError as e:
|
||||
return self.fail(3, str(e))
|
||||
except ValueError as e:
|
||||
traceback.print_exc()
|
||||
return self.fail(1, str(e))
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return self.fail(2, f'OAuth callback failed: {str(e)}')
|
||||
|
||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Get current user information including account type"""
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'user': user_obj.user,
|
||||
'account_type': user_obj.account_type,
|
||||
'has_password': bool(user_obj.password and user_obj.password.strip()),
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Get Space credits balance for current user"""
|
||||
credits = await self.ap.space_service.get_credits(user_email)
|
||||
return self.success(data={'credits': credits})
|
||||
|
||||
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Get account info for login page (account type and has_password)"""
|
||||
if not await self.ap.user_service.is_initialized():
|
||||
return self.success(data={'initialized': False})
|
||||
|
||||
user_obj = await self.ap.user_service.get_first_user()
|
||||
if user_obj is None:
|
||||
return self.success(data={'initialized': False})
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'initialized': True,
|
||||
'account_type': user_obj.account_type,
|
||||
'has_password': bool(user_obj.password and user_obj.password.strip()),
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Set password for Space account (first time) or change password"""
|
||||
json_data = await quart.request.json
|
||||
new_password = json_data.get('new_password')
|
||||
current_password = json_data.get('current_password')
|
||||
|
||||
if not new_password:
|
||||
return self.http_status(400, -1, 'New password is required')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
try:
|
||||
await self.ap.user_service.set_password(user_email, new_password, current_password)
|
||||
return self.success(data={'user': user_email})
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
except argon2.exceptions.VerifyMismatchError:
|
||||
return self.http_status(400, -1, 'Current password is incorrect')
|
||||
|
||||
@self.route('/bind-space', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Bind Space account to existing local account"""
|
||||
# Check if modifying login info is allowed
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state') # JWT token passed as state
|
||||
|
||||
if not code:
|
||||
return self.http_status(400, -1, 'Missing authorization code')
|
||||
|
||||
if not state:
|
||||
return self.http_status(400, -1, 'Missing state parameter')
|
||||
|
||||
# Verify state is a valid JWT token
|
||||
try:
|
||||
user_email = await self.ap.user_service.verify_jwt_token(state)
|
||||
except Exception:
|
||||
return self.http_status(401, -1, 'Invalid or expired state')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
if user_obj.account_type != 'local':
|
||||
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
||||
|
||||
try:
|
||||
updated_user = await self.ap.user_service.bind_space_account(user_email, code)
|
||||
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user)
|
||||
return self.success(
|
||||
data={
|
||||
'token': jwt_token,
|
||||
'user': updated_user.user,
|
||||
'account_type': updated_user.account_type,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}')
|
||||
|
||||
49
src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py
Normal file
49
src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('webhook_mgmt', '/api/v1/webhooks')
|
||||
class WebhookManagementRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
webhooks = await self.ap.webhook_service.get_webhooks()
|
||||
return self.success(data={'webhooks': webhooks})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name', '')
|
||||
url = json_data.get('url', '')
|
||||
description = json_data.get('description', '')
|
||||
enabled = json_data.get('enabled', True)
|
||||
|
||||
if not name:
|
||||
return self.http_status(400, -1, 'Name is required')
|
||||
if not url:
|
||||
return self.http_status(400, -1, 'URL is required')
|
||||
|
||||
webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
@self.route('/<int:webhook_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(webhook_id: int) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
webhook = await self.ap.webhook_service.get_webhook(webhook_id)
|
||||
if webhook is None:
|
||||
return self.http_status(404, -1, 'Webhook not found')
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name')
|
||||
url = json_data.get('url')
|
||||
description = json_data.get('description')
|
||||
enabled = json_data.get('enabled')
|
||||
|
||||
await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
|
||||
return self.success()
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.webhook_service.delete_webhook(webhook_id)
|
||||
return self.success()
|
||||
@@ -30,7 +30,6 @@ class WebhookRouterGroup(group.RouterGroup):
|
||||
适配器返回的响应
|
||||
"""
|
||||
try:
|
||||
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
|
||||
if not runtime_bot:
|
||||
@@ -39,11 +38,9 @@ class WebhookRouterGroup(group.RouterGroup):
|
||||
if not runtime_bot.enable:
|
||||
return quart.jsonify({'error': 'Bot is disabled'}), 403
|
||||
|
||||
|
||||
if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'):
|
||||
return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501
|
||||
|
||||
|
||||
response = await runtime_bot.adapter.handle_unified_webhook(
|
||||
bot_uuid=bot_uuid,
|
||||
path=path,
|
||||
|
||||
@@ -92,7 +92,11 @@ class HTTPController:
|
||||
|
||||
@self.quart_app.route('/')
|
||||
async def index():
|
||||
return await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
||||
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
@self.quart_app.route('/<path:path>')
|
||||
async def static_file(path: str):
|
||||
|
||||
@@ -59,7 +59,16 @@ class BotService:
|
||||
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
|
||||
|
||||
# Webhook URL for unified webhook adapters (independent of bot running state)
|
||||
if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE']:
|
||||
if persistence_bot['adapter'] in [
|
||||
'wecom',
|
||||
'wecombot',
|
||||
'officialaccount',
|
||||
'qqofficial',
|
||||
'slack',
|
||||
'wecomcs',
|
||||
'LINE',
|
||||
'lark',
|
||||
]:
|
||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||
webhook_url = f'/bots/{bot_uuid}'
|
||||
adapter_runtime_values['webhook_url'] = webhook_url
|
||||
|
||||
@@ -11,6 +11,18 @@ from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....provider.modelmgr import requester as model_requester
|
||||
|
||||
|
||||
def _parse_provider_api_keys(provider_dict: dict) -> dict:
|
||||
"""Parse api_keys if it's a JSON string"""
|
||||
if isinstance(provider_dict.get('api_keys'), str):
|
||||
import json
|
||||
|
||||
try:
|
||||
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
|
||||
except Exception:
|
||||
provider_dict['api_keys'] = []
|
||||
return provider_dict
|
||||
|
||||
|
||||
class LLMModelsService:
|
||||
ap: app.Application
|
||||
|
||||
@@ -18,29 +30,72 @@ class LLMModelsService:
|
||||
self.ap = ap
|
||||
|
||||
async def get_llm_models(self, include_secret: bool = True) -> list[dict]:
|
||||
"""Get all LLM models with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
|
||||
|
||||
models = result.all()
|
||||
|
||||
masked_columns = []
|
||||
if not include_secret:
|
||||
masked_columns = ['api_keys']
|
||||
# Get all providers for lookup
|
||||
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider)
|
||||
)
|
||||
providers = {p.uuid: p for p in providers_result.all()}
|
||||
|
||||
return [
|
||||
self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model, masked_columns)
|
||||
for model in models
|
||||
]
|
||||
models_list = []
|
||||
for model in models:
|
||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
|
||||
provider = providers.get(model.provider_uuid)
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
provider_dict = _parse_provider_api_keys(provider_dict)
|
||||
if not include_secret:
|
||||
provider_dict['api_keys'] = ['***'] * len(provider_dict.get('api_keys', []))
|
||||
model_dict['provider'] = provider_dict
|
||||
models_list.append(model_dict)
|
||||
|
||||
async def create_llm_model(self, model_data: dict) -> str:
|
||||
model_data['uuid'] = str(uuid.uuid4())
|
||||
return models_list
|
||||
|
||||
async def get_llm_models_by_provider(self, provider_uuid: str) -> list[dict]:
|
||||
"""Get LLM models by provider UUID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(
|
||||
persistence_model.LLMModel.provider_uuid == provider_uuid
|
||||
)
|
||||
)
|
||||
models = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
|
||||
|
||||
async def create_llm_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||
"""Create a new LLM model"""
|
||||
if not preserve_uuid:
|
||||
model_data['uuid'] = str(uuid.uuid4())
|
||||
|
||||
# Handle provider creation if needed
|
||||
if 'provider' in model_data:
|
||||
provider_data = model_data.pop('provider')
|
||||
if provider_data.get('uuid'):
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
# Create new provider
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
|
||||
|
||||
llm_model = await self.get_llm_model(model_data['uuid'])
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
await self.ap.model_mgr.load_llm_model(llm_model)
|
||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||
persistence_model.LLMModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||
|
||||
# check if default pipeline has no model bound
|
||||
# set the default pipeline model to this model
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.is_default == True
|
||||
@@ -56,21 +111,47 @@ class LLMModelsService:
|
||||
return model_data['uuid']
|
||||
|
||||
async def get_llm_model(self, model_uuid: str) -> dict | None:
|
||||
"""Get a single LLM model with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
|
||||
)
|
||||
|
||||
model = result.first()
|
||||
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
|
||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
|
||||
|
||||
# Get provider
|
||||
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||
)
|
||||
)
|
||||
provider = provider_result.first()
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||
|
||||
return model_dict
|
||||
|
||||
async def update_llm_model(self, model_uuid: str, model_data: dict) -> None:
|
||||
"""Update an existing LLM model"""
|
||||
if 'uuid' in model_data:
|
||||
del model_data['uuid']
|
||||
|
||||
# Handle provider update if needed
|
||||
if 'provider' in model_data:
|
||||
provider_data = model_data.pop('provider')
|
||||
if provider_data.get('uuid'):
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.LLMModel)
|
||||
.where(persistence_model.LLMModel.uuid == model_uuid)
|
||||
@@ -79,18 +160,25 @@ class LLMModelsService:
|
||||
|
||||
await self.ap.model_mgr.remove_llm_model(model_uuid)
|
||||
|
||||
llm_model = await self.get_llm_model(model_uuid)
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
await self.ap.model_mgr.load_llm_model(llm_model)
|
||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||
persistence_model.LLMModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||
|
||||
async def delete_llm_model(self, model_uuid: str) -> None:
|
||||
"""Delete an LLM model"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
|
||||
)
|
||||
|
||||
await self.ap.model_mgr.remove_llm_model(model_uuid)
|
||||
|
||||
async def test_llm_model(self, model_uuid: str, model_data: dict) -> None:
|
||||
"""Test an LLM model"""
|
||||
runtime_llm_model: model_requester.RuntimeLLMModel | None = None
|
||||
|
||||
if model_uuid != '_':
|
||||
@@ -98,25 +186,18 @@ class LLMModelsService:
|
||||
if model.model_entity.uuid == model_uuid:
|
||||
runtime_llm_model = model
|
||||
break
|
||||
|
||||
if runtime_llm_model is None:
|
||||
raise Exception('model not found')
|
||||
|
||||
else:
|
||||
runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data)
|
||||
runtime_llm_model = await self.ap.model_mgr.init_temporary_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(
|
||||
extra_args = model_data.get('extra_args', {})
|
||||
await runtime_llm_model.provider.invoke_llm(
|
||||
query=None,
|
||||
model=runtime_llm_model,
|
||||
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
|
||||
funcs=[],
|
||||
# extra_args=extra_args,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
|
||||
|
||||
@@ -127,42 +208,111 @@ class EmbeddingModelsService:
|
||||
self.ap = ap
|
||||
|
||||
async def get_embedding_models(self) -> list[dict]:
|
||||
"""Get all embedding models with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
|
||||
|
||||
models = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model) for model in models]
|
||||
|
||||
async def create_embedding_model(self, model_data: dict) -> str:
|
||||
model_data['uuid'] = str(uuid.uuid4())
|
||||
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider)
|
||||
)
|
||||
providers = {p.uuid: p for p in providers_result.all()}
|
||||
|
||||
models_list = []
|
||||
for model in models:
|
||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)
|
||||
provider = providers.get(model.provider_uuid)
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||
models_list.append(model_dict)
|
||||
|
||||
return models_list
|
||||
|
||||
async def get_embedding_models_by_provider(self, provider_uuid: str) -> list[dict]:
|
||||
"""Get embedding models by provider UUID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
|
||||
)
|
||||
)
|
||||
models = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models]
|
||||
|
||||
async def create_embedding_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||
"""Create a new embedding model"""
|
||||
if not preserve_uuid:
|
||||
model_data['uuid'] = str(uuid.uuid4())
|
||||
|
||||
if 'provider' in model_data:
|
||||
provider_data = model_data.pop('provider')
|
||||
if provider_data.get('uuid'):
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)
|
||||
)
|
||||
|
||||
embedding_model = await self.get_embedding_model(model_data['uuid'])
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
await self.ap.model_mgr.load_embedding_model(embedding_model)
|
||||
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
||||
persistence_model.EmbeddingModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
||||
|
||||
return model_data['uuid']
|
||||
|
||||
async def get_embedding_model(self, model_uuid: str) -> dict | None:
|
||||
"""Get a single embedding model with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.uuid == model_uuid
|
||||
)
|
||||
)
|
||||
|
||||
model = result.first()
|
||||
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)
|
||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)
|
||||
|
||||
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||
)
|
||||
)
|
||||
provider = provider_result.first()
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||
|
||||
return model_dict
|
||||
|
||||
async def update_embedding_model(self, model_uuid: str, model_data: dict) -> None:
|
||||
"""Update an existing embedding model"""
|
||||
if 'uuid' in model_data:
|
||||
del model_data['uuid']
|
||||
|
||||
if 'provider' in model_data:
|
||||
provider_data = model_data.pop('provider')
|
||||
if provider_data.get('uuid'):
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.EmbeddingModel)
|
||||
.where(persistence_model.EmbeddingModel.uuid == model_uuid)
|
||||
@@ -171,20 +321,27 @@ class EmbeddingModelsService:
|
||||
|
||||
await self.ap.model_mgr.remove_embedding_model(model_uuid)
|
||||
|
||||
embedding_model = await self.get_embedding_model(model_uuid)
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
await self.ap.model_mgr.load_embedding_model(embedding_model)
|
||||
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
||||
persistence_model.EmbeddingModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
||||
|
||||
async def delete_embedding_model(self, model_uuid: str) -> None:
|
||||
"""Delete an embedding model"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.uuid == model_uuid
|
||||
)
|
||||
)
|
||||
|
||||
await self.ap.model_mgr.remove_embedding_model(model_uuid)
|
||||
|
||||
async def test_embedding_model(self, model_uuid: str, model_data: dict) -> None:
|
||||
"""Test an embedding model"""
|
||||
runtime_embedding_model: model_requester.RuntimeEmbeddingModel | None = None
|
||||
|
||||
if model_uuid != '_':
|
||||
@@ -192,14 +349,12 @@ class EmbeddingModelsService:
|
||||
if model.model_entity.uuid == model_uuid:
|
||||
runtime_embedding_model = model
|
||||
break
|
||||
|
||||
if runtime_embedding_model is None:
|
||||
raise Exception('model not found')
|
||||
|
||||
else:
|
||||
runtime_embedding_model = await self.ap.model_mgr.init_runtime_embedding_model(model_data)
|
||||
runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(model_data)
|
||||
|
||||
await runtime_embedding_model.requester.invoke_embedding(
|
||||
await runtime_embedding_model.provider.invoke_embedding(
|
||||
model=runtime_embedding_model,
|
||||
input_text=['Hello, world!'],
|
||||
extra_args={},
|
||||
|
||||
796
src/langbot/pkg/api/http/service/monitoring.py
Normal file
796
src/langbot/pkg/api/http/service/monitoring.py
Normal file
@@ -0,0 +1,796 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import datetime
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import monitoring as persistence_monitoring
|
||||
|
||||
|
||||
class MonitoringService:
|
||||
"""Monitoring service"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
# ========== Recording Methods ==========
|
||||
|
||||
async def record_message(
|
||||
self,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
message_content: str,
|
||||
session_id: str,
|
||||
status: str = 'success',
|
||||
level: str = 'info',
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
runner_name: str | None = None,
|
||||
variables: str | None = None,
|
||||
) -> str:
|
||||
"""Record a message"""
|
||||
message_id = str(uuid.uuid4())
|
||||
message_data = {
|
||||
'id': message_id,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'bot_id': bot_id,
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'message_content': message_content,
|
||||
'session_id': session_id,
|
||||
'status': status,
|
||||
'level': level,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'runner_name': runner_name,
|
||||
'variables': variables,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringMessage).values(message_data)
|
||||
)
|
||||
|
||||
return message_id
|
||||
|
||||
async def record_llm_call(
|
||||
self,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
session_id: str,
|
||||
model_name: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
duration: int,
|
||||
status: str = 'success',
|
||||
cost: float | None = None,
|
||||
error_message: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> str:
|
||||
"""Record an LLM call"""
|
||||
call_id = str(uuid.uuid4())
|
||||
call_data = {
|
||||
'id': call_id,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'model_name': model_name,
|
||||
'input_tokens': input_tokens,
|
||||
'output_tokens': output_tokens,
|
||||
'total_tokens': input_tokens + output_tokens,
|
||||
'duration': duration,
|
||||
'cost': cost,
|
||||
'status': status,
|
||||
'bot_id': bot_id,
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'session_id': session_id,
|
||||
'error_message': error_message,
|
||||
'message_id': message_id,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringLLMCall).values(call_data)
|
||||
)
|
||||
|
||||
return call_id
|
||||
|
||||
async def record_embedding_call(
|
||||
self,
|
||||
model_name: str,
|
||||
prompt_tokens: int,
|
||||
total_tokens: int,
|
||||
duration: int,
|
||||
input_count: int,
|
||||
status: str = 'success',
|
||||
error_message: str | None = None,
|
||||
knowledge_base_id: str | None = None,
|
||||
query_text: str | None = None,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
call_type: str | None = None,
|
||||
) -> str:
|
||||
"""Record an embedding call"""
|
||||
call_id = str(uuid.uuid4())
|
||||
call_data = {
|
||||
'id': call_id,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'model_name': model_name,
|
||||
'prompt_tokens': prompt_tokens,
|
||||
'total_tokens': total_tokens,
|
||||
'duration': duration,
|
||||
'input_count': input_count,
|
||||
'status': status,
|
||||
'error_message': error_message,
|
||||
'knowledge_base_id': knowledge_base_id,
|
||||
'query_text': query_text,
|
||||
'session_id': session_id,
|
||||
'message_id': message_id,
|
||||
'call_type': call_type,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringEmbeddingCall).values(call_data)
|
||||
)
|
||||
|
||||
return call_id
|
||||
|
||||
async def record_session_start(
|
||||
self,
|
||||
session_id: str,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record a new session"""
|
||||
session_data = {
|
||||
'session_id': session_id,
|
||||
'bot_id': bot_id,
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'message_count': 0,
|
||||
'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'is_active': True,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data)
|
||||
)
|
||||
|
||||
async def update_session_activity(
|
||||
self,
|
||||
session_id: str,
|
||||
pipeline_id: str | None = None,
|
||||
pipeline_name: str | None = None,
|
||||
) -> bool:
|
||||
"""Update session last activity time and increment message count.
|
||||
|
||||
Also updates pipeline info if the bot's pipeline has changed.
|
||||
|
||||
Returns:
|
||||
True if session was found and updated, False if session doesn't exist.
|
||||
"""
|
||||
update_values = {
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
|
||||
}
|
||||
|
||||
# Update pipeline info if provided (handles pipeline switch)
|
||||
if pipeline_id is not None:
|
||||
update_values['pipeline_id'] = pipeline_id
|
||||
if pipeline_name is not None:
|
||||
update_values['pipeline_name'] = pipeline_name
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_monitoring.MonitoringSession)
|
||||
.where(persistence_monitoring.MonitoringSession.session_id == session_id)
|
||||
.values(update_values)
|
||||
)
|
||||
# Check if any rows were updated
|
||||
return result.rowcount > 0
|
||||
|
||||
async def record_error(
|
||||
self,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
error_type: str,
|
||||
error_message: str,
|
||||
session_id: str | None = None,
|
||||
stack_trace: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> str:
|
||||
"""Record an error"""
|
||||
error_id = str(uuid.uuid4())
|
||||
error_data = {
|
||||
'id': error_id,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'error_type': error_type,
|
||||
'error_message': error_message,
|
||||
'bot_id': bot_id,
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'session_id': session_id,
|
||||
'stack_trace': stack_trace,
|
||||
'message_id': message_id,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringError).values(error_data)
|
||||
)
|
||||
|
||||
return error_id
|
||||
|
||||
async def update_message_status(
|
||||
self,
|
||||
message_id: str,
|
||||
status: str,
|
||||
level: str | None = None,
|
||||
variables: str | None = None,
|
||||
) -> None:
|
||||
"""Update message status and optionally variables"""
|
||||
update_values = {'status': status}
|
||||
if level is not None:
|
||||
update_values['level'] = level
|
||||
if variables is not None:
|
||||
update_values['variables'] = variables
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_monitoring.MonitoringMessage)
|
||||
.where(persistence_monitoring.MonitoringMessage.id == message_id)
|
||||
.values(update_values)
|
||||
)
|
||||
|
||||
# ========== Query Methods ==========
|
||||
|
||||
async def get_overview_metrics(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
) -> dict:
|
||||
"""Get overview metrics"""
|
||||
# Build base query conditions
|
||||
message_conditions = []
|
||||
llm_conditions = []
|
||||
embedding_conditions = []
|
||||
session_conditions = []
|
||||
|
||||
if bot_ids:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
|
||||
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
|
||||
|
||||
if pipeline_ids:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids))
|
||||
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids))
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
|
||||
|
||||
if start_time:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
|
||||
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
|
||||
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
|
||||
if end_time:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
|
||||
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
|
||||
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
|
||||
# Total messages
|
||||
message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
|
||||
if message_conditions:
|
||||
message_query = message_query.where(sqlalchemy.and_(*message_conditions))
|
||||
|
||||
total_messages_result = await self.ap.persistence_mgr.execute_async(message_query)
|
||||
total_messages = total_messages_result.scalar() or 0
|
||||
|
||||
# Total LLM calls
|
||||
llm_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id))
|
||||
if llm_conditions:
|
||||
llm_query = llm_query.where(sqlalchemy.and_(*llm_conditions))
|
||||
|
||||
llm_calls_result = await self.ap.persistence_mgr.execute_async(llm_query)
|
||||
llm_calls = llm_calls_result.scalar() or 0
|
||||
|
||||
# Total Embedding calls
|
||||
embedding_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id))
|
||||
if embedding_conditions:
|
||||
embedding_query = embedding_query.where(sqlalchemy.and_(*embedding_conditions))
|
||||
|
||||
embedding_calls_result = await self.ap.persistence_mgr.execute_async(embedding_query)
|
||||
embedding_calls = embedding_calls_result.scalar() or 0
|
||||
|
||||
# Total model calls (LLM + Embedding)
|
||||
model_calls = llm_calls + embedding_calls
|
||||
|
||||
# Success rate (based on messages)
|
||||
success_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)).where(
|
||||
persistence_monitoring.MonitoringMessage.status == 'success'
|
||||
)
|
||||
if message_conditions:
|
||||
success_query = success_query.where(sqlalchemy.and_(*message_conditions))
|
||||
|
||||
success_result = await self.ap.persistence_mgr.execute_async(success_query)
|
||||
success_count = success_result.scalar() or 0
|
||||
success_rate = (success_count / total_messages * 100) if total_messages > 0 else 100
|
||||
|
||||
# Active sessions
|
||||
active_session_query = sqlalchemy.select(
|
||||
sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id)
|
||||
).where(persistence_monitoring.MonitoringSession.is_active == True)
|
||||
if session_conditions:
|
||||
active_session_query = active_session_query.where(sqlalchemy.and_(*session_conditions))
|
||||
|
||||
active_sessions_result = await self.ap.persistence_mgr.execute_async(active_session_query)
|
||||
active_sessions = active_sessions_result.scalar() or 0
|
||||
|
||||
return {
|
||||
'total_messages': total_messages,
|
||||
'llm_calls': llm_calls,
|
||||
'embedding_calls': embedding_calls,
|
||||
'model_calls': model_calls,
|
||||
'success_rate': round(success_rate, 2),
|
||||
'active_sessions': active_sessions,
|
||||
}
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get messages with filters"""
|
||||
conditions = []
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
|
||||
|
||||
# Get total count
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
|
||||
if conditions:
|
||||
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Get messages
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by(
|
||||
persistence_monitoring.MonitoringMessage.timestamp.desc()
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
messages_rows = result.all()
|
||||
|
||||
serialized = []
|
||||
for row in messages_rows:
|
||||
# Extract model instance from Row (SQLAlchemy returns Row objects)
|
||||
msg = row[0] if isinstance(row, tuple) else row
|
||||
serialized_msg = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, msg)
|
||||
serialized.append(serialized_msg)
|
||||
|
||||
return (serialized, total)
|
||||
|
||||
async def get_llm_calls(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get LLM calls with filters"""
|
||||
conditions = []
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
|
||||
|
||||
# Get total count
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id))
|
||||
if conditions:
|
||||
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Get LLM calls
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by(
|
||||
persistence_monitoring.MonitoringLLMCall.timestamp.desc()
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
llm_calls_rows = result.all()
|
||||
|
||||
return (
|
||||
[
|
||||
self.ap.persistence_mgr.serialize_model(
|
||||
persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row
|
||||
)
|
||||
for row in llm_calls_rows
|
||||
],
|
||||
total,
|
||||
)
|
||||
|
||||
async def get_embedding_calls(
|
||||
self,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
knowledge_base_id: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get embedding calls with filters"""
|
||||
conditions = []
|
||||
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
|
||||
if knowledge_base_id:
|
||||
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id)
|
||||
|
||||
# Get total count
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id))
|
||||
if conditions:
|
||||
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Get embedding calls
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by(
|
||||
persistence_monitoring.MonitoringEmbeddingCall.timestamp.desc()
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
embedding_calls_rows = result.all()
|
||||
|
||||
return (
|
||||
[
|
||||
self.ap.persistence_mgr.serialize_model(
|
||||
persistence_monitoring.MonitoringEmbeddingCall, row[0] if isinstance(row, tuple) else row
|
||||
)
|
||||
for row in embedding_calls_rows
|
||||
],
|
||||
total,
|
||||
)
|
||||
|
||||
async def get_sessions(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
is_active: bool | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get sessions with filters"""
|
||||
conditions = []
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
if is_active is not None:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
||||
|
||||
# Get total count
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id))
|
||||
if conditions:
|
||||
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Get sessions
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
|
||||
persistence_monitoring.MonitoringSession.last_activity.desc()
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
sessions_rows = result.all()
|
||||
|
||||
return (
|
||||
[
|
||||
self.ap.persistence_mgr.serialize_model(
|
||||
persistence_monitoring.MonitoringSession, row[0] if isinstance(row, tuple) else row
|
||||
)
|
||||
for row in sessions_rows
|
||||
],
|
||||
total,
|
||||
)
|
||||
|
||||
async def get_errors(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get errors with filters"""
|
||||
conditions = []
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time)
|
||||
|
||||
# Get total count
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringError.id))
|
||||
if conditions:
|
||||
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Get errors
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by(
|
||||
persistence_monitoring.MonitoringError.timestamp.desc()
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
errors_rows = result.all()
|
||||
|
||||
return (
|
||||
[
|
||||
self.ap.persistence_mgr.serialize_model(
|
||||
persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row
|
||||
)
|
||||
for row in errors_rows
|
||||
],
|
||||
total,
|
||||
)
|
||||
|
||||
async def get_session_analysis(
|
||||
self,
|
||||
session_id: str,
|
||||
) -> dict:
|
||||
"""Get detailed analysis for a specific session"""
|
||||
# Get session info
|
||||
session_query = sqlalchemy.select(persistence_monitoring.MonitoringSession).where(
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id
|
||||
)
|
||||
session_result = await self.ap.persistence_mgr.execute_async(session_query)
|
||||
session_row = session_result.first()
|
||||
|
||||
if not session_row:
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'found': False,
|
||||
}
|
||||
|
||||
session = session_row[0] if isinstance(session_row, tuple) else session_row
|
||||
|
||||
# Get messages for this session
|
||||
messages_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
|
||||
.where(persistence_monitoring.MonitoringMessage.session_id == session_id)
|
||||
.order_by(persistence_monitoring.MonitoringMessage.timestamp.asc())
|
||||
)
|
||||
messages_result = await self.ap.persistence_mgr.execute_async(messages_query)
|
||||
messages_rows = messages_result.all()
|
||||
|
||||
# Count messages by status
|
||||
success_messages = 0
|
||||
error_messages = 0
|
||||
pending_messages = 0
|
||||
for row in messages_rows:
|
||||
msg = row[0] if isinstance(row, tuple) else row
|
||||
if msg.status == 'success':
|
||||
success_messages += 1
|
||||
elif msg.status == 'error':
|
||||
error_messages += 1
|
||||
elif msg.status == 'pending':
|
||||
pending_messages += 1
|
||||
|
||||
# Get LLM calls for this session
|
||||
llm_query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).where(
|
||||
persistence_monitoring.MonitoringLLMCall.session_id == session_id
|
||||
)
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
|
||||
llm_rows = llm_result.all()
|
||||
|
||||
# Calculate LLM statistics
|
||||
total_llm_calls = len(llm_rows)
|
||||
total_input_tokens = 0
|
||||
total_output_tokens = 0
|
||||
total_tokens = 0
|
||||
total_duration = 0
|
||||
success_llm_calls = 0
|
||||
error_llm_calls = 0
|
||||
|
||||
for row in llm_rows:
|
||||
llm_call = row[0] if isinstance(row, tuple) else row
|
||||
total_input_tokens += llm_call.input_tokens
|
||||
total_output_tokens += llm_call.output_tokens
|
||||
total_tokens += llm_call.total_tokens
|
||||
total_duration += llm_call.duration
|
||||
if llm_call.status == 'success':
|
||||
success_llm_calls += 1
|
||||
else:
|
||||
error_llm_calls += 1
|
||||
|
||||
# Get errors for this session
|
||||
error_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringError)
|
||||
.where(persistence_monitoring.MonitoringError.session_id == session_id)
|
||||
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
|
||||
)
|
||||
error_result = await self.ap.persistence_mgr.execute_async(error_query)
|
||||
error_rows = error_result.all()
|
||||
|
||||
errors = [
|
||||
self.ap.persistence_mgr.serialize_model(
|
||||
persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row
|
||||
)
|
||||
for row in error_rows
|
||||
]
|
||||
|
||||
# Calculate session duration
|
||||
if messages_rows:
|
||||
first_msg = messages_rows[0][0] if isinstance(messages_rows[0], tuple) else messages_rows[0]
|
||||
last_msg = messages_rows[-1][0] if isinstance(messages_rows[-1], tuple) else messages_rows[-1]
|
||||
session_duration_seconds = int((last_msg.timestamp - first_msg.timestamp).total_seconds())
|
||||
else:
|
||||
session_duration_seconds = 0
|
||||
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'found': True,
|
||||
'session': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSession, session),
|
||||
'message_stats': {
|
||||
'total': len(messages_rows),
|
||||
'success': success_messages,
|
||||
'error': error_messages,
|
||||
'pending': pending_messages,
|
||||
},
|
||||
'llm_stats': {
|
||||
'total_calls': total_llm_calls,
|
||||
'success_calls': success_llm_calls,
|
||||
'error_calls': error_llm_calls,
|
||||
'total_input_tokens': total_input_tokens,
|
||||
'total_output_tokens': total_output_tokens,
|
||||
'total_tokens': total_tokens,
|
||||
'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0,
|
||||
},
|
||||
'errors': errors,
|
||||
'session_duration_seconds': session_duration_seconds,
|
||||
}
|
||||
|
||||
async def get_message_details(
|
||||
self,
|
||||
message_id: str,
|
||||
) -> dict:
|
||||
"""Get detailed information for a specific message including associated LLM calls and errors"""
|
||||
# Get message info
|
||||
message_query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
|
||||
persistence_monitoring.MonitoringMessage.id == message_id
|
||||
)
|
||||
message_result = await self.ap.persistence_mgr.execute_async(message_query)
|
||||
message_row = message_result.first()
|
||||
|
||||
if not message_row:
|
||||
return {
|
||||
'message_id': message_id,
|
||||
'found': False,
|
||||
}
|
||||
|
||||
message = message_row[0] if isinstance(message_row, tuple) else message_row
|
||||
|
||||
# Get LLM calls for this message
|
||||
llm_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringLLMCall)
|
||||
.where(persistence_monitoring.MonitoringLLMCall.message_id == message_id)
|
||||
.order_by(persistence_monitoring.MonitoringLLMCall.timestamp.asc())
|
||||
)
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
|
||||
llm_rows = llm_result.all()
|
||||
|
||||
llm_calls = [
|
||||
self.ap.persistence_mgr.serialize_model(
|
||||
persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row
|
||||
)
|
||||
for row in llm_rows
|
||||
]
|
||||
|
||||
# Calculate LLM statistics
|
||||
total_input_tokens = sum(call.input_tokens for call in llm_rows)
|
||||
total_output_tokens = sum(call.output_tokens for call in llm_rows)
|
||||
total_tokens = sum(call.total_tokens for call in llm_rows)
|
||||
total_duration = sum(call.duration for call in llm_rows)
|
||||
|
||||
# Get errors for this message
|
||||
error_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringError)
|
||||
.where(persistence_monitoring.MonitoringError.message_id == message_id)
|
||||
.order_by(persistence_monitoring.MonitoringError.timestamp.asc())
|
||||
)
|
||||
error_result = await self.ap.persistence_mgr.execute_async(error_query)
|
||||
error_rows = error_result.all()
|
||||
|
||||
errors = [
|
||||
self.ap.persistence_mgr.serialize_model(
|
||||
persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row
|
||||
)
|
||||
for row in error_rows
|
||||
]
|
||||
|
||||
return {
|
||||
'message_id': message_id,
|
||||
'found': True,
|
||||
'message': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, message),
|
||||
'llm_calls': llm_calls,
|
||||
'llm_stats': {
|
||||
'total_calls': len(llm_rows),
|
||||
'total_input_tokens': total_input_tokens,
|
||||
'total_output_tokens': total_output_tokens,
|
||||
'total_tokens': total_tokens,
|
||||
'total_duration_ms': total_duration,
|
||||
'average_duration_ms': int(total_duration / len(llm_rows)) if len(llm_rows) > 0 else 0,
|
||||
},
|
||||
'errors': errors,
|
||||
}
|
||||
@@ -151,6 +151,52 @@ class PipelineService:
|
||||
)
|
||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
||||
|
||||
async def copy_pipeline(self, pipeline_uuid: str) -> str:
|
||||
"""Copy a pipeline with all its configurations"""
|
||||
# Get the original pipeline
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
)
|
||||
)
|
||||
|
||||
original_pipeline = result.first()
|
||||
if original_pipeline is None:
|
||||
raise ValueError(f'Pipeline {pipeline_uuid} not found')
|
||||
|
||||
# Create new pipeline data
|
||||
new_uuid = str(uuid.uuid4())
|
||||
new_pipeline_data = {
|
||||
'uuid': new_uuid,
|
||||
'name': f'{original_pipeline.name} (Copy)',
|
||||
'description': original_pipeline.description,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(),
|
||||
'config': original_pipeline.config.copy() if original_pipeline.config else {},
|
||||
'is_default': False,
|
||||
'extensions_preferences': (
|
||||
original_pipeline.extensions_preferences.copy()
|
||||
if original_pipeline.extensions_preferences
|
||||
else {
|
||||
'enable_all_plugins': True,
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
# Insert the new pipeline
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**new_pipeline_data)
|
||||
)
|
||||
|
||||
# Load the new pipeline
|
||||
pipeline = await self.get_pipeline(new_uuid)
|
||||
await self.ap.pipeline_mgr.load_pipeline(pipeline)
|
||||
|
||||
return new_uuid
|
||||
|
||||
async def update_pipeline_extensions(
|
||||
self,
|
||||
pipeline_uuid: str,
|
||||
|
||||
166
src/langbot/pkg/api/http/service/provider.py
Normal file
166
src/langbot/pkg/api/http/service/provider.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import model as persistence_model
|
||||
|
||||
|
||||
class ModelProviderService:
|
||||
"""Service for managing model providers"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_providers(self) -> list[dict]:
|
||||
"""Get all providers"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
||||
providers = result.all()
|
||||
providers_list = []
|
||||
for p in providers:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, p)
|
||||
# Parse api_keys if it's a JSON string
|
||||
if isinstance(provider_dict.get('api_keys'), str):
|
||||
import json
|
||||
|
||||
try:
|
||||
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
|
||||
except Exception:
|
||||
provider_dict['api_keys'] = []
|
||||
providers_list.append(provider_dict)
|
||||
return providers_list
|
||||
|
||||
async def get_provider(self, provider_uuid: str) -> dict | None:
|
||||
"""Get a single provider by UUID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
)
|
||||
)
|
||||
provider = result.first()
|
||||
if provider is None:
|
||||
return None
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
# Parse api_keys if it's a JSON string
|
||||
if isinstance(provider_dict.get('api_keys'), str):
|
||||
import json
|
||||
|
||||
try:
|
||||
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
|
||||
except Exception:
|
||||
provider_dict['api_keys'] = []
|
||||
return provider_dict
|
||||
|
||||
async def create_provider(self, provider_data: dict) -> str:
|
||||
"""Create a new provider"""
|
||||
provider_data['uuid'] = str(uuid.uuid4())
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||
)
|
||||
|
||||
# load to runtime
|
||||
runtime_provider = await self.ap.model_mgr.load_provider(provider_data)
|
||||
self.ap.model_mgr.provider_dict[runtime_provider.provider_entity.uuid] = runtime_provider
|
||||
return provider_data['uuid']
|
||||
|
||||
async def update_provider(self, provider_uuid: str, provider_data: dict) -> None:
|
||||
"""Update an existing provider"""
|
||||
if 'uuid' in provider_data:
|
||||
del provider_data['uuid']
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.ModelProvider)
|
||||
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||
.values(**provider_data)
|
||||
)
|
||||
await self.ap.model_mgr.reload_provider(provider_uuid)
|
||||
|
||||
async def delete_provider(self, provider_uuid: str) -> None:
|
||||
"""Delete a provider (only if no models reference it)"""
|
||||
# Check if any models use this provider
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(
|
||||
persistence_model.LLMModel.provider_uuid == provider_uuid
|
||||
)
|
||||
)
|
||||
if llm_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: LLM models still reference it')
|
||||
|
||||
embedding_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
|
||||
)
|
||||
)
|
||||
if embedding_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
)
|
||||
)
|
||||
|
||||
await self.ap.model_mgr.remove_provider(provider_uuid)
|
||||
|
||||
async def get_provider_model_counts(self, provider_uuid: str) -> dict:
|
||||
"""Get count of models using this provider"""
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(persistence_model.LLMModel)
|
||||
.where(persistence_model.LLMModel.provider_uuid == provider_uuid)
|
||||
)
|
||||
llm_count = llm_result.scalar() or 0
|
||||
|
||||
embedding_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(persistence_model.EmbeddingModel)
|
||||
.where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid)
|
||||
)
|
||||
embedding_count = embedding_result.scalar() or 0
|
||||
|
||||
return {'llm_count': llm_count, 'embedding_count': embedding_count}
|
||||
|
||||
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
||||
"""Find existing provider or create new one"""
|
||||
# Try to find existing provider with same config
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.requester == requester,
|
||||
persistence_model.ModelProvider.base_url == base_url,
|
||||
)
|
||||
)
|
||||
for provider in result.all():
|
||||
if sorted(provider.api_keys or []) == sorted(api_keys or []):
|
||||
return provider.uuid
|
||||
|
||||
# Create new provider
|
||||
provider_name = requester
|
||||
if base_url:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(base_url)
|
||||
provider_name = parsed.netloc or requester
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await self.create_provider(
|
||||
{
|
||||
'name': provider_name,
|
||||
'requester': requester,
|
||||
'base_url': base_url,
|
||||
'api_keys': api_keys or [],
|
||||
}
|
||||
)
|
||||
|
||||
async def update_space_model_provider_api_keys(self, api_key: str) -> None:
|
||||
"""Update Space model provider API keys"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.ModelProvider)
|
||||
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
||||
.values(api_keys=[api_key])
|
||||
)
|
||||
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
||||
189
src/langbot/pkg/api/http/service/space.py
Normal file
189
src/langbot/pkg/api/http/service/space.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
import typing
|
||||
import datetime
|
||||
import time
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import user
|
||||
from ....entity.dto.space_model import SpaceModel
|
||||
|
||||
|
||||
class SpaceService:
|
||||
"""Service for interacting with LangBot Space API"""
|
||||
|
||||
ap: app.Application
|
||||
_credits_cache: typing.Dict[str, typing.Tuple[int, float]] # {user_email: (credits, timestamp)}
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
self._credits_cache = {}
|
||||
|
||||
def _get_space_config(self) -> typing.Dict[str, str]:
|
||||
"""Get Space configuration from config file"""
|
||||
space_config = self.ap.instance_config.data.get('space', {})
|
||||
return {
|
||||
'url': space_config.get('url', 'https://space.langbot.app'),
|
||||
'oauth_authorize_url': space_config.get('oauth_authorize_url', 'https://space.langbot.app/auth/authorize'),
|
||||
}
|
||||
|
||||
async def _get_user_by_email(self, user_email: str) -> user.User | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(user.User).where(user.User.user == user_email)
|
||||
)
|
||||
result_list = result.all()
|
||||
return result_list[0] if result_list else None
|
||||
|
||||
async def _ensure_valid_token(self, user_email: str) -> str | None:
|
||||
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
|
||||
user_obj = await self._get_user_by_email(user_email)
|
||||
if not user_obj or user_obj.account_type != 'space':
|
||||
return None
|
||||
|
||||
if not user_obj.space_access_token:
|
||||
return None
|
||||
|
||||
# Check if token is expired (with 60s buffer)
|
||||
if user_obj.space_access_token_expires_at:
|
||||
if datetime.datetime.now() >= user_obj.space_access_token_expires_at - datetime.timedelta(seconds=60):
|
||||
# Token expired, try to refresh
|
||||
if user_obj.space_refresh_token:
|
||||
try:
|
||||
new_token = await self._refresh_and_save_token(user_obj)
|
||||
return new_token
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
return user_obj.space_access_token
|
||||
|
||||
async def _refresh_and_save_token(self, user_obj: user.User) -> str:
|
||||
"""Refresh token and save to database"""
|
||||
token_data = await self.refresh_token(user_obj.space_refresh_token)
|
||||
access_token = token_data.get('access_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
|
||||
if not access_token:
|
||||
raise ValueError('Failed to refresh token')
|
||||
|
||||
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.user == user_obj.user)
|
||||
.values(
|
||||
space_access_token=access_token,
|
||||
space_access_token_expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
|
||||
return access_token
|
||||
|
||||
# === Raw API calls (no token validation) ===
|
||||
|
||||
def get_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str:
|
||||
"""Get the Space OAuth authorization URL for redirect"""
|
||||
space_config = self._get_space_config()
|
||||
authorize_url = space_config['oauth_authorize_url']
|
||||
params = f'redirect_uri={redirect_uri}'
|
||||
if state:
|
||||
params += f'&state={state}'
|
||||
return f'{authorize_url}?{params}'
|
||||
|
||||
async def exchange_oauth_code(self, code: str) -> typing.Dict:
|
||||
"""Exchange OAuth authorization code for tokens"""
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
space_config = self._get_space_config()
|
||||
space_url = space_config['url']
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f'{space_url}/api/v1/accounts/oauth/token',
|
||||
json={'code': code, 'instance_id': constants.instance_id},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f'Failed to exchange OAuth code: {await response.text()}')
|
||||
data = await response.json()
|
||||
if data.get('code') != 0:
|
||||
raise ValueError(f'Failed to exchange OAuth code: {data.get("msg")}')
|
||||
return data.get('data', {})
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> typing.Dict:
|
||||
"""Refresh Space access token"""
|
||||
space_config = self._get_space_config()
|
||||
space_url = space_config['url']
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token}
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f'Failed to refresh token: {await response.text()}')
|
||||
data = await response.json()
|
||||
if data.get('code') != 0:
|
||||
raise ValueError(f'Failed to refresh token: {data.get("msg")}')
|
||||
return data.get('data', {})
|
||||
|
||||
async def get_user_info_raw(self, access_token: str) -> typing.Dict:
|
||||
"""Get user info from Space using access token (no validation)"""
|
||||
space_config = self._get_space_config()
|
||||
space_url = space_config['url']
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'}
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f'Failed to get user info: {await response.text()}')
|
||||
data = await response.json()
|
||||
if data.get('code') != 0:
|
||||
raise ValueError(f'Failed to get user info: {data.get("msg")}')
|
||||
return data.get('data', {})
|
||||
|
||||
# === API calls with token validation ===
|
||||
|
||||
async def get_user_info(self, user_email: str) -> typing.Dict | None:
|
||||
"""Get user info from Space (with token validation)"""
|
||||
access_token = await self._ensure_valid_token(user_email)
|
||||
if not access_token:
|
||||
return None
|
||||
return await self.get_user_info_raw(access_token)
|
||||
|
||||
async def get_credits(self, user_email: str, force_refresh: bool = False) -> int | None:
|
||||
"""Get Space credits for user with caching (60s TTL)"""
|
||||
cache_ttl = 60
|
||||
|
||||
if not force_refresh and user_email in self._credits_cache:
|
||||
credits, ts = self._credits_cache[user_email]
|
||||
if time.time() - ts < cache_ttl:
|
||||
return credits
|
||||
|
||||
try:
|
||||
info = await self.get_user_info(user_email)
|
||||
if info is None:
|
||||
return None
|
||||
credits = info.get('credits')
|
||||
if credits is not None:
|
||||
self._credits_cache[user_email] = (credits, time.time())
|
||||
return credits
|
||||
except Exception:
|
||||
return self._credits_cache.get(user_email, (None, 0))[0]
|
||||
|
||||
async def get_models(self) -> typing.List[SpaceModel]:
|
||||
"""Get models from Space"""
|
||||
|
||||
space_config = self._get_space_config()
|
||||
space_url = space_config['url']
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f'{space_url}/api/v1/models') as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f'Failed to get models: {await response.text()}')
|
||||
data = await response.json()
|
||||
if data.get('code') != 0:
|
||||
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
||||
models_data = data.get('data', {}).get('models', [])
|
||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||
@@ -4,17 +4,22 @@ import sqlalchemy
|
||||
import argon2
|
||||
import jwt
|
||||
import datetime
|
||||
import typing
|
||||
import asyncio
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import user
|
||||
from ....utils import constants
|
||||
from ....entity.errors import account as account_errors
|
||||
|
||||
|
||||
class UserService:
|
||||
ap: app.Application
|
||||
_create_user_lock: asyncio.Lock
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
self._create_user_lock = asyncio.Lock()
|
||||
|
||||
async def is_initialized(self) -> bool:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
|
||||
@@ -28,7 +33,7 @@ class UserService:
|
||||
hashed_password = ph.hash(password)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password)
|
||||
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')
|
||||
)
|
||||
|
||||
async def get_user_by_email(self, user_email: str) -> user.User | None:
|
||||
@@ -39,6 +44,15 @@ class UserService:
|
||||
result_list = result.all()
|
||||
return result_list[0] if result_list is not None and len(result_list) > 0 else None
|
||||
|
||||
async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None:
|
||||
"""Get user by Space account UUID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid)
|
||||
)
|
||||
|
||||
result_list = result.all()
|
||||
return result_list[0] if result_list is not None and len(result_list) > 0 else None
|
||||
|
||||
async def authenticate(self, user_email: str, password: str) -> str | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(user.User).where(user.User.user == user_email)
|
||||
@@ -51,6 +65,10 @@ class UserService:
|
||||
|
||||
user_obj = result_list[0]
|
||||
|
||||
# Check if this is a Space account
|
||||
if user_obj.account_type == 'space':
|
||||
raise ValueError('请使用 Space 账户登录')
|
||||
|
||||
ph = argon2.PasswordHasher()
|
||||
|
||||
ph.verify(user_obj.password, password)
|
||||
@@ -90,6 +108,10 @@ class UserService:
|
||||
if user_obj is None:
|
||||
raise ValueError('User not found')
|
||||
|
||||
# Space accounts cannot change password locally
|
||||
if user_obj.account_type == 'space':
|
||||
raise ValueError('Space account cannot change password locally')
|
||||
|
||||
ph.verify(user_obj.password, current_password)
|
||||
|
||||
hashed_password = ph.hash(new_password)
|
||||
@@ -97,3 +119,183 @@ class UserService:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
||||
)
|
||||
|
||||
# Space user management
|
||||
|
||||
async def create_or_update_space_user(
|
||||
self,
|
||||
space_account_uuid: str,
|
||||
email: str,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
api_key: str,
|
||||
expires_in: int = 0,
|
||||
) -> user.User:
|
||||
"""Create or update a Space user account (only if system not initialized or user exists)"""
|
||||
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
|
||||
|
||||
async with self._create_user_lock:
|
||||
# Check if user with this Space UUID already exists
|
||||
existing_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
|
||||
if existing_user:
|
||||
# Update existing user's tokens
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.space_account_uuid == space_account_uuid)
|
||||
.values(
|
||||
space_access_token=access_token,
|
||||
space_refresh_token=refresh_token,
|
||||
space_api_key=api_key,
|
||||
space_access_token_expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
|
||||
return await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
|
||||
# Check if user with same email exists
|
||||
existing_email_user = await self.get_user_by_email(email)
|
||||
if existing_email_user:
|
||||
# Update existing user to link with Space account
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.user == email)
|
||||
.values(
|
||||
account_type='space',
|
||||
space_account_uuid=space_account_uuid,
|
||||
space_access_token=access_token,
|
||||
space_refresh_token=refresh_token,
|
||||
space_api_key=api_key,
|
||||
space_access_token_expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
|
||||
return await self.get_user_by_email(email)
|
||||
|
||||
# Check if system is already initialized
|
||||
is_initialized = await self.is_initialized()
|
||||
if is_initialized:
|
||||
raise account_errors.AccountEmailMismatchError()
|
||||
|
||||
# Create new Space user (first time initialization)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(user.User).values(
|
||||
user=email,
|
||||
password='', # Space users don't have local password
|
||||
account_type='space',
|
||||
space_account_uuid=space_account_uuid,
|
||||
space_access_token=access_token,
|
||||
space_refresh_token=refresh_token,
|
||||
space_api_key=api_key,
|
||||
space_access_token_expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
|
||||
|
||||
return await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
|
||||
async def authenticate_space_user(
|
||||
self, access_token: str, refresh_token: str, expires_in: int = 0
|
||||
) -> typing.Tuple[str, user.User]:
|
||||
"""Authenticate with Space and return JWT token"""
|
||||
# Get user info from Space using raw API (token just obtained, no need to validate)
|
||||
user_info = await self.ap.space_service.get_user_info_raw(access_token)
|
||||
|
||||
account = user_info.get('account', {})
|
||||
api_key = user_info.get('api_key', '')
|
||||
|
||||
space_account_uuid = account.get('uuid')
|
||||
email = account.get('email')
|
||||
|
||||
if not space_account_uuid or not email:
|
||||
raise ValueError('Invalid Space user info')
|
||||
|
||||
# Create or update Space user in local database
|
||||
user_obj = await self.create_or_update_space_user(
|
||||
space_account_uuid=space_account_uuid,
|
||||
email=email,
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
api_key=api_key,
|
||||
expires_in=expires_in,
|
||||
)
|
||||
|
||||
# Generate JWT token
|
||||
jwt_token = await self.generate_jwt_token(email)
|
||||
|
||||
return jwt_token, user_obj
|
||||
|
||||
async def get_first_user(self) -> user.User | None:
|
||||
"""Get the first user (for single-user mode)"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
|
||||
result_list = result.all()
|
||||
return result_list[0] if result_list else None
|
||||
|
||||
async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None:
|
||||
"""Set or change password for a user"""
|
||||
ph = argon2.PasswordHasher()
|
||||
user_obj = await self.get_user_by_email(user_email)
|
||||
|
||||
if user_obj is None:
|
||||
raise ValueError('User not found')
|
||||
|
||||
# If user already has a password, verify current password
|
||||
has_password = bool(user_obj.password and user_obj.password.strip())
|
||||
if has_password:
|
||||
if not current_password:
|
||||
raise ValueError('Current password is required')
|
||||
ph.verify(user_obj.password, current_password)
|
||||
|
||||
hashed_password = ph.hash(new_password)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
||||
)
|
||||
|
||||
async def bind_space_account(self, user_email: str, code: str) -> user.User:
|
||||
"""Bind Space account to existing local account"""
|
||||
# Exchange code for tokens
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
|
||||
if not access_token:
|
||||
raise ValueError('Failed to get access token from Space')
|
||||
|
||||
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
|
||||
|
||||
# Get Space user info (token just obtained, use raw API)
|
||||
user_info = await self.ap.space_service.get_user_info_raw(access_token)
|
||||
account = user_info.get('account', {})
|
||||
api_key = user_info.get('api_key', '')
|
||||
|
||||
space_account_uuid = account.get('uuid')
|
||||
space_email = account.get('email')
|
||||
|
||||
if not space_account_uuid or not space_email:
|
||||
raise ValueError('Invalid Space user info')
|
||||
|
||||
# Check if this Space account is already bound to another user
|
||||
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
if existing_space_user and existing_space_user.user != user_email:
|
||||
raise ValueError('This Space account is already bound to another user')
|
||||
|
||||
# Update local account to Space account
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.user == user_email)
|
||||
.values(
|
||||
user=space_email, # Update email to Space email
|
||||
account_type='space',
|
||||
space_account_uuid=space_account_uuid,
|
||||
space_access_token=access_token,
|
||||
space_refresh_token=refresh_token,
|
||||
space_api_key=api_key,
|
||||
space_access_token_expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
|
||||
# Update Space model provider API keys
|
||||
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
|
||||
|
||||
return await self.get_user_by_email(space_email)
|
||||
|
||||
@@ -19,7 +19,9 @@ 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
|
||||
from ..api.http.service import space as space_service
|
||||
from ..api.http.service import model as model_service
|
||||
from ..api.http.service import provider as provider_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
|
||||
@@ -27,6 +29,7 @@ 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 ..api.http.service import external_kb as external_kb_service
|
||||
from ..api.http.service import monitoring as monitoring_service
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
from ..utils import logcache
|
||||
@@ -34,6 +37,7 @@ from . import taskmgr
|
||||
from . import entities as core_entities
|
||||
from ..rag.knowledge import kbmgr as rag_mgr
|
||||
from ..vector import mgr as vectordb_mgr
|
||||
from ..telemetry import telemetry as telemetry_module
|
||||
|
||||
|
||||
class Application:
|
||||
@@ -75,6 +79,8 @@ class Application:
|
||||
|
||||
instance_config: config_mgr.ConfigManager = None
|
||||
|
||||
instance_id: config_mgr.ConfigManager = None # used to identify the instance
|
||||
|
||||
# ======= Metadata config manager =======
|
||||
|
||||
sensitive_meta: config_mgr.ConfigManager = None
|
||||
@@ -114,10 +120,14 @@ class Application:
|
||||
|
||||
user_service: user_service.UserService = None
|
||||
|
||||
space_service: space_service.SpaceService = None
|
||||
|
||||
llm_model_service: model_service.LLMModelsService = None
|
||||
|
||||
embedding_models_service: model_service.EmbeddingModelsService = None
|
||||
|
||||
provider_service: provider_service.ModelProviderService = None
|
||||
|
||||
pipeline_service: pipeline_service.PipelineService = None
|
||||
|
||||
bot_service: bot_service.BotService = None
|
||||
@@ -132,6 +142,10 @@ class Application:
|
||||
|
||||
webhook_service: webhook_service.WebhookService = None
|
||||
|
||||
telemetry: telemetry_module.TelemetryManager = None
|
||||
|
||||
monitoring_service: monitoring_service.MonitoringService = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import logging.handlers
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -15,6 +16,10 @@ log_colors_config = {
|
||||
'CRITICAL': 'cyan',
|
||||
}
|
||||
|
||||
# Log rotation configuration to prevent unbounded log file growth
|
||||
LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file
|
||||
LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max)
|
||||
|
||||
|
||||
async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger:
|
||||
# Remove all existing loggers
|
||||
@@ -43,9 +48,17 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
|
||||
# stream_handler.setFormatter(color_formatter)
|
||||
stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
|
||||
|
||||
# Use RotatingFileHandler to prevent unbounded log file growth
|
||||
rotating_file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_file_name,
|
||||
encoding='utf-8',
|
||||
maxBytes=LOG_FILE_MAX_BYTES,
|
||||
backupCount=LOG_FILE_BACKUP_COUNT,
|
||||
)
|
||||
|
||||
log_handlers: list[logging.Handler] = [
|
||||
stream_handler,
|
||||
logging.FileHandler(log_file_name, encoding='utf-8'),
|
||||
rotating_file_handler,
|
||||
]
|
||||
log_handlers += extra_handlers if extra_handlers is not None else []
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ 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
|
||||
from ...api.http.service import space as space_service
|
||||
from ...api.http.service import model as model_service
|
||||
from ...api.http.service import provider as provider_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
|
||||
@@ -24,11 +26,13 @@ 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 ...api.http.service import external_kb as external_kb_service
|
||||
from ...api.http.service import monitoring as monitoring_service
|
||||
from ...discover import engine as discover_engine
|
||||
from ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
from ...vector import mgr as vectordb_mgr
|
||||
from .. import taskmgr
|
||||
from ...telemetry import telemetry as telemetry_module
|
||||
|
||||
|
||||
@stage.stage_class('BuildAppStage')
|
||||
@@ -43,6 +47,42 @@ class BuildAppStage(stage.BootingStage):
|
||||
discover.discover_blueprint('templates/components.yaml')
|
||||
ap.discover = discover
|
||||
|
||||
user_service_inst = user_service.UserService(ap)
|
||||
ap.user_service = user_service_inst
|
||||
|
||||
space_service_inst = space_service.SpaceService(ap)
|
||||
ap.space_service = space_service_inst
|
||||
|
||||
llm_model_service_inst = model_service.LLMModelsService(ap)
|
||||
ap.llm_model_service = llm_model_service_inst
|
||||
|
||||
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
||||
ap.embedding_models_service = embedding_models_service_inst
|
||||
|
||||
provider_service_inst = provider_service.ModelProviderService(ap)
|
||||
ap.provider_service = provider_service_inst
|
||||
|
||||
pipeline_service_inst = pipeline_service.PipelineService(ap)
|
||||
ap.pipeline_service = pipeline_service_inst
|
||||
|
||||
bot_service_inst = bot_service.BotService(ap)
|
||||
ap.bot_service = bot_service_inst
|
||||
|
||||
knowledge_service_inst = knowledge_service.KnowledgeService(ap)
|
||||
ap.knowledge_service = knowledge_service_inst
|
||||
|
||||
external_kb_service_inst = external_kb_service.ExternalKBService(ap)
|
||||
ap.external_kb_service = external_kb_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
|
||||
|
||||
proxy_mgr = proxy.ProxyManager(ap)
|
||||
await proxy_mgr.initialize()
|
||||
ap.proxy_mgr = proxy_mgr
|
||||
@@ -64,13 +104,18 @@ class BuildAppStage(stage.BootingStage):
|
||||
ap.persistence_mgr = persistence_mgr_inst
|
||||
await persistence_mgr_inst.initialize()
|
||||
|
||||
# Telemetry manager: attach to app so other components can call via self.ap.telemetry
|
||||
telemetry_inst = telemetry_module.TelemetryManager(ap)
|
||||
await telemetry_inst.initialize()
|
||||
ap.telemetry = telemetry_inst
|
||||
|
||||
cmd_mgr_inst = cmdmgr.CommandManager(ap)
|
||||
await cmd_mgr_inst.initialize()
|
||||
ap.cmd_mgr = cmd_mgr_inst
|
||||
|
||||
llm_model_mgr_inst = llm_model_mgr.ModelManager(ap)
|
||||
await llm_model_mgr_inst.initialize()
|
||||
ap.model_mgr = llm_model_mgr_inst
|
||||
await llm_model_mgr_inst.initialize()
|
||||
|
||||
llm_session_mgr_inst = llm_session_mgr.SessionManager(ap)
|
||||
await llm_session_mgr_inst.initialize()
|
||||
@@ -105,35 +150,8 @@ class BuildAppStage(stage.BootingStage):
|
||||
await http_ctrl.initialize()
|
||||
ap.http_ctrl = http_ctrl
|
||||
|
||||
user_service_inst = user_service.UserService(ap)
|
||||
ap.user_service = user_service_inst
|
||||
|
||||
llm_model_service_inst = model_service.LLMModelsService(ap)
|
||||
ap.llm_model_service = llm_model_service_inst
|
||||
|
||||
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
||||
ap.embedding_models_service = embedding_models_service_inst
|
||||
|
||||
pipeline_service_inst = pipeline_service.PipelineService(ap)
|
||||
ap.pipeline_service = pipeline_service_inst
|
||||
|
||||
bot_service_inst = bot_service.BotService(ap)
|
||||
ap.bot_service = bot_service_inst
|
||||
|
||||
knowledge_service_inst = knowledge_service.KnowledgeService(ap)
|
||||
ap.knowledge_service = knowledge_service_inst
|
||||
|
||||
external_kb_service_inst = external_kb_service.ExternalKBService(ap)
|
||||
ap.external_kb_service = external_kb_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
|
||||
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
||||
ap.monitoring_service = monitoring_service_inst
|
||||
|
||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||
await asyncio.sleep(3)
|
||||
|
||||
@@ -2,8 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from langbot.pkg.utils import constants
|
||||
import yaml
|
||||
import importlib.resources as resources
|
||||
import uuid
|
||||
import time
|
||||
|
||||
from .. import stage, app
|
||||
from ..bootutils import config
|
||||
@@ -142,6 +145,22 @@ class LoadConfigStage(stage.BootingStage):
|
||||
|
||||
await ap.instance_config.dump_config()
|
||||
|
||||
# load or generate instance id
|
||||
ap.instance_id = await config.load_json_config(
|
||||
'data/labels/instance_id.json',
|
||||
template_data={
|
||||
'instance_id': f'instance_{str(uuid.uuid4())}',
|
||||
'instance_create_ts': int(time.time()),
|
||||
},
|
||||
completion=False,
|
||||
)
|
||||
|
||||
constants.instance_id = ap.instance_id.data['instance_id']
|
||||
|
||||
print(f'LangBot instance id: {constants.instance_id}')
|
||||
|
||||
await ap.instance_id.dump_config()
|
||||
|
||||
ap.sensitive_meta = await config.load_json_config(
|
||||
'data/metadata/sensitive-words.json',
|
||||
'metadata/sensitive-words.json',
|
||||
|
||||
0
src/langbot/pkg/entity/dto/__init__.py
Normal file
0
src/langbot/pkg/entity/dto/__init__.py
Normal file
49
src/langbot/pkg/entity/dto/space_model.py
Normal file
49
src/langbot/pkg/entity/dto/space_model.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# [
|
||||
# {
|
||||
# "uuid": "7652ebdb-54dc-412c-a830-e9268ac88471",
|
||||
# "model_id": "claude-opus-4-5-20251101",
|
||||
# "display_name": {
|
||||
# "en_US": "claude-opus-4-5-20251101",
|
||||
# "zh_Hans": "claude-opus-4-5-20251101"
|
||||
# },
|
||||
# "description": {},
|
||||
# "provider": "anthropic",
|
||||
# "category": "chat",
|
||||
# "icon_url": "Claude.Color",
|
||||
# "tags": {},
|
||||
# "is_featured": true,
|
||||
# "featured_order": 999,
|
||||
# "model_ratio": 2.5,
|
||||
# "completion_ratio": 5,
|
||||
# "quota_type": 0,
|
||||
# "model_price": 0,
|
||||
# "input_credits": 500,
|
||||
# "output_credits": 2500,
|
||||
# "vendor_id": 1,
|
||||
# "vendor_name": "Anthropic",
|
||||
# "vendor_icon": "Claude.Color",
|
||||
# "supported_endpoints": [
|
||||
# "anthropic",
|
||||
# "openai"
|
||||
# ],
|
||||
# "status": "active",
|
||||
# "metadata": null,
|
||||
# "created_at": "2025-12-30T22:23:38.337207+08:00",
|
||||
# "updated_at": "2025-12-30T22:23:38.337207+08:00"
|
||||
# }
|
||||
# ]
|
||||
|
||||
import pydantic
|
||||
|
||||
|
||||
class SpaceModel(pydantic.BaseModel):
|
||||
uuid: str
|
||||
model_id: str
|
||||
provider: str
|
||||
category: str # chat / embedding
|
||||
llm_abilities: list[str] | None = None
|
||||
is_featured: bool = False
|
||||
featured_order: int = 0
|
||||
status: str
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
6
src/langbot/pkg/entity/errors/account.py
Normal file
6
src/langbot/pkg/entity/errors/account.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AccountEmailMismatchError(Exception):
|
||||
def __str__(self):
|
||||
return 'Account email mismatch'
|
||||
@@ -7,3 +7,11 @@ class RequesterNotFoundError(Exception):
|
||||
|
||||
def __str__(self):
|
||||
return f'Requester {self.requester_name} not found'
|
||||
|
||||
|
||||
class ProviderNotFoundError(Exception):
|
||||
def __init__(self, provider_name: str):
|
||||
self.provider_name = provider_name
|
||||
|
||||
def __str__(self):
|
||||
return f'Provider {self.provider_name} not found'
|
||||
|
||||
@@ -9,7 +9,7 @@ class MCPServer(Base):
|
||||
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
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
|
||||
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(
|
||||
|
||||
@@ -3,6 +3,25 @@ import sqlalchemy
|
||||
from .base import Base
|
||||
|
||||
|
||||
class ModelProvider(Base):
|
||||
"""Model provider"""
|
||||
|
||||
__tablename__ = 'model_providers'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
base_url = sqlalchemy.Column(sqlalchemy.String(512), nullable=False)
|
||||
api_keys = 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(),
|
||||
)
|
||||
|
||||
|
||||
class LLMModel(Base):
|
||||
"""LLM model"""
|
||||
|
||||
@@ -10,12 +29,10 @@ class LLMModel(Base):
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
requester_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
@@ -26,17 +43,15 @@ class LLMModel(Base):
|
||||
|
||||
|
||||
class EmbeddingModel(Base):
|
||||
"""Embedding 模型"""
|
||||
"""Embedding model"""
|
||||
|
||||
__tablename__ = 'embedding_models'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
requester_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
105
src/langbot/pkg/entity/persistence/monitoring.py
Normal file
105
src/langbot/pkg/entity/persistence/monitoring.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class MonitoringMessage(Base):
|
||||
"""Monitoring message records"""
|
||||
|
||||
__tablename__ = 'monitoring_messages'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
message_content = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error, pending
|
||||
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
|
||||
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
||||
|
||||
|
||||
class MonitoringLLMCall(Base):
|
||||
"""LLM call records"""
|
||||
|
||||
__tablename__ = 'monitoring_llm_calls'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
input_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
||||
output_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
||||
total_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
||||
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds
|
||||
cost = sqlalchemy.Column(sqlalchemy.Float, nullable=True)
|
||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
|
||||
|
||||
|
||||
class MonitoringSession(Base):
|
||||
"""Session tracking records"""
|
||||
|
||||
__tablename__ = 'monitoring_sessions'
|
||||
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
message_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
last_activity = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
|
||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
|
||||
|
||||
class MonitoringError(Base):
|
||||
"""Error log records"""
|
||||
|
||||
__tablename__ = 'monitoring_errors'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
error_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
stack_trace = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
|
||||
|
||||
|
||||
class MonitoringEmbeddingCall(Base):
|
||||
"""Embedding call records"""
|
||||
|
||||
__tablename__ = 'monitoring_embedding_calls'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
prompt_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
||||
total_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
||||
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds
|
||||
input_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # Number of input texts
|
||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error
|
||||
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
# Optional context fields
|
||||
knowledge_base_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
query_text = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # For retrieval calls
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
|
||||
@@ -11,6 +11,7 @@ class LegacyPipeline(Base):
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='⚙️')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
@@ -7,6 +7,7 @@ class KnowledgeBase(Base):
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String, index=True)
|
||||
description = sqlalchemy.Column(sqlalchemy.Text)
|
||||
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='📚')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now())
|
||||
embedding_model_uuid = sqlalchemy.Column(sqlalchemy.String, default='')
|
||||
@@ -35,6 +36,7 @@ class ExternalKnowledgeBase(Base):
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String, index=True)
|
||||
description = sqlalchemy.Column(sqlalchemy.Text)
|
||||
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🔗')
|
||||
plugin_author = sqlalchemy.Column(sqlalchemy.String, nullable=False)
|
||||
plugin_name = sqlalchemy.Column(sqlalchemy.String, nullable=False)
|
||||
retriever_name = sqlalchemy.Column(sqlalchemy.String, nullable=False)
|
||||
|
||||
@@ -9,6 +9,17 @@ class User(Base):
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
|
||||
user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
|
||||
# Account type: 'local' (default) or 'space'
|
||||
account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
|
||||
|
||||
# Space account fields (nullable, only used when account_type='space')
|
||||
space_account_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
space_access_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
space_refresh_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
space_access_token_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
space_api_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
@@ -9,7 +9,7 @@ import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||
import sqlalchemy
|
||||
|
||||
from . import database, migration
|
||||
from ..entity.persistence import base, pipeline, metadata
|
||||
from ..entity.persistence import base, pipeline, metadata, model as persistence_model
|
||||
from ..entity import persistence
|
||||
from ..core import app
|
||||
from ..utils import constants, importutil
|
||||
@@ -79,6 +79,7 @@ class PersistenceManager:
|
||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||
|
||||
await self.write_default_pipeline()
|
||||
await self.write_space_model_providers()
|
||||
|
||||
async def create_tables(self):
|
||||
# create tables
|
||||
@@ -123,7 +124,42 @@ class PersistenceManager:
|
||||
|
||||
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
|
||||
|
||||
# =================================
|
||||
async def write_space_model_providers(self):
|
||||
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
|
||||
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
|
||||
)
|
||||
|
||||
# write space model providers
|
||||
result = await self.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.requester == 'space-chat-completions'
|
||||
)
|
||||
)
|
||||
exists_space_chat_completions_model_provider = result.first()
|
||||
|
||||
# api keys will be set/updated when the oauth callback
|
||||
if exists_space_chat_completions_model_provider is None:
|
||||
self.ap.logger.info('Creating space model providers...')
|
||||
space_chat_completions_model_provider = {
|
||||
'uuid': '00000000-0000-0000-0000-000000000000',
|
||||
'name': 'LangBot Models',
|
||||
'requester': 'space-chat-completions',
|
||||
'base_url': space_models_gateway_api_url,
|
||||
'api_keys': [],
|
||||
}
|
||||
|
||||
await self.execute_async(
|
||||
sqlalchemy.insert(persistence_model.ModelProvider).values(space_chat_completions_model_provider)
|
||||
)
|
||||
else:
|
||||
if exists_space_chat_completions_model_provider.base_url != space_models_gateway_api_url:
|
||||
await self.execute_async(
|
||||
sqlalchemy.update(persistence_model.ModelProvider)
|
||||
.where(persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid)
|
||||
.values({'base_url': space_models_gateway_api_url})
|
||||
)
|
||||
|
||||
# =================================
|
||||
|
||||
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
||||
async with self.get_db_engine().connect() as conn:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(2)
|
||||
@@ -11,30 +10,45 @@ class DBMigrateCombineQuoteMsgConfig(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure 'trigger' exists
|
||||
if 'trigger' not in config:
|
||||
config['trigger'] = {}
|
||||
|
||||
# Ensure 'misc' exists in 'trigger'
|
||||
if 'misc' not in config['trigger']:
|
||||
config['trigger']['misc'] = {}
|
||||
|
||||
# Add 'combine-quote-message' if not exists
|
||||
if 'combine-quote-message' not in config['trigger']['misc']:
|
||||
config['trigger']['misc']['combine-quote-message'] = False
|
||||
|
||||
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(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(3)
|
||||
@@ -11,14 +10,23 @@ class DBMigrateN8nConfig(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure 'ai' exists
|
||||
if 'ai' not in config:
|
||||
config['ai'] = {}
|
||||
|
||||
# Add 'n8n-service-api' if not exists
|
||||
if 'n8n-service-api' not in config['ai']:
|
||||
config['ai']['n8n-service-api'] = {
|
||||
'webhook-url': 'http://your-n8n-webhook-url',
|
||||
@@ -33,16 +41,21 @@ class DBMigrateN8nConfig(migration.DBMigration):
|
||||
'output-key': 'response',
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(4)
|
||||
@@ -11,27 +10,43 @@ class DBMigrateRAGKBUUID(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""升级"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure nested structure exists
|
||||
if 'ai' not in config:
|
||||
config['ai'] = {}
|
||||
if 'local-agent' not in config['ai']:
|
||||
config['ai']['local-agent'] = {}
|
||||
|
||||
# Add 'knowledge-base' if not exists
|
||||
if 'knowledge-base' not in config['ai']['local-agent']:
|
||||
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(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""降级"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(5)
|
||||
@@ -11,27 +10,43 @@ class DBMigratePipelineRemoveCotConfig(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure nested structure exists
|
||||
if 'output' not in config:
|
||||
config['output'] = {}
|
||||
if 'misc' not in config['output']:
|
||||
config['output']['misc'] = {}
|
||||
|
||||
# Add 'remove-think' if not exists
|
||||
if 'remove-think' not in config['output']['misc']:
|
||||
config['output']['misc']['remove-think'] = False
|
||||
|
||||
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(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(6)
|
||||
@@ -11,14 +10,23 @@ class DBMigrateLangflowApiConfig(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure 'ai' exists
|
||||
if 'ai' not in config:
|
||||
config['ai'] = {}
|
||||
|
||||
# Add 'langflow-api' if not exists
|
||||
if 'langflow-api' not in config['ai']:
|
||||
config['ai']['langflow-api'] = {
|
||||
'base-url': 'http://localhost:7860',
|
||||
@@ -29,16 +37,21 @@ class DBMigrateLangflowApiConfig(migration.DBMigration):
|
||||
'tweaks': '{}',
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(10)
|
||||
@@ -11,16 +10,20 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Convert knowledge-base from string to array
|
||||
if 'local-agent' in config['ai']:
|
||||
if 'ai' in config and 'local-agent' in config['ai']:
|
||||
current_kb = config['ai']['local-agent'].get('knowledge-base', '')
|
||||
|
||||
# If it's already a list, skip
|
||||
@@ -37,29 +40,38 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
|
||||
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(),
|
||||
}
|
||||
)
|
||||
)
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Convert knowledge-bases from array back to string
|
||||
if 'local-agent' in config['ai']:
|
||||
if 'ai' in config and 'local-agent' in config['ai']:
|
||||
current_kbs = config['ai']['local-agent'].get('knowledge-bases', [])
|
||||
|
||||
# If it's already a string, skip
|
||||
@@ -76,13 +88,18 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
|
||||
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(),
|
||||
}
|
||||
)
|
||||
)
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(11)
|
||||
@@ -11,29 +10,45 @@ class DBMigrateDifyApiConfig(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure nested structure exists
|
||||
if 'ai' not in config:
|
||||
config['ai'] = {}
|
||||
if 'dify-service-api' not in config['ai']:
|
||||
config['ai']['dify-service-api'] = {}
|
||||
|
||||
# Add 'base-prompt' if not exists
|
||||
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(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(12)
|
||||
@@ -11,14 +10,25 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, extensions_preferences FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
extensions_preferences = serialized_pipeline['extensions_preferences']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
extensions_preferences = (
|
||||
json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
)
|
||||
|
||||
# Ensure extensions_preferences is a dict
|
||||
if extensions_preferences is None:
|
||||
extensions_preferences = {}
|
||||
|
||||
# Add 'enable_all_plugins' if not exists
|
||||
if 'enable_all_plugins' not in extensions_preferences:
|
||||
if 'plugins' in extensions_preferences:
|
||||
extensions_preferences['enable_all_plugins'] = False
|
||||
@@ -26,6 +36,7 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
|
||||
extensions_preferences['enable_all_plugins'] = True
|
||||
extensions_preferences['plugins'] = []
|
||||
|
||||
# Add 'enable_all_mcp_servers' if not exists
|
||||
if 'enable_all_mcp_servers' not in extensions_preferences:
|
||||
if 'mcp_servers' in extensions_preferences:
|
||||
extensions_preferences['enable_all_mcp_servers'] = False
|
||||
@@ -33,14 +44,29 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
|
||||
extensions_preferences['enable_all_mcp_servers'] = True
|
||||
extensions_preferences['mcp_servers'] = []
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
extensions_preferences=extensions_preferences,
|
||||
for_version=self.ap.ver_mgr.get_current_version(),
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{
|
||||
'extensions_preferences': json.dumps(extensions_preferences),
|
||||
'for_version': current_version,
|
||||
'uuid': uuid,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{
|
||||
'extensions_preferences': json.dumps(extensions_preferences),
|
||||
'for_version': current_version,
|
||||
'uuid': uuid,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(14)
|
||||
class DBMigrateSpaceAccountSupport(migration.DBMigration):
|
||||
"""Add Space account support fields to users table"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# Get all column names from the users table
|
||||
columns = []
|
||||
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("SELECT column_name FROM information_schema.columns WHERE table_name = 'users';")
|
||||
)
|
||||
all_result = result.fetchall()
|
||||
columns = [row[0] for row in all_result]
|
||||
else:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(users);'))
|
||||
all_result = result.fetchall()
|
||||
columns = [row[1] for row in all_result]
|
||||
|
||||
# Add account_type column
|
||||
if 'account_type' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL")
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL")
|
||||
)
|
||||
|
||||
# Add space_account_uuid column
|
||||
if 'space_account_uuid' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')
|
||||
)
|
||||
|
||||
# Add space_access_token column
|
||||
if 'space_access_token' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')
|
||||
)
|
||||
|
||||
# Add space_refresh_token column
|
||||
if 'space_refresh_token' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')
|
||||
)
|
||||
|
||||
# Add space_access_token_expires_at column
|
||||
if 'space_access_token_expires_at' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at TIMESTAMP')
|
||||
)
|
||||
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at DATETIME')
|
||||
)
|
||||
|
||||
# Add space_api_key column
|
||||
if 'space_api_key' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
from .. import migration
|
||||
|
||||
|
||||
# this is a deprecated migration
|
||||
@migration.migration_class(15)
|
||||
class DBMigrateModelSourceTracking(migration.DBMigration):
|
||||
"""Add source tracking fields to models tables for Space integration"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
pass
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
pass
|
||||
@@ -0,0 +1,305 @@
|
||||
import uuid as uuid_lib
|
||||
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(16)
|
||||
class DBMigrateModelProviderRefactor(migration.DBMigration):
|
||||
"""Refactor model structure: create providers from existing models and update references"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# Step 1: Create model_providers table if not exists
|
||||
await self._create_providers_table()
|
||||
|
||||
# Step 2: Migrate existing models to use providers
|
||||
await self._migrate_llm_models()
|
||||
await self._migrate_embedding_models()
|
||||
|
||||
# Step 3: Remove deprecated columns
|
||||
await self._cleanup_columns()
|
||||
|
||||
async def _create_providers_table(self):
|
||||
"""Create model_providers table"""
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("""
|
||||
CREATE TABLE IF NOT EXISTS model_providers (
|
||||
uuid VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
requester VARCHAR(255) NOT NULL,
|
||||
base_url VARCHAR(512) NOT NULL,
|
||||
api_keys JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("""
|
||||
CREATE TABLE IF NOT EXISTS model_providers (
|
||||
uuid VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
requester VARCHAR(255) NOT NULL,
|
||||
base_url VARCHAR(512) NOT NULL,
|
||||
api_keys JSON NOT NULL DEFAULT '[]',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
)
|
||||
|
||||
async def _migrate_llm_models(self):
|
||||
"""Migrate LLM models to use providers"""
|
||||
llm_columns = await self._get_columns('llm_models')
|
||||
|
||||
# Add provider_uuid column if not exists
|
||||
if 'provider_uuid' not in llm_columns:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN provider_uuid VARCHAR(255)')
|
||||
)
|
||||
|
||||
# Add prefered_ranking column if not exists
|
||||
if 'prefered_ranking' not in llm_columns:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0')
|
||||
)
|
||||
|
||||
# Only migrate if old columns exist
|
||||
if 'requester' not in llm_columns:
|
||||
return
|
||||
|
||||
# Get all LLM models with old structure
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM llm_models')
|
||||
)
|
||||
models = result.fetchall()
|
||||
|
||||
# Create providers and update models
|
||||
provider_cache = {} # (requester, base_url, api_keys_str) -> provider_uuid
|
||||
|
||||
for model in models:
|
||||
model_uuid, model_name, requester, requester_config, api_keys = model
|
||||
|
||||
# Extract base_url from requester_config
|
||||
base_url = ''
|
||||
if requester_config:
|
||||
if isinstance(requester_config, str):
|
||||
import json
|
||||
|
||||
requester_config = json.loads(requester_config)
|
||||
base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '')
|
||||
|
||||
# Parse api_keys if it's a string
|
||||
if isinstance(api_keys, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
api_keys = json.loads(api_keys)
|
||||
except Exception:
|
||||
api_keys = []
|
||||
if not api_keys:
|
||||
api_keys = []
|
||||
|
||||
# Create cache key
|
||||
api_keys_str = str(sorted(api_keys)) if api_keys else '[]'
|
||||
cache_key = (requester, base_url, api_keys_str)
|
||||
|
||||
if cache_key in provider_cache:
|
||||
provider_uuid = provider_cache[cache_key]
|
||||
else:
|
||||
# Create new provider
|
||||
provider_uuid = str(uuid_lib.uuid4())
|
||||
provider_name = f'{requester}'
|
||||
if base_url:
|
||||
# Extract domain for name
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(base_url)
|
||||
provider_name = parsed.netloc or requester
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import json
|
||||
|
||||
api_keys_json = json.dumps(api_keys) if api_keys else '[]'
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("""
|
||||
INSERT INTO model_providers (uuid, name, requester, base_url, api_keys)
|
||||
VALUES (:uuid, :name, :requester, :base_url, :api_keys)
|
||||
"""),
|
||||
{
|
||||
'uuid': provider_uuid,
|
||||
'name': provider_name,
|
||||
'requester': requester,
|
||||
'base_url': base_url,
|
||||
'api_keys': api_keys_json,
|
||||
},
|
||||
)
|
||||
provider_cache[cache_key] = provider_uuid
|
||||
|
||||
# Update model with provider_uuid
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('UPDATE llm_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'),
|
||||
{'provider_uuid': provider_uuid, 'uuid': model_uuid},
|
||||
)
|
||||
|
||||
async def _migrate_embedding_models(self):
|
||||
"""Migrate embedding models to use providers"""
|
||||
embedding_columns = await self._get_columns('embedding_models')
|
||||
|
||||
# Add provider_uuid column if not exists
|
||||
if 'provider_uuid' not in embedding_columns:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN provider_uuid VARCHAR(255)')
|
||||
)
|
||||
|
||||
# Add prefered_ranking column if not exists
|
||||
if 'prefered_ranking' not in embedding_columns:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0')
|
||||
)
|
||||
|
||||
# Only migrate if old columns exist
|
||||
if 'requester' not in embedding_columns:
|
||||
return
|
||||
|
||||
# Get all embedding models with old structure
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM embedding_models')
|
||||
)
|
||||
models = result.fetchall()
|
||||
|
||||
# Get existing providers
|
||||
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, requester, base_url, api_keys FROM model_providers')
|
||||
)
|
||||
existing_providers = provider_result.fetchall()
|
||||
|
||||
provider_cache = {}
|
||||
for p in existing_providers:
|
||||
p_uuid, p_requester, p_base_url, p_api_keys = p
|
||||
api_keys_str = str(sorted(p_api_keys)) if p_api_keys else '[]'
|
||||
provider_cache[(p_requester, p_base_url, api_keys_str)] = p_uuid
|
||||
|
||||
for model in models:
|
||||
model_uuid, model_name, requester, requester_config, api_keys = model
|
||||
|
||||
base_url = ''
|
||||
if requester_config:
|
||||
if isinstance(requester_config, str):
|
||||
import json
|
||||
|
||||
requester_config = json.loads(requester_config)
|
||||
base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '')
|
||||
|
||||
# Parse api_keys if it's a string
|
||||
if isinstance(api_keys, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
api_keys = json.loads(api_keys)
|
||||
except Exception:
|
||||
api_keys = []
|
||||
if not api_keys:
|
||||
api_keys = []
|
||||
|
||||
api_keys_str = str(sorted(api_keys)) if api_keys else '[]'
|
||||
cache_key = (requester, base_url, api_keys_str)
|
||||
|
||||
if cache_key in provider_cache:
|
||||
provider_uuid = provider_cache[cache_key]
|
||||
else:
|
||||
provider_uuid = str(uuid_lib.uuid4())
|
||||
provider_name = f'{requester}'
|
||||
if base_url:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(base_url)
|
||||
provider_name = parsed.netloc or requester
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import json
|
||||
|
||||
api_keys_json = json.dumps(api_keys) if api_keys else '[]'
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("""
|
||||
INSERT INTO model_providers (uuid, name, requester, base_url, api_keys)
|
||||
VALUES (:uuid, :name, :requester, :base_url, :api_keys)
|
||||
"""),
|
||||
{
|
||||
'uuid': provider_uuid,
|
||||
'name': provider_name,
|
||||
'requester': requester,
|
||||
'base_url': base_url,
|
||||
'api_keys': api_keys_json,
|
||||
},
|
||||
)
|
||||
provider_cache[cache_key] = provider_uuid
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('UPDATE embedding_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'),
|
||||
{'provider_uuid': provider_uuid, 'uuid': model_uuid},
|
||||
)
|
||||
|
||||
async def _cleanup_columns(self):
|
||||
"""Remove deprecated columns from model tables"""
|
||||
|
||||
llm_columns = await self._get_columns('llm_models')
|
||||
deprecated_llm_cols = ['requester', 'requester_config', 'api_keys', 'description', 'source', 'space_model_id']
|
||||
for col in deprecated_llm_cols:
|
||||
if col in llm_columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN IF EXISTS {col}')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN {col}')
|
||||
)
|
||||
|
||||
embedding_columns = await self._get_columns('embedding_models')
|
||||
deprecated_embedding_cols = [
|
||||
'requester',
|
||||
'requester_config',
|
||||
'api_keys',
|
||||
'description',
|
||||
'source',
|
||||
'space_model_id',
|
||||
]
|
||||
for col in deprecated_embedding_cols:
|
||||
if col in embedding_columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN IF EXISTS {col}')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN {col}')
|
||||
)
|
||||
|
||||
async def _get_columns(self, table_name: str) -> list:
|
||||
"""Get column names for a table"""
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';"
|
||||
)
|
||||
)
|
||||
all_result = result.fetchall()
|
||||
return [row[0] for row in all_result]
|
||||
else:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
|
||||
all_result = result.fetchall()
|
||||
return [row[1] for row in all_result]
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
pass
|
||||
@@ -0,0 +1,25 @@
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(17)
|
||||
class MoveCloudServiceUrl(migration.DBMigration):
|
||||
"""迁移云服务 URL 配置"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""升级"""
|
||||
if 'space' not in self.ap.instance_config.data:
|
||||
self.ap.instance_config.data['space'] = {
|
||||
'url': 'https://space.langbot.app',
|
||||
'models_gateway_api_url': 'https://api.langbot.cloud/v1',
|
||||
'oauth_authorize_url': 'https://space.langbot.app/auth/authorize',
|
||||
'disable_models_service': False,
|
||||
}
|
||||
|
||||
if 'plugin' in self.ap.instance_config.data:
|
||||
self.ap.instance_config.data['plugin'].pop('cloud_service_url', None)
|
||||
|
||||
await self.ap.instance_config.dump_config()
|
||||
|
||||
async def downgrade(self):
|
||||
"""降级"""
|
||||
pass
|
||||
@@ -0,0 +1,58 @@
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(18)
|
||||
class DBMigrateAddEmojiSupport(migration.DBMigration):
|
||||
"""Add emoji field to knowledge_bases, external_knowledge_bases and legacy_pipelines tables"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# Add emoji field to knowledge_bases
|
||||
await self._add_emoji_to_table('knowledge_bases', '📚')
|
||||
|
||||
# Add emoji field to external_knowledge_bases
|
||||
await self._add_emoji_to_table('external_knowledge_bases', '🔗')
|
||||
|
||||
# Add emoji field to legacy_pipelines
|
||||
await self._add_emoji_to_table('legacy_pipelines', '⚙️')
|
||||
|
||||
async def _add_emoji_to_table(self, table_name: str, default_emoji: str):
|
||||
"""Add emoji column to specified table if it doesn't exist"""
|
||||
# Get all column names from the table
|
||||
columns = []
|
||||
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';"
|
||||
)
|
||||
)
|
||||
all_result = result.fetchall()
|
||||
columns = [row[0] for row in all_result]
|
||||
else:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
|
||||
all_result = result.fetchall()
|
||||
columns = [row[1] for row in all_result]
|
||||
|
||||
# Check and add emoji column
|
||||
if 'emoji' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(f"ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10) DEFAULT '{default_emoji}'")
|
||||
)
|
||||
else:
|
||||
# SQLite doesn't support DEFAULT with emoji directly in ALTER TABLE
|
||||
# Add column without default first
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10)')
|
||||
)
|
||||
|
||||
# Set default emoji value for existing records
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(f"UPDATE {table_name} SET emoji = '{default_emoji}' WHERE emoji IS NULL")
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
pass
|
||||
@@ -33,11 +33,14 @@ class Controller:
|
||||
|
||||
for query in queries:
|
||||
session = await self.ap.sess_mgr.get_session(query)
|
||||
self.ap.logger.debug(f'Checking query {query} session {session}')
|
||||
# Debug logging removed from tight loop to prevent excessive log generation
|
||||
# that can cause memory overflow in high-traffic scenarios
|
||||
|
||||
if not session._semaphore.locked():
|
||||
selected_query = query
|
||||
await session._semaphore.acquire()
|
||||
# Only log when actually selecting a query
|
||||
self.ap.logger.debug(f'Selected query {query.query_id} for processing')
|
||||
|
||||
break
|
||||
|
||||
|
||||
270
src/langbot/pkg/pipeline/monitoring_helper.py
Normal file
270
src/langbot/pkg/pipeline/monitoring_helper.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Monitoring helper for recording events during pipeline execution.
|
||||
This module provides convenient methods to record monitoring data
|
||||
without cluttering the main pipeline code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
import typing
|
||||
import time
|
||||
import json
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
|
||||
|
||||
class MonitoringHelper:
|
||||
"""Helper class for monitoring operations"""
|
||||
|
||||
@staticmethod
|
||||
async def record_query_start(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
runner_name: str | None = None,
|
||||
) -> str:
|
||||
"""Record the start of query processing, returns message_id"""
|
||||
try:
|
||||
# Check if session exists, if not, record session start
|
||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||
|
||||
# Try to record message
|
||||
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
||||
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
||||
message_content = json.dumps(query.message_chain.model_dump(), ensure_ascii=False)
|
||||
else:
|
||||
message_content = str(query)
|
||||
|
||||
# Variables will be updated in record_query_success after preproc stage sets them
|
||||
# Here we just record None, the full variables will be set when query completes
|
||||
|
||||
message_id = await ap.monitoring_service.record_message(
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
message_content=message_content,
|
||||
session_id=session_id,
|
||||
status='pending',
|
||||
level='info',
|
||||
platform=query.launcher_type.value
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
runner_name=runner_name,
|
||||
variables=None, # Will be updated in record_query_success
|
||||
)
|
||||
|
||||
# Update session activity or create new session if it doesn't exist
|
||||
# Always pass pipeline info to handle pipeline switches
|
||||
session_updated = await ap.monitoring_service.update_session_activity(
|
||||
session_id,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
)
|
||||
if not session_updated:
|
||||
# Session doesn't exist, create it
|
||||
await ap.monitoring_service.record_session_start(
|
||||
session_id=session_id,
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
platform=query.launcher_type.value
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
)
|
||||
|
||||
return message_id
|
||||
except Exception as e:
|
||||
ap.logger.error(f'Failed to record query start: {e}')
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
async def record_query_success(
|
||||
ap: app.Application,
|
||||
message_id: str,
|
||||
query: pipeline_query.Query | None = None,
|
||||
):
|
||||
"""Record successful query processing by updating message status and variables"""
|
||||
try:
|
||||
if message_id:
|
||||
# Serialize query.variables (filtering out internal variables)
|
||||
query_variables_str = None
|
||||
if query and hasattr(query, 'variables') and query.variables:
|
||||
filtered_vars = {k: v for k, v in query.variables.items() if not k.startswith('_')}
|
||||
if filtered_vars:
|
||||
try:
|
||||
query_variables_str = json.dumps(filtered_vars, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await ap.monitoring_service.update_message_status(
|
||||
message_id=message_id,
|
||||
status='success',
|
||||
variables=query_variables_str,
|
||||
)
|
||||
except Exception as e:
|
||||
ap.logger.error(f'Failed to record query success: {e}')
|
||||
|
||||
@staticmethod
|
||||
async def record_query_error(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
error: Exception,
|
||||
runner_name: str | None = None,
|
||||
) -> str:
|
||||
"""Record query processing error, returns message_id"""
|
||||
try:
|
||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||
|
||||
# Record error message
|
||||
message_id = await ap.monitoring_service.record_message(
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
message_content=f'Error: {str(error)}',
|
||||
session_id=session_id,
|
||||
status='error',
|
||||
level='error',
|
||||
platform=query.launcher_type.value
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
|
||||
# Record error log
|
||||
await ap.monitoring_service.record_error(
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
error_type=type(error).__name__,
|
||||
error_message=str(error),
|
||||
session_id=session_id,
|
||||
stack_trace=traceback.format_exc(),
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
return message_id
|
||||
except Exception as e:
|
||||
ap.logger.error(f'Failed to record query error: {e}')
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
async def record_llm_call(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
model_name: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
duration_ms: int,
|
||||
status: str = 'success',
|
||||
cost: float | None = None,
|
||||
error_message: str | None = None,
|
||||
message_id: str | None = None,
|
||||
):
|
||||
"""Record LLM call"""
|
||||
try:
|
||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||
|
||||
await ap.monitoring_service.record_llm_call(
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
session_id=session_id,
|
||||
model_name=model_name,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
duration=duration_ms,
|
||||
status=status,
|
||||
cost=cost,
|
||||
error_message=error_message,
|
||||
message_id=message_id,
|
||||
)
|
||||
except Exception as e:
|
||||
ap.logger.error(f'Failed to record LLM call: {e}')
|
||||
|
||||
|
||||
class LLMCallMonitor:
|
||||
"""Context manager for monitoring LLM calls"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
model_name: str,
|
||||
):
|
||||
self.ap = ap
|
||||
self.query = query
|
||||
self.bot_id = bot_id
|
||||
self.bot_name = bot_name
|
||||
self.pipeline_id = pipeline_id
|
||||
self.pipeline_name = pipeline_name
|
||||
self.model_name = model_name
|
||||
self.start_time = None
|
||||
self.input_tokens = 0
|
||||
self.output_tokens = 0
|
||||
|
||||
async def __aenter__(self):
|
||||
self.start_time = time.time()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
duration_ms = int((time.time() - self.start_time) * 1000)
|
||||
|
||||
if exc_type is not None:
|
||||
# Error occurred
|
||||
await MonitoringHelper.record_llm_call(
|
||||
ap=self.ap,
|
||||
query=self.query,
|
||||
bot_id=self.bot_id,
|
||||
bot_name=self.bot_name,
|
||||
pipeline_id=self.pipeline_id,
|
||||
pipeline_name=self.pipeline_name,
|
||||
model_name=self.model_name,
|
||||
input_tokens=self.input_tokens,
|
||||
output_tokens=self.output_tokens,
|
||||
duration_ms=duration_ms,
|
||||
status='error',
|
||||
error_message=str(exc_val) if exc_val else None,
|
||||
)
|
||||
else:
|
||||
# Success
|
||||
await MonitoringHelper.record_llm_call(
|
||||
ap=self.ap,
|
||||
query=self.query,
|
||||
bot_id=self.bot_id,
|
||||
bot_name=self.bot_name,
|
||||
pipeline_id=self.pipeline_id,
|
||||
pipeline_name=self.pipeline_name,
|
||||
model_name=self.model_name,
|
||||
input_tokens=self.input_tokens,
|
||||
output_tokens=self.output_tokens,
|
||||
duration_ms=duration_ms,
|
||||
status='success',
|
||||
)
|
||||
|
||||
return False # Don't suppress exceptions
|
||||
@@ -115,6 +115,25 @@ class RuntimePipeline:
|
||||
# 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
|
||||
|
||||
# Record query start for monitoring
|
||||
try:
|
||||
# Get bot name from bot_uuid
|
||||
bot_name = 'WebChat'
|
||||
if query.bot_uuid:
|
||||
try:
|
||||
bot = await self.ap.bot_service.get_bot(query.bot_uuid, include_secret=False)
|
||||
if bot:
|
||||
bot_name = bot.get('name', 'Unknown')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Store for later use in process_query
|
||||
query.variables['_monitoring_bot_name'] = bot_name
|
||||
query.variables['_monitoring_pipeline_name'] = self.pipeline_entity.name
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to prepare monitoring data: {e}')
|
||||
|
||||
await self.process_query(query)
|
||||
|
||||
async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult):
|
||||
@@ -131,7 +150,7 @@ class RuntimePipeline:
|
||||
query.message_event, platform_events.GroupMessage
|
||||
):
|
||||
result.user_notice.insert(0, platform_message.At(target=query.message_event.sender.id))
|
||||
if await query.adapter.is_stream_output_supported():
|
||||
if await query.adapter.is_stream_output_supported() and query.resp_messages:
|
||||
await query.adapter.reply_message_chunk(
|
||||
message_source=query.message_event,
|
||||
bot_message=query.resp_messages[-1],
|
||||
@@ -151,6 +170,37 @@ class RuntimePipeline:
|
||||
self.ap.logger.info(result.console_notice)
|
||||
if result.error_notice:
|
||||
self.ap.logger.error(result.error_notice)
|
||||
# Mark query as having error
|
||||
query.variables['_monitoring_has_error'] = True
|
||||
# Record error to monitoring system
|
||||
try:
|
||||
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
||||
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
||||
message_id = query.variables.get('_monitoring_message_id', '')
|
||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||
|
||||
# Update message status to error
|
||||
if message_id:
|
||||
await self.ap.monitoring_service.update_message_status(
|
||||
message_id=message_id,
|
||||
status='error',
|
||||
level='error',
|
||||
)
|
||||
|
||||
# Record error log
|
||||
await self.ap.monitoring_service.record_error(
|
||||
bot_id=query.bot_uuid or 'unknown',
|
||||
bot_name=bot_name,
|
||||
pipeline_id=self.pipeline_entity.uuid,
|
||||
pipeline_name=pipeline_name,
|
||||
error_type='PipelineError',
|
||||
error_message=result.error_notice,
|
||||
session_id=session_id,
|
||||
stack_trace=result.debug_notice if result.debug_notice else None,
|
||||
message_id=message_id,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to record error to monitoring: {e}')
|
||||
|
||||
async def _execute_from_stage(
|
||||
self,
|
||||
@@ -221,6 +271,34 @@ class RuntimePipeline:
|
||||
|
||||
async def process_query(self, query: pipeline_query.Query):
|
||||
"""处理请求"""
|
||||
# Get monitoring metadata
|
||||
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
||||
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
||||
|
||||
# Get runner name from pipeline config
|
||||
runner_name = None
|
||||
if query.pipeline_config and 'ai' in query.pipeline_config and 'runner' in query.pipeline_config['ai']:
|
||||
runner_name = query.pipeline_config['ai']['runner'].get('runner')
|
||||
|
||||
# Record query start and store message_id
|
||||
message_id = ''
|
||||
try:
|
||||
from . import monitoring_helper
|
||||
|
||||
message_id = await monitoring_helper.MonitoringHelper.record_query_start(
|
||||
ap=self.ap,
|
||||
query=query,
|
||||
bot_id=query.bot_uuid or 'unknown',
|
||||
bot_name=bot_name,
|
||||
pipeline_id=self.pipeline_entity.uuid,
|
||||
pipeline_name=pipeline_name,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
# Store message_id in query variables for LLM call monitoring
|
||||
query.variables['_monitoring_message_id'] = message_id
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to record query start: {e}')
|
||||
|
||||
try:
|
||||
# Get bound plugins for this pipeline
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
@@ -237,6 +315,7 @@ class RuntimePipeline:
|
||||
launcher_type=query.launcher_type.value,
|
||||
launcher_id=query.launcher_id,
|
||||
sender_id=query.sender_id,
|
||||
message_event=query.message_event,
|
||||
message_chain=query.message_chain,
|
||||
)
|
||||
|
||||
@@ -248,10 +327,40 @@ class RuntimePipeline:
|
||||
self.ap.logger.debug(f'Processing query {query.query_id}')
|
||||
|
||||
await self._execute_from_stage(0, query)
|
||||
|
||||
# Record query success only if no error occurred during processing
|
||||
if not query.variables.get('_monitoring_has_error', False):
|
||||
try:
|
||||
await monitoring_helper.MonitoringHelper.record_query_success(
|
||||
ap=self.ap,
|
||||
message_id=message_id,
|
||||
query=query,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to record query success: {e}')
|
||||
|
||||
except Exception as e:
|
||||
inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
|
||||
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
|
||||
self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
|
||||
|
||||
# Record query error
|
||||
try:
|
||||
from . import monitoring_helper
|
||||
|
||||
await monitoring_helper.MonitoringHelper.record_query_error(
|
||||
ap=self.ap,
|
||||
query=query,
|
||||
bot_id=query.bot_uuid or 'unknown',
|
||||
bot_name=bot_name,
|
||||
pipeline_id=self.pipeline_entity.uuid,
|
||||
pipeline_name=pipeline_name,
|
||||
error=e,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
except Exception as me:
|
||||
self.ap.logger.error(f'Failed to record query error: {me}')
|
||||
|
||||
finally:
|
||||
self.ap.logger.debug(f'Query {query.query_id} processed')
|
||||
del self.ap.query_pool.cached_queries[query.query_id]
|
||||
|
||||
@@ -7,6 +7,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
|
||||
import langbot_plugin.api.entities.events as events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
|
||||
|
||||
@stage.stage_class('PreProcessor')
|
||||
@@ -74,12 +75,26 @@ class PreProcessor(stage.PipelineStage):
|
||||
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
||||
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
||||
|
||||
sender_name = ''
|
||||
|
||||
if isinstance(query.message_event, platform_events.GroupMessage):
|
||||
sender_name = query.message_event.sender.member_name
|
||||
elif isinstance(query.message_event, platform_events.FriendMessage):
|
||||
sender_name = query.message_event.sender.nickname
|
||||
|
||||
variables = {
|
||||
'launcher_type': query.session.launcher_type.value,
|
||||
'launcher_id': query.session.launcher_id,
|
||||
'sender_id': query.sender_id,
|
||||
'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
'conversation_id': conversation.uuid,
|
||||
'msg_create_time': (
|
||||
int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp())
|
||||
),
|
||||
'group_name': query.message_event.group.name
|
||||
if isinstance(query.message_event, platform_events.GroupMessage)
|
||||
else '',
|
||||
'sender_name': sender_name,
|
||||
}
|
||||
query.variables.update(variables)
|
||||
|
||||
@@ -111,6 +126,12 @@ class PreProcessor(stage.PipelineStage):
|
||||
):
|
||||
if me.base64 is not None:
|
||||
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
|
||||
elif isinstance(me, platform_message.Voice):
|
||||
# 转成文件链接,让下游 runner 上传到目标模型
|
||||
if me.base64:
|
||||
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk'))
|
||||
elif me.url:
|
||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
||||
elif isinstance(me, platform_message.File):
|
||||
# if me.url is not None:
|
||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import uuid
|
||||
import typing
|
||||
import traceback
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
from .. import handler
|
||||
@@ -10,10 +12,11 @@ from ... import entities
|
||||
from ....provider import runner as runner_module
|
||||
|
||||
import langbot_plugin.api.entities.events as events
|
||||
from ....utils import importutil
|
||||
from ....utils import importutil, constants
|
||||
from ....provider import runners
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
importutil.import_modules_in_pkg(runners)
|
||||
@@ -40,6 +43,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
launcher_id=query.launcher_id,
|
||||
sender_id=query.sender_id,
|
||||
text_message=str(query.message_chain),
|
||||
message_event=query.message_event,
|
||||
message_chain=query.message_chain,
|
||||
query=query,
|
||||
)
|
||||
@@ -60,8 +64,14 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
||||
else:
|
||||
if event_ctx.event.user_message_alter is not None:
|
||||
# if isinstance(event_ctx.event, str): # 现在暂时不考虑多模态alter
|
||||
query.user_message.content = event_ctx.event.user_message_alter
|
||||
if isinstance(event_ctx.event.user_message_alter, list):
|
||||
query.user_message.content = event_ctx.event.user_message_alter
|
||||
elif isinstance(event_ctx.event.user_message_alter, str):
|
||||
query.user_message.content = [
|
||||
provider_message.ContentElement.from_text(event_ctx.event.user_message_alter)
|
||||
]
|
||||
elif isinstance(event_ctx.event.user_message_alter, provider_message.ContentElement):
|
||||
query.user_message.content = [event_ctx.event.user_message_alter]
|
||||
|
||||
text_length = 0
|
||||
try:
|
||||
@@ -75,9 +85,13 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
runner = r(self.ap, query.pipeline_config)
|
||||
break
|
||||
else:
|
||||
raise ValueError(f'未找到请求运行器: {query.pipeline_config["ai"]["runner"]["runner"]}')
|
||||
raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}')
|
||||
# Mark start time for telemetry
|
||||
start_ts = time.time()
|
||||
|
||||
if is_stream:
|
||||
resp_message_id = uuid.uuid4()
|
||||
chunk_count = 0 # Track streaming chunks to reduce excessive logging
|
||||
|
||||
async for result in runner.run(query):
|
||||
result.resp_message_id = str(resp_message_id)
|
||||
@@ -90,18 +104,37 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
await query.adapter.create_message_card(str(resp_message_id), query.message_event)
|
||||
is_create_card = True
|
||||
query.resp_messages.append(result)
|
||||
self.ap.logger.info(f'对话({query.query_id})流式响应: {self.cut_str(result.readable_str())}')
|
||||
|
||||
chunk_count += 1
|
||||
# Only log every 10th chunk to reduce excessive logging during streaming
|
||||
# This prevents memory overflow from thousands of log entries per conversation
|
||||
# First chunk uses INFO level to confirm connection establishment
|
||||
if chunk_count == 1:
|
||||
self.ap.logger.info(
|
||||
f'Conversation({query.query_id}) Streaming started: {self.cut_str(result.readable_str())}'
|
||||
)
|
||||
elif chunk_count % 10 == 0:
|
||||
self.ap.logger.debug(
|
||||
f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}'
|
||||
)
|
||||
|
||||
if result.content is not None:
|
||||
text_length += len(result.content)
|
||||
|
||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
|
||||
# Log final summary after streaming completes
|
||||
self.ap.logger.info(
|
||||
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
|
||||
)
|
||||
|
||||
else:
|
||||
async for result in runner.run(query):
|
||||
query.resp_messages.append(result)
|
||||
|
||||
self.ap.logger.info(f'对话({query.query_id})响应: {self.cut_str(result.readable_str())}')
|
||||
self.ap.logger.info(
|
||||
f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}'
|
||||
)
|
||||
|
||||
if result.content is not None:
|
||||
text_length += len(result.content)
|
||||
@@ -112,7 +145,8 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
|
||||
query.session.using_conversation.messages.extend(query.resp_messages)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'对话({query.query_id})请求失败: {type(e).__name__} {str(e)}')
|
||||
error_info = f'{traceback.format_exc()}'
|
||||
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
||||
traceback.print_exc()
|
||||
|
||||
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
|
||||
@@ -125,5 +159,47 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
debug_notice=traceback.format_exc(),
|
||||
)
|
||||
finally:
|
||||
# TODO statistics
|
||||
pass
|
||||
# Telemetry reporting: collect minimal per-query execution info and send asynchronously
|
||||
try:
|
||||
end_ts = time.time()
|
||||
duration_ms = None
|
||||
if 'start_ts' in locals():
|
||||
duration_ms = int((end_ts - start_ts) * 1000)
|
||||
|
||||
adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None
|
||||
runner_name = (
|
||||
query.pipeline_config.get('ai', {}).get('runner', {}).get('runner')
|
||||
if query.pipeline_config
|
||||
else None
|
||||
)
|
||||
|
||||
# Model name if using localagent
|
||||
model_name = None
|
||||
try:
|
||||
if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None):
|
||||
m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||
if m and getattr(m, 'model_entity', None):
|
||||
model_name = getattr(m.model_entity, 'name', None)
|
||||
except Exception:
|
||||
model_name = None
|
||||
|
||||
pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
|
||||
payload = {
|
||||
'query_id': query.query_id,
|
||||
'adapter': adapter_name,
|
||||
'runner': runner_name,
|
||||
'duration_ms': duration_ms,
|
||||
'model_name': model_name,
|
||||
'version': constants.semantic_version,
|
||||
'instance_id': constants.instance_id,
|
||||
'pipeline_plugins': pipeline_plugins,
|
||||
'error': locals().get('error_info', None),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Send telemetry asynchronously and do not block pipeline via app's telemetry manager
|
||||
await self.ap.telemetry.start_send_task(payload)
|
||||
except Exception as ex:
|
||||
# Ensure telemetry issues do not affect normal flow
|
||||
self.ap.logger.warning(f'Failed to send telemetry: {ex}')
|
||||
|
||||
@@ -31,4 +31,8 @@ class AtBotRule(rule_model.GroupRespondRule):
|
||||
remove_at(message_chain)
|
||||
remove_at(message_chain) # 回复消息时会at两次,检查并删除重复的
|
||||
|
||||
should_respond_at = rule_dict.get('at', None)
|
||||
if should_respond_at is not None:
|
||||
return entities.RuleJudgeResult(matching=found and bool(should_respond_at), replacement=message_chain)
|
||||
|
||||
return entities.RuleJudgeResult(matching=found, replacement=message_chain)
|
||||
|
||||
@@ -66,22 +66,34 @@ class RuntimeBot:
|
||||
message_session_id=f'person_{event.sender.id}',
|
||||
)
|
||||
|
||||
# Push to webhooks
|
||||
# Push to webhooks and check if pipeline should be skipped
|
||||
skip_pipeline = False
|
||||
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__)
|
||||
skip_pipeline = await 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,
|
||||
launcher_id=event.sender.id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
launcher_id = event.sender.id
|
||||
|
||||
if hasattr(adapter, 'get_launcher_id'):
|
||||
custom_launcher_id = adapter.get_launcher_id(event)
|
||||
if custom_launcher_id:
|
||||
launcher_id = custom_launcher_id
|
||||
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
else:
|
||||
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
||||
|
||||
async def on_group_message(
|
||||
event: platform_events.GroupMessage,
|
||||
@@ -97,22 +109,34 @@ class RuntimeBot:
|
||||
message_session_id=f'group_{event.group.id}',
|
||||
)
|
||||
|
||||
# Push to webhooks
|
||||
# Push to webhooks and check if pipeline should be skipped
|
||||
skip_pipeline = False
|
||||
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__)
|
||||
skip_pipeline = await 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,
|
||||
launcher_id=event.group.id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
launcher_id = event.group.id
|
||||
|
||||
if hasattr(adapter, 'get_launcher_id'):
|
||||
custom_launcher_id = adapter.get_launcher_id(event)
|
||||
if custom_launcher_id:
|
||||
launcher_id = custom_launcher_id
|
||||
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
else:
|
||||
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
||||
|
||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||
|
||||
@@ -327,9 +327,6 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title=event.sender['title'] if 'title' in event.sender else '',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=yiri_chain,
|
||||
time=event.time,
|
||||
|
||||
@@ -119,9 +119,6 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = event.incoming_message.create_at
|
||||
return platform_events.GroupMessage(
|
||||
@@ -263,7 +260,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.bot.start()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
await self.bot.stop()
|
||||
return True
|
||||
|
||||
async def is_muted(self) -> bool:
|
||||
return False
|
||||
|
||||
@@ -8,6 +8,9 @@ import base64
|
||||
import uuid
|
||||
import os
|
||||
import datetime
|
||||
|
||||
# 使用BytesIO创建文件对象,避免路径问题
|
||||
import io
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
|
||||
@@ -594,7 +597,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
|
||||
break
|
||||
|
||||
text_string = ''
|
||||
image_files = []
|
||||
files = []
|
||||
|
||||
for ele in message_chain:
|
||||
if isinstance(ele, platform_message.Image):
|
||||
@@ -668,22 +671,67 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
|
||||
continue # 跳过读取失败的文件
|
||||
|
||||
if image_bytes:
|
||||
# 使用BytesIO创建文件对象,避免路径问题
|
||||
import io
|
||||
|
||||
image_files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
|
||||
files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
|
||||
elif isinstance(ele, platform_message.Plain):
|
||||
text_string += ele.text
|
||||
elif isinstance(ele, platform_message.Voice):
|
||||
file_bytes = None
|
||||
filename = f'{uuid.uuid4()}.mp3'
|
||||
if ele.base64:
|
||||
if ele.base64.startswith('data:'):
|
||||
data_header = ele.base64.split(',')[0]
|
||||
if 'wav' in data_header:
|
||||
filename = f'{uuid.uuid4()}.wav'
|
||||
elif 'mp3' in data_header:
|
||||
filename = f'{uuid.uuid4()}.mp3'
|
||||
elif 'ogg' in data_header:
|
||||
filename = f'{uuid.uuid4()}.ogg'
|
||||
elif 'm4a' in data_header:
|
||||
filename = f'{uuid.uuid4()}.m4a'
|
||||
elif 'aac' in data_header:
|
||||
filename = f'{uuid.uuid4()}.aac'
|
||||
elif 'flac' in data_header:
|
||||
filename = f'{uuid.uuid4()}.flac'
|
||||
elif 'alac' in data_header:
|
||||
filename = f'{uuid.uuid4()}.alac'
|
||||
elif 'opus' in data_header:
|
||||
filename = f'{uuid.uuid4()}.opus'
|
||||
elif 'webm' in data_header:
|
||||
filename = f'{uuid.uuid4()}.webm'
|
||||
|
||||
file_base64 = ele.base64.split(',')[-1]
|
||||
file_bytes = base64.b64decode(file_base64)
|
||||
elif ele.url:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(ele.url) as response:
|
||||
file_bytes = await response.read()
|
||||
if file_bytes:
|
||||
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
|
||||
elif isinstance(ele, platform_message.File):
|
||||
file_bytes = None
|
||||
filename = f'{uuid.uuid4()}.{ele.name.split(".")[-1]}'
|
||||
if ele.base64:
|
||||
if ele.base64.startswith('data:'):
|
||||
file_base64 = ele.base64.split(',')[1]
|
||||
file_bytes = base64.b64decode(file_base64)
|
||||
else:
|
||||
file_bytes = base64.b64decode(ele.base64)
|
||||
elif ele.url:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(ele.url) as response:
|
||||
file_bytes = await response.read()
|
||||
if file_bytes:
|
||||
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
|
||||
elif isinstance(ele, platform_message.Forward):
|
||||
for node in ele.node_list:
|
||||
(
|
||||
node_text,
|
||||
node_images,
|
||||
node_files,
|
||||
) = await DiscordMessageConverter.yiri2target(node.message_chain)
|
||||
text_string += node_text
|
||||
image_files.extend(node_images)
|
||||
files.extend(node_files)
|
||||
|
||||
return text_string, image_files
|
||||
return text_string, files
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: discord.Message) -> platform_message.MessageChain:
|
||||
@@ -769,9 +817,6 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.created_at.timestamp(),
|
||||
@@ -993,7 +1038,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.voice_manager.cleanup_inactive_connections()
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
msg_to_send, image_files = await self.message_converter.yiri2target(message)
|
||||
msg_to_send, files = await self.message_converter.yiri2target(message)
|
||||
|
||||
try:
|
||||
# 获取频道对象
|
||||
@@ -1006,8 +1051,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'content': msg_to_send,
|
||||
}
|
||||
|
||||
if len(image_files) > 0:
|
||||
args['files'] = image_files
|
||||
if len(files) > 0:
|
||||
args['files'] = files
|
||||
|
||||
await channel.send(**args)
|
||||
|
||||
@@ -1021,15 +1066,16 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
msg_to_send, image_files = await self.message_converter.yiri2target(message)
|
||||
msg_to_send, files = await self.message_converter.yiri2target(message)
|
||||
|
||||
assert isinstance(message_source.source_platform_object, discord.Message)
|
||||
|
||||
args = {
|
||||
'content': msg_to_send,
|
||||
}
|
||||
|
||||
if len(image_files) > 0:
|
||||
args['files'] = image_files
|
||||
if len(files) > 0:
|
||||
args['files'] = files
|
||||
|
||||
if quote_origin:
|
||||
args['reference'] = message_source.source_platform_object
|
||||
|
||||
BIN
src/langbot/pkg/platform/sources/kook.png
Normal file
BIN
src/langbot/pkg/platform/sources/kook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
680
src/langbot/pkg/platform/sources/kook.py
Normal file
680
src/langbot/pkg/platform/sources/kook.py
Normal file
@@ -0,0 +1,680 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
import json
|
||||
import base64
|
||||
import zlib
|
||||
import traceback
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
import websockets
|
||||
import pydantic
|
||||
|
||||
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
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Convert between LangBot MessageChain and KOOK message format"""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]:
|
||||
"""
|
||||
Convert LangBot MessageChain to KOOK message format
|
||||
|
||||
Returns:
|
||||
tuple: (content, message_type)
|
||||
- content: message content string
|
||||
- message_type: 1=text, 2=image, 4=file, 9=KMarkdown
|
||||
"""
|
||||
content_parts = []
|
||||
message_type = 1 # Default to text
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
content_parts.append(component.text)
|
||||
elif isinstance(component, platform_message.At):
|
||||
# KOOK mention format: (met)user_id(met)
|
||||
if component.target:
|
||||
content_parts.append(f'(met){component.target}(met)')
|
||||
elif isinstance(component, platform_message.AtAll):
|
||||
# KOOK @all format: (met)all(met)
|
||||
content_parts.append('(met)all(met)')
|
||||
elif isinstance(component, platform_message.Image):
|
||||
# For images, we need to upload first via KOOK's asset API
|
||||
# For now, we'll send the image URL if available
|
||||
if component.url:
|
||||
content_parts.append(component.url)
|
||||
message_type = 2 # Image message type
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
# Handle forward messages by concatenating content
|
||||
for node in component.node_list:
|
||||
forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain)
|
||||
content_parts.append(forward_content)
|
||||
# Ignore Source and other components
|
||||
|
||||
content = ''.join(content_parts)
|
||||
return content, message_type
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain:
|
||||
"""
|
||||
Convert KOOK message format to LangBot MessageChain
|
||||
|
||||
Args:
|
||||
kook_message: KOOK message event data dict
|
||||
bot_account_id: Bot's account ID for handling role mentions
|
||||
"""
|
||||
components = []
|
||||
|
||||
msg_type = kook_message.get('type', 1)
|
||||
content = kook_message.get('content', '')
|
||||
extra = kook_message.get('extra', {})
|
||||
|
||||
# Handle mentions
|
||||
mentions = extra.get('mention', [])
|
||||
mention_all = extra.get('mention_all', False)
|
||||
mention_roles = extra.get('mention_roles', [])
|
||||
|
||||
if mention_all:
|
||||
components.append(platform_message.AtAll())
|
||||
|
||||
for mention_id in mentions:
|
||||
components.append(platform_message.At(target=str(mention_id)))
|
||||
|
||||
# Handle role mentions (when bot is mentioned via role)
|
||||
# In KOOK, when a role that the bot has is mentioned, we receive it as a role mention
|
||||
# We need to convert this to an At with the bot's account ID for the pipeline to recognize it
|
||||
if mention_roles and bot_account_id:
|
||||
# Add an At component with the bot's account ID when any role is mentioned
|
||||
# This is because KOOK bots are often assigned roles and @role mentions should trigger responses
|
||||
components.append(platform_message.At(target=bot_account_id))
|
||||
|
||||
# Strip mention patterns from content
|
||||
# Remove user mention patterns: (met)USER_ID(met)
|
||||
for mention_id in mentions:
|
||||
content = content.replace(f'(met){mention_id}(met)', '')
|
||||
|
||||
# Remove @all pattern
|
||||
if mention_all:
|
||||
content = content.replace('(met)all(met)', '')
|
||||
|
||||
# Remove role mention patterns: (rol)ROLE_ID(rol)
|
||||
for role_id in mention_roles:
|
||||
content = content.replace(f'(rol){role_id}(rol)', '')
|
||||
|
||||
# Clean up extra whitespace
|
||||
content = content.strip()
|
||||
|
||||
# Handle different message types
|
||||
if msg_type == 1: # Text message
|
||||
if content:
|
||||
components.append(platform_message.Plain(text=content))
|
||||
elif msg_type == 2: # Image message
|
||||
# Image content is typically a URL
|
||||
if content:
|
||||
# Download image and convert to base64
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(content) as response:
|
||||
if response.status == 200:
|
||||
image_bytes = await response.read()
|
||||
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
|
||||
# Detect image format
|
||||
content_type = response.headers.get('Content-Type', 'image/png')
|
||||
components.append(
|
||||
platform_message.Image(base64=f'data:{content_type};base64,{image_base64}')
|
||||
)
|
||||
except Exception:
|
||||
# If download fails, just add as plain text
|
||||
components.append(platform_message.Plain(text=f'[Image: {content}]'))
|
||||
elif msg_type == 4: # File message
|
||||
# For file messages, content is typically the file URL
|
||||
attachments = extra.get('attachments', {})
|
||||
file_name = attachments.get('name', 'file')
|
||||
components.append(platform_message.File(url=content, name=file_name))
|
||||
elif msg_type == 8: # Audio message
|
||||
# For audio messages, content is typically the audio URL
|
||||
attachments = extra.get('attachments', {})
|
||||
components.append(platform_message.Voice(url=content))
|
||||
elif msg_type == 9: # KMarkdown message
|
||||
# Note: content is already stripped of mention patterns above
|
||||
if content:
|
||||
components.append(platform_message.Plain(text=content))
|
||||
elif msg_type == 10: # Card message
|
||||
# Card messages are complex, for now just indicate it's a card
|
||||
components.append(platform_message.Plain(text='[Card Message]'))
|
||||
else:
|
||||
# Other message types, just use content as plain text
|
||||
if content:
|
||||
components.append(platform_message.Plain(text=content))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""Convert between LangBot events and KOOK events"""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent):
|
||||
"""Convert LangBot event to KOOK event (not implemented)"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageEvent:
|
||||
"""
|
||||
Convert KOOK event to LangBot MessageEvent
|
||||
|
||||
Args:
|
||||
kook_event: KOOK event data dict containing channel_type, type, etc.
|
||||
bot_account_id: Bot's account ID for handling role mentions
|
||||
|
||||
Returns:
|
||||
FriendMessage or GroupMessage depending on channel_type
|
||||
"""
|
||||
channel_type = kook_event.get('channel_type')
|
||||
author_id = kook_event.get('author_id')
|
||||
target_id = kook_event.get('target_id')
|
||||
msg_timestamp = kook_event.get('msg_timestamp', int(time.time() * 1000))
|
||||
extra = kook_event.get('extra', {})
|
||||
|
||||
# Convert message to MessageChain
|
||||
message_chain = await KookMessageConverter.target2yiri(kook_event, bot_account_id)
|
||||
|
||||
# Convert timestamp from milliseconds to seconds
|
||||
event_time = msg_timestamp / 1000.0
|
||||
|
||||
if channel_type == 'PERSON':
|
||||
# Direct/Private message
|
||||
author = extra.get('author', {})
|
||||
author_name = author.get('nickname', author.get('username', str(author_id)))
|
||||
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=str(author_id),
|
||||
nickname=author_name,
|
||||
remark=str(author_id),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event_time,
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
elif channel_type == 'GROUP':
|
||||
# Guild/Server channel message
|
||||
author = extra.get('author', {})
|
||||
author_name = author.get('nickname', author.get('username', str(author_id)))
|
||||
|
||||
# guild_id = extra.get('guild_id', '')
|
||||
channel_name = extra.get('channel_name', str(target_id))
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=str(author_id),
|
||||
member_name=author_name,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=str(target_id), # Channel ID
|
||||
name=channel_name,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event_time,
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
else:
|
||||
# Fallback to FriendMessage for unknown channel types
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=str(author_id),
|
||||
nickname=str(author_id),
|
||||
remark=str(author_id),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event_time,
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
|
||||
|
||||
class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""KOOK platform adapter for LangBot"""
|
||||
|
||||
config: dict
|
||||
message_converter: KookMessageConverter = KookMessageConverter()
|
||||
event_converter: KookEventConverter = KookEventConverter()
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
# WebSocket connection
|
||||
ws: typing.Optional[websockets.WebSocketClientProtocol] = pydantic.Field(exclude=True, default=None)
|
||||
ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
|
||||
heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
|
||||
running: bool = pydantic.Field(exclude=True, default=False)
|
||||
|
||||
# Connection state
|
||||
session_id: str = pydantic.Field(exclude=True, default='')
|
||||
current_sn: int = pydantic.Field(exclude=True, default=0)
|
||||
gateway_url: str = pydantic.Field(exclude=True, default='')
|
||||
|
||||
# HTTP session
|
||||
http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
# Debug: Track init
|
||||
with open('/tmp/kook_adapter_init.txt', 'w') as f:
|
||||
f.write(f'KOOK adapter __init__ called at {time.time()}\n')
|
||||
|
||||
# Validate required config
|
||||
if 'token' not in config:
|
||||
raise Exception('KOOK adapter requires "token" in config')
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot_account_id='', # Will be set after connection
|
||||
listeners={},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _get_gateway_url(self) -> str:
|
||||
"""Get WebSocket gateway URL from KOOK API"""
|
||||
base_url = 'https://www.kookapp.cn/api/v3/gateway/index'
|
||||
|
||||
# Always use compression for better performance
|
||||
params = {'compress': 1}
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(base_url, params=params, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
if data.get('code') == 0:
|
||||
gateway_url = data['data']['url']
|
||||
return gateway_url
|
||||
else:
|
||||
raise Exception(f'Failed to get gateway URL: {data.get("message")}')
|
||||
else:
|
||||
raise Exception(f'Failed to get gateway URL: HTTP {response.status}')
|
||||
|
||||
async def _get_bot_user_info(self) -> dict:
|
||||
"""Get bot's own user information from KOOK API"""
|
||||
base_url = 'https://www.kookapp.cn/api/v3/user/me'
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(base_url, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
if data.get('code') == 0:
|
||||
user_info = data['data']
|
||||
return user_info
|
||||
else:
|
||||
raise Exception(f'Failed to get bot user info: {data.get("message")}')
|
||||
else:
|
||||
raise Exception(f'Failed to get bot user info: HTTP {response.status}')
|
||||
|
||||
async def _handle_hello(self, data: dict):
|
||||
"""Handle HELLO signal (signal 1)"""
|
||||
session_id = data.get('session_id', '')
|
||||
self.session_id = session_id
|
||||
await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {session_id}')
|
||||
|
||||
async def _handle_event(self, data: dict, sn: int):
|
||||
"""Handle EVENT signal (signal 0)"""
|
||||
self.current_sn = max(self.current_sn, sn)
|
||||
|
||||
# Check if this is a message event
|
||||
event_type = data.get('type')
|
||||
channel_type = data.get('channel_type')
|
||||
author_id = data.get('author_id')
|
||||
|
||||
# Ignore messages from bot itself to prevent infinite loops
|
||||
if self.bot_account_id and str(author_id) == self.bot_account_id:
|
||||
return
|
||||
|
||||
# Only process text messages (type 1, 2, 4, 8, 9, 10) in GROUP or PERSON channels
|
||||
if event_type in [1, 2, 4, 8, 9, 10] and channel_type in ['GROUP', 'PERSON']:
|
||||
try:
|
||||
# Convert to LangBot event
|
||||
lb_event = await self.event_converter.target2yiri(data, self.bot_account_id)
|
||||
|
||||
# Call registered listener
|
||||
event_class = type(lb_event)
|
||||
if event_class in self.listeners:
|
||||
await self.listeners[event_class](lb_event, self)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error handling KOOK event: {e}\n{traceback.format_exc()}')
|
||||
|
||||
async def _handle_pong(self, data: dict):
|
||||
"""Handle PONG signal (signal 3)"""
|
||||
# PONG received, connection is healthy
|
||||
pass
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
"""Send PING every 30 seconds"""
|
||||
try:
|
||||
while self.running and self.ws:
|
||||
await asyncio.sleep(30)
|
||||
|
||||
if self.ws:
|
||||
try:
|
||||
ping_msg = {
|
||||
's': 2, # PING signal
|
||||
'sn': self.current_sn,
|
||||
}
|
||||
await self.ws.send(json.dumps(ping_msg))
|
||||
except Exception:
|
||||
# Connection closed or send failed, exit loop
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Heartbeat error: {e}')
|
||||
|
||||
async def _websocket_loop(self):
|
||||
"""Main WebSocket event loop"""
|
||||
retry_count = 0
|
||||
max_retries = 3
|
||||
|
||||
while self.running and retry_count < max_retries:
|
||||
try:
|
||||
# Get gateway URL if not already retrieved
|
||||
if not self.gateway_url:
|
||||
self.gateway_url = await self._get_gateway_url()
|
||||
|
||||
# Connect to WebSocket
|
||||
async with websockets.connect(self.gateway_url) as ws:
|
||||
await self.logger.info(f'Connected to KOOK WebSocket: {self.gateway_url}')
|
||||
self.ws = ws
|
||||
|
||||
# Start heartbeat
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
# Wait for HELLO within 6 seconds
|
||||
try:
|
||||
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
|
||||
|
||||
# Handle compressed messages (same as main message loop)
|
||||
if isinstance(hello_msg, bytes):
|
||||
# Decompress if compressed
|
||||
try:
|
||||
hello_msg = zlib.decompress(hello_msg).decode('utf-8')
|
||||
except Exception:
|
||||
# Not compressed or decompression failed
|
||||
hello_msg = hello_msg.decode('utf-8')
|
||||
|
||||
hello_data = json.loads(hello_msg)
|
||||
|
||||
if hello_data.get('s') == 1: # HELLO signal
|
||||
await self._handle_hello(hello_data['d'])
|
||||
else:
|
||||
raise Exception(f'Expected HELLO signal, got signal {hello_data.get("s")}')
|
||||
except asyncio.TimeoutError:
|
||||
raise Exception('Did not receive HELLO within 6 seconds')
|
||||
|
||||
# Reset retry count on successful connection
|
||||
retry_count = 0
|
||||
|
||||
# Main message loop
|
||||
async for message in ws:
|
||||
if isinstance(message, bytes):
|
||||
# Decompress if compressed
|
||||
try:
|
||||
message = zlib.decompress(message).decode('utf-8')
|
||||
except Exception:
|
||||
# Not compressed or decompression failed
|
||||
message = message.decode('utf-8')
|
||||
|
||||
try:
|
||||
msg_data = json.loads(message)
|
||||
signal = msg_data.get('s')
|
||||
|
||||
if signal == 0: # EVENT
|
||||
data = msg_data.get('d', {})
|
||||
sn = msg_data.get('sn', 0)
|
||||
await self._handle_event(data, sn)
|
||||
elif signal == 3: # PONG
|
||||
await self._handle_pong(msg_data.get('d', {}))
|
||||
elif signal == 5: # RECONNECT
|
||||
# await self.logger.info('Received RECONNECT signal')
|
||||
break # Break to reconnect
|
||||
elif signal == 6: # RESUME ACK
|
||||
# await self.logger.info('Resume successful')
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
await self.logger.error(f'Failed to parse message: {message}')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error processing message: {e}\n{traceback.format_exc()}')
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
await self.logger.warning('KOOK WebSocket connection closed, reconnecting...')
|
||||
retry_count += 1
|
||||
await asyncio.sleep(2**retry_count) # Exponential backoff
|
||||
except Exception as e:
|
||||
await self.logger.error(f'KOOK WebSocket error: {e}\n{traceback.format_exc()}')
|
||||
retry_count += 1
|
||||
await asyncio.sleep(2**retry_count)
|
||||
finally:
|
||||
# Stop heartbeat
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
if retry_count >= max_retries:
|
||||
await self.logger.error(f'Failed to connect after {max_retries} retries')
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
"""Send a message to a channel or user"""
|
||||
content, msg_type = await self.message_converter.yiri2target(message)
|
||||
|
||||
# Determine endpoint based on target_type
|
||||
if target_type == 'GROUP':
|
||||
# Send to channel
|
||||
url = 'https://www.kookapp.cn/api/v3/message/create'
|
||||
payload = {
|
||||
'target_id': target_id,
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
else: # PERSON or default
|
||||
# Send direct message
|
||||
url = 'https://www.kookapp.cn/api/v3/direct-message/create'
|
||||
payload = {
|
||||
'target_id': target_id,
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
try:
|
||||
if not self.http_session:
|
||||
self.http_session = aiohttp.ClientSession()
|
||||
|
||||
async with self.http_session.post(url, json=payload, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
if result.get('code') == 0:
|
||||
await self.logger.debug(f'Message sent successfully to {target_id}')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send message: {result.get("message")}')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send message: HTTP {response.status}')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error sending message: {e}')
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
"""Reply to a message"""
|
||||
content, msg_type = await self.message_converter.yiri2target(message)
|
||||
|
||||
kook_event = message_source.source_platform_object
|
||||
channel_type = kook_event.get('channel_type')
|
||||
target_id = kook_event.get('target_id')
|
||||
msg_id = kook_event.get('msg_id')
|
||||
|
||||
# Determine endpoint based on channel_type
|
||||
if channel_type == 'GROUP':
|
||||
url = 'https://www.kookapp.cn/api/v3/message/create'
|
||||
payload = {
|
||||
'target_id': target_id,
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
else: # PERSON
|
||||
url = 'https://www.kookapp.cn/api/v3/direct-message/create'
|
||||
# For direct messages, we need the chat_code or target_id
|
||||
author_id = kook_event.get('author_id')
|
||||
extra = kook_event.get('extra', {})
|
||||
chat_code = extra.get('code', '')
|
||||
|
||||
payload = {
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
|
||||
if chat_code:
|
||||
payload['chat_code'] = chat_code
|
||||
else:
|
||||
payload['target_id'] = str(author_id)
|
||||
|
||||
# Add quote if requested
|
||||
if quote_origin and msg_id:
|
||||
payload['quote'] = msg_id
|
||||
|
||||
payload['reply_msg_id'] = msg_id
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
try:
|
||||
if not self.http_session:
|
||||
self.http_session = aiohttp.ClientSession()
|
||||
|
||||
async with self.http_session.post(url, json=payload, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
if result.get('code') == 0:
|
||||
await self.logger.debug('Reply sent successfully')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send reply: {result.get("message")}')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send reply: HTTP {response.status}')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error sending reply: {e}')
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
"""Check if bot is muted in a group (not implemented for KOOK)"""
|
||||
return False
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
"""Register an event listener"""
|
||||
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
|
||||
],
|
||||
):
|
||||
"""Unregister an event listener"""
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
"""Start the KOOK adapter"""
|
||||
# Debug: Track run_async
|
||||
with open('/tmp/kook_adapter_run.txt', 'w') as f:
|
||||
f.write(f'KOOK adapter run_async called at {time.time()}\n')
|
||||
|
||||
self.running = True
|
||||
|
||||
try:
|
||||
# Create HTTP session
|
||||
self.http_session = aiohttp.ClientSession()
|
||||
|
||||
await self.logger.info('Starting KOOK adapter')
|
||||
|
||||
# Get bot's user information and set bot_account_id
|
||||
try:
|
||||
bot_info = await self._get_bot_user_info()
|
||||
self.bot_account_id = str(bot_info.get('id', ''))
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to get bot user info: {e}')
|
||||
# Continue anyway, but bot will process its own messages
|
||||
|
||||
# Start WebSocket connection
|
||||
self.ws_task = asyncio.create_task(self._websocket_loop())
|
||||
|
||||
# Keep running
|
||||
await self.ws_task
|
||||
except Exception as e:
|
||||
await self.logger.error(f'KOOK adapter error: {e}\n{traceback.format_exc()}')
|
||||
finally:
|
||||
self.running = False
|
||||
|
||||
async def kill(self) -> bool:
|
||||
"""Stop the KOOK adapter"""
|
||||
self.running = False
|
||||
|
||||
# Cancel tasks
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self.ws_task:
|
||||
self.ws_task.cancel()
|
||||
try:
|
||||
await self.ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Close WebSocket
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass # Already closed or error during close
|
||||
|
||||
# Close HTTP session
|
||||
if self.http_session:
|
||||
await self.http_session.close()
|
||||
|
||||
await self.logger.info('KOOK adapter stopped')
|
||||
return True
|
||||
24
src/langbot/pkg/platform/sources/kook.yaml
Normal file
24
src/langbot/pkg/platform/sources/kook.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: kook
|
||||
label:
|
||||
en_US: KOOK
|
||||
zh_Hans: KOOK
|
||||
description:
|
||||
en_US: KOOK Adapter (formerly KaiHeiLa)
|
||||
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
||||
icon: kook.png
|
||||
spec:
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
en_US: Bot Token
|
||||
zh_Hans: 机器人令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./kook.py
|
||||
attr: KookAdapter
|
||||
@@ -9,9 +9,13 @@ import re
|
||||
import base64
|
||||
import uuid
|
||||
import json
|
||||
import time
|
||||
import datetime
|
||||
import hashlib
|
||||
from Crypto.Cipher import AES
|
||||
import tempfile
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
import aiohttp
|
||||
import lark_oapi.ws.exception
|
||||
@@ -19,6 +23,8 @@ import quart
|
||||
from lark_oapi.api.im.v1 import *
|
||||
import pydantic
|
||||
from lark_oapi.api.cardkit.v1 import *
|
||||
from lark_oapi.api.auth.v3 import *
|
||||
from lark_oapi.core.model import *
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
@@ -55,9 +61,7 @@ class AESCipher(object):
|
||||
|
||||
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def upload_image_to_lark(
|
||||
msg: platform_message.Image, api_client: lark_oapi.Client
|
||||
) -> typing.Optional[str]:
|
||||
async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> typing.Optional[str]:
|
||||
"""Upload an image to Lark and return the image_key, or None if upload fails."""
|
||||
image_bytes = None
|
||||
|
||||
@@ -95,7 +99,9 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
return None
|
||||
|
||||
if image_bytes is None:
|
||||
print(f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})')
|
||||
print(
|
||||
f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})'
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -113,10 +119,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
request = (
|
||||
CreateImageRequest.builder()
|
||||
.request_body(
|
||||
CreateImageRequestBody.builder()
|
||||
.image_type('message')
|
||||
.image(open(temp_file_path, 'rb'))
|
||||
.build()
|
||||
CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
@@ -143,7 +146,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
message_chain: platform_message.MessageChain, api_client: lark_oapi.Client
|
||||
) -> typing.Tuple[list, list]:
|
||||
"""Convert message chain to Lark format.
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (text_elements, image_keys):
|
||||
- text_elements: List of paragraphs for post message format
|
||||
@@ -159,24 +162,24 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
async def process_text_with_images(text: str) -> typing.Tuple[str, list]:
|
||||
"""Extract Markdown images from text and return cleaned text + image URLs."""
|
||||
extracted_urls = []
|
||||
|
||||
|
||||
# Find all Markdown images
|
||||
matches = list(markdown_image_pattern.finditer(text))
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
|
||||
# Extract URLs and remove image syntax from text
|
||||
cleaned_text = text
|
||||
for match in reversed(matches): # Reverse to maintain correct positions
|
||||
url = match.group(2)
|
||||
extracted_urls.insert(0, url) # Insert at beginning since we're going in reverse
|
||||
# Replace image syntax with empty string or a placeholder
|
||||
cleaned_text = cleaned_text[:match.start()] + cleaned_text[match.end():]
|
||||
|
||||
cleaned_text = cleaned_text[: match.start()] + cleaned_text[match.end() :]
|
||||
|
||||
# Clean up multiple consecutive newlines that might result from removing images
|
||||
cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text)
|
||||
cleaned_text = cleaned_text.strip()
|
||||
|
||||
|
||||
return cleaned_text, extracted_urls
|
||||
|
||||
for msg in message_chain:
|
||||
@@ -189,14 +192,14 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
text = msg.text.encode('latin1').decode('utf-8')
|
||||
except UnicodeError:
|
||||
text = msg.text.encode('utf-8', errors='replace').decode('utf-8')
|
||||
|
||||
|
||||
# Check for and extract Markdown images from text
|
||||
cleaned_text, extracted_urls = await process_text_with_images(text)
|
||||
|
||||
|
||||
# Add cleaned text if not empty
|
||||
if cleaned_text:
|
||||
pending_paragraph.append({'tag': 'md', 'text': cleaned_text})
|
||||
|
||||
|
||||
# Process extracted image URLs
|
||||
for url in extracted_urls:
|
||||
# Create a temporary Image message to upload
|
||||
@@ -204,7 +207,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
image_key = await LarkMessageConverter.upload_image_to_lark(temp_image, api_client)
|
||||
if image_key:
|
||||
image_keys.append(image_key)
|
||||
|
||||
|
||||
elif isinstance(msg, platform_message.At):
|
||||
pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []})
|
||||
elif isinstance(msg, platform_message.AtAll):
|
||||
@@ -300,6 +303,18 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
message_content['content'] = new_list
|
||||
elif message.message_type == 'image':
|
||||
message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}]
|
||||
elif message.message_type == 'file':
|
||||
message_content['content'] = [
|
||||
{'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']}
|
||||
]
|
||||
elif message.message_type == 'audio':
|
||||
message_content['content'] = [
|
||||
{
|
||||
'tag': 'audio',
|
||||
'file_key': message_content['file_key'],
|
||||
'duration': message_content.get('duration', 0),
|
||||
}
|
||||
]
|
||||
|
||||
for ele in message_content['content']:
|
||||
if ele['tag'] == 'text':
|
||||
@@ -330,6 +345,112 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
image_format = response.raw.headers['content-type']
|
||||
|
||||
lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
|
||||
elif ele['tag'] == 'audio':
|
||||
file_key = ele['file_key']
|
||||
duration = ele['duration']
|
||||
|
||||
# Download audio file
|
||||
request: GetMessageResourceRequest = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(message.message_id)
|
||||
.file_key(file_key)
|
||||
.type('file')
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
|
||||
|
||||
if not response.success():
|
||||
print(f'Failed to download audio: code: {response.code}, msg: {response.msg}')
|
||||
lb_msg_list.append(platform_message.Plain(text='[Audio file download failed]'))
|
||||
return platform_message.MessageChain(lb_msg_list)
|
||||
|
||||
# Read audio bytes
|
||||
audio_bytes = response.file.read()
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode()
|
||||
|
||||
# Get content type from response headers
|
||||
content_type = response.raw.headers.get('content-type', 'audio/mpeg')
|
||||
|
||||
mime_main = content_type.split(';')[0].strip()
|
||||
ext = mimetypes.guess_extension(mime_main) or '.bin'
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_file_path = os.path.join(temp_dir, f'lark_audio_{file_key}{ext}')
|
||||
|
||||
with open(temp_file_path, 'wb') as f:
|
||||
f.write(audio_bytes)
|
||||
|
||||
# Create Voice message: prefer path/url + length, include base64 as optional data URI
|
||||
lb_msg_list.append(
|
||||
platform_message.Voice(
|
||||
voice_id=file_key,
|
||||
url=f'file://{temp_file_path}',
|
||||
path=temp_file_path,
|
||||
base64=f'data:{content_type};base64,{audio_base64}',
|
||||
length=(duration // 1000) if duration else None,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'Error downloading audio: {e}')
|
||||
traceback.print_exc()
|
||||
lb_msg_list.append(platform_message.Plain(text='[Audio file download error]'))
|
||||
|
||||
elif ele['tag'] == 'file':
|
||||
file_key = ele['file_key']
|
||||
file_name = ele['file_name']
|
||||
|
||||
request: GetMessageResourceRequest = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(message.message_id)
|
||||
.file_key(file_key)
|
||||
.type('file')
|
||||
.build()
|
||||
)
|
||||
|
||||
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
|
||||
file_bytes = response.file.read()
|
||||
file_base64 = base64.b64encode(file_bytes).decode()
|
||||
|
||||
file_format = response.raw.headers['content-type']
|
||||
|
||||
file_size = len(file_bytes)
|
||||
|
||||
# Determine extension from content-type if possible
|
||||
content_type = response.raw.headers.get('content-type', '')
|
||||
mime_main = content_type.split(';')[0].strip() if content_type else ''
|
||||
ext = mimetypes.guess_extension(mime_main) or ''
|
||||
|
||||
# Ensure a safe filename (avoid path components)
|
||||
safe_name = os.path.basename(file_name).replace('/', '_').replace('\\', '_')
|
||||
if ext and not safe_name.lower().endswith(ext.lower()):
|
||||
filename_with_ext = f'{safe_name}{ext}'
|
||||
else:
|
||||
filename_with_ext = safe_name
|
||||
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_file_path = os.path.join(temp_dir, f'lark_{file_key}_{filename_with_ext}')
|
||||
|
||||
with open(temp_file_path, 'wb') as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
# Create File message with local path and file:// URL
|
||||
lb_msg_list.append(
|
||||
platform_message.File(
|
||||
id=file_key,
|
||||
name=file_name,
|
||||
size=file_size,
|
||||
url=f'file://{temp_file_path}',
|
||||
path=temp_file_path,
|
||||
base64=f'data:{file_format};base64,{file_base64}', # not including base64 by default to save memory; can be added if needed
|
||||
)
|
||||
)
|
||||
|
||||
return platform_message.MessageChain(lb_msg_list)
|
||||
|
||||
@@ -356,6 +477,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.event.message.create_time,
|
||||
source_platform_object=event,
|
||||
)
|
||||
elif event.event.message.chat_type == 'group':
|
||||
return platform_events.GroupMessage(
|
||||
@@ -369,12 +491,10 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.event.message.create_time,
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
|
||||
@@ -391,6 +511,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
message_converter: LarkMessageConverter = LarkMessageConverter()
|
||||
event_converter: LarkEventConverter = LarkEventConverter()
|
||||
cipher: AESCipher
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
@@ -402,51 +523,15 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
||||
|
||||
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
||||
bot_uuid: str = None # 机器人UUID
|
||||
app_ticket: str = None # 商店应用用到
|
||||
app_access_token: str = None # 商店应用用到
|
||||
app_access_token_expire_at: int = None
|
||||
tenant_access_tokens: dict[str, dict[str, str]] = {} # 租户access_token映射
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
quart_app = quart.Quart(__name__)
|
||||
|
||||
@quart_app.route('/lark/callback', methods=['POST'])
|
||||
async def lark_callback():
|
||||
try:
|
||||
data = await quart.request.json
|
||||
|
||||
if 'encrypt' in data:
|
||||
cipher = AESCipher(config['encrypt-key'])
|
||||
data = cipher.decrypt_string(data['encrypt'])
|
||||
data = json.loads(data)
|
||||
|
||||
type = data.get('type')
|
||||
if type is None:
|
||||
context = EventContext(data)
|
||||
type = context.header.event_type
|
||||
|
||||
if 'url_verification' == type:
|
||||
# todo 验证verification token
|
||||
return {'challenge': data.get('challenge')}
|
||||
context = EventContext(data)
|
||||
type = context.header.event_type
|
||||
p2v1 = P2ImMessageReceiveV1()
|
||||
p2v1.header = context.header
|
||||
event = P2ImMessageReceiveV1Data()
|
||||
event.message = EventMessage(context.event['message'])
|
||||
event.sender = EventSender(context.event['sender'])
|
||||
p2v1.event = event
|
||||
p2v1.schema = context.schema
|
||||
if 'im.message.receive_v1' == type:
|
||||
try:
|
||||
event = await self.event_converter.target2yiri(p2v1, self.api_client)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
|
||||
return {'code': 200, 'message': 'ok'}
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
return {'code': 500, 'message': 'error'}
|
||||
|
||||
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||
lb_event = await self.event_converter.target2yiri(event, self.api_client)
|
||||
|
||||
@@ -462,7 +547,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot_account_id = config['bot_name']
|
||||
|
||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
|
||||
api_client = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret']).build()
|
||||
api_client = self.build_api_client(config)
|
||||
cipher = AESCipher(config.get('encrypt-key', ''))
|
||||
self.request_app_ticket(api_client, config)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
@@ -475,9 +562,105 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot=bot,
|
||||
api_client=api_client,
|
||||
bot_account_id=bot_account_id,
|
||||
cipher=cipher,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def request_app_ticket(self, api_client, config):
|
||||
app_id = config['app_id']
|
||||
app_secret = config['app_secret']
|
||||
print(f'Requesting app ticket for app_id: {app_id[:3]}***{app_id[-3:]}')
|
||||
if 'isv' == config.get('app_type', 'self'):
|
||||
request: ResendAppTicketRequest = (
|
||||
ResendAppTicketRequest.builder()
|
||||
.request_body(ResendAppTicketRequestBody.builder().app_id(app_id).app_secret(app_secret).build())
|
||||
.build()
|
||||
)
|
||||
response: ResendAppTicketResponse = api_client.auth.v3.app_ticket.resend(request)
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.auth.v3.auth.app_ticket_resend failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
|
||||
def request_app_access_token(self):
|
||||
app_id = self.config['app_id']
|
||||
app_secret = self.config['app_secret']
|
||||
if 'isv' == self.config.get('app_type', 'self'):
|
||||
request: CreateAppAccessTokenRequest = (
|
||||
CreateAppAccessTokenRequest.builder()
|
||||
.request_body(
|
||||
CreateAppAccessTokenRequestBody.builder()
|
||||
.app_id(app_id)
|
||||
.app_secret(app_secret)
|
||||
.app_ticket(self.app_ticket)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: CreateAppAccessTokenResponse = self.api_client.auth.v3.app_access_token.create(request)
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.auth.v3.auth.app_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
content = json.loads(response.raw.content)
|
||||
self.app_access_token = content['app_access_token']
|
||||
self.app_access_token_expire_at = int(time.time()) + content['expire'] - 300
|
||||
|
||||
def get_app_access_token(self):
|
||||
if 'isv' != self.config.get('app_type', 'self'):
|
||||
return None
|
||||
if (
|
||||
self.app_access_token is None
|
||||
or self.app_access_token_expire_at is None
|
||||
or int(time.time()) >= self.app_access_token_expire_at
|
||||
):
|
||||
self.request_app_access_token()
|
||||
return self.app_access_token
|
||||
|
||||
def request_tenant_access_token(self, tenant_key: str):
|
||||
app_access_token = self.get_app_access_token()
|
||||
if 'isv' == self.config.get('app_type', 'self'):
|
||||
request: CreateTenantAccessTokenRequest = (
|
||||
CreateTenantAccessTokenRequest.builder()
|
||||
.request_body(
|
||||
CreateTenantAccessTokenRequestBody.builder()
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_key(tenant_key)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: CreateTenantAccessTokenResponse = self.api_client.auth.v3.tenant_access_token.create(request)
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.auth.v3.auth.tenant_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
content = json.loads(response.raw.content)
|
||||
tenant_access_token = content['tenant_access_token']
|
||||
expire = content['expire']
|
||||
self.tenant_access_tokens[tenant_key] = {
|
||||
'token': tenant_access_token,
|
||||
'expire_at': int(time.time()) + expire - 300,
|
||||
}
|
||||
|
||||
def get_tenant_access_token(self, tenant_key: str):
|
||||
if tenant_key is None or 'isv' != self.config.get('app_type', 'self'):
|
||||
return None
|
||||
tenant_access_token = self.tenant_access_tokens.get(tenant_key)
|
||||
if tenant_access_token is None or int(time.time()) >= tenant_access_token['expire_at']:
|
||||
self.request_tenant_access_token(tenant_key)
|
||||
return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None
|
||||
|
||||
def build_api_client(self, config):
|
||||
app_id = config['app_id']
|
||||
app_secret = config['app_secret']
|
||||
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build()
|
||||
if 'isv' == config.get('app_type', 'self'):
|
||||
api_client = (
|
||||
lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build()
|
||||
)
|
||||
return api_client
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
|
||||
@@ -705,9 +888,19 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
tenant_key = event.source_platform_object.header.tenant_key if event.source_platform_object else None
|
||||
app_access_token = self.get_app_access_token()
|
||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||
req_opt: RequestOption = (
|
||||
RequestOption.builder()
|
||||
.app_ticket(self.app_ticket)
|
||||
.tenant_key(tenant_key)
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_access_token(tenant_access_token)
|
||||
.build()
|
||||
)
|
||||
# 发起请求
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
|
||||
|
||||
# 处理失败返回
|
||||
if not response.success():
|
||||
@@ -734,7 +927,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'content': text_elements,
|
||||
},
|
||||
}
|
||||
|
||||
request: ReplyMessageRequest = (
|
||||
ReplyMessageRequest.builder()
|
||||
.message_id(message_source.message_chain.message_id)
|
||||
@@ -749,7 +941,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
.build()
|
||||
)
|
||||
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
tenant_key = (
|
||||
message_source.source_platform_object.header.tenant_key
|
||||
if message_source.source_platform_object
|
||||
else None
|
||||
)
|
||||
app_access_token = self.get_app_access_token()
|
||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||
req_opt: RequestOption = (
|
||||
RequestOption.builder()
|
||||
.app_ticket(self.app_ticket)
|
||||
.tenant_key(tenant_key)
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_access_token(tenant_access_token)
|
||||
.build()
|
||||
)
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
@@ -774,7 +981,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
.build()
|
||||
)
|
||||
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
tenant_key = (
|
||||
message_source.source_platform_object.header.tenant_key
|
||||
if message_source.source_platform_object
|
||||
else None
|
||||
)
|
||||
app_access_token = self.get_app_access_token()
|
||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||
req_opt: RequestOption = (
|
||||
RequestOption.builder()
|
||||
.app_ticket(self.app_ticket)
|
||||
.tenant_key(tenant_key)
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_access_token(tenant_access_token)
|
||||
.build()
|
||||
)
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
@@ -828,8 +1050,24 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
# self.seq = 1 # 消息回复结束之后重置seq
|
||||
self.card_id_dict.pop(message_id) # 清理已经使用过的卡片
|
||||
|
||||
tenant_key = (
|
||||
message_source.source_platform_object.header.tenant_key
|
||||
if message_source.source_platform_object
|
||||
else None
|
||||
)
|
||||
app_access_token = self.get_app_access_token()
|
||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||
req_opt: RequestOption = (
|
||||
RequestOption.builder()
|
||||
.app_ticket(self.app_ticket)
|
||||
.tenant_key(tenant_key)
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_access_token(tenant_access_token)
|
||||
.build()
|
||||
)
|
||||
# 发起请求
|
||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request)
|
||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request, req_opt)
|
||||
|
||||
# 处理失败返回
|
||||
if not response.success():
|
||||
@@ -859,8 +1097,110 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
):
|
||||
self.listeners.pop(event_type)
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
def get_event_type(self, data):
|
||||
schema = '1.0'
|
||||
if 'schema' in data:
|
||||
schema = data['schema']
|
||||
if '2.0' == schema:
|
||||
return data['header']['event_type']
|
||||
elif 'event' in data:
|
||||
return data['event']['type']
|
||||
else:
|
||||
return data['type']
|
||||
|
||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
||||
"""处理统一 webhook 请求。
|
||||
Args:
|
||||
bot_uuid: Bot 的 UUID
|
||||
path: 子路径(如果有的话)
|
||||
request: Quart Request 对象
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
try:
|
||||
data = await request.json
|
||||
|
||||
if 'encrypt' in data:
|
||||
data = self.cipher.decrypt_string(data['encrypt'])
|
||||
data = json.loads(data)
|
||||
type = self.get_event_type(data)
|
||||
context = EventContext(data)
|
||||
if 'url_verification' == type:
|
||||
# todo 验证verification token
|
||||
return {'challenge': data.get('challenge')}
|
||||
elif 'app_ticket' == type:
|
||||
self.app_ticket = context.event['app_ticket']
|
||||
elif 'im.message.receive_v1' == type:
|
||||
try:
|
||||
p2v1 = P2ImMessageReceiveV1()
|
||||
p2v1.header = context.header
|
||||
event = P2ImMessageReceiveV1Data()
|
||||
event.message = EventMessage(context.event['message'])
|
||||
event.sender = EventSender(context.event['sender'])
|
||||
p2v1.event = event
|
||||
p2v1.schema = context.schema
|
||||
event = await self.event_converter.target2yiri(p2v1, self.api_client)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
elif 'im.chat.member.bot.added_v1' == type:
|
||||
try:
|
||||
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
||||
if bot_added_welcome_msg:
|
||||
final_content = {
|
||||
'zh_Hans': {
|
||||
'title': '',
|
||||
'content': [[{'tag': 'md', 'text': bot_added_welcome_msg}]],
|
||||
},
|
||||
}
|
||||
chat_id = context.event['chat_id']
|
||||
request: CreateMessageRequest = (
|
||||
CreateMessageRequest.builder()
|
||||
.receive_id_type('chat_id')
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(chat_id)
|
||||
.content(json.dumps(final_content))
|
||||
.msg_type('post')
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
tenant_key = context.header.tenant_key if context.header else None
|
||||
app_access_token = self.get_app_access_token()
|
||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||
req_opt: RequestOption = (
|
||||
RequestOption.builder()
|
||||
.app_ticket(self.app_ticket)
|
||||
.tenant_key(tenant_key)
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_access_token(tenant_access_token)
|
||||
.build()
|
||||
)
|
||||
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'im.chat.member.bot.added_v1: {e}')
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
|
||||
return {'code': 200, 'message': 'ok'}
|
||||
except Exception as e:
|
||||
print(f'Error in lark callback: {e}')
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
return {'code': 500, 'message': 'error'}
|
||||
|
||||
async def run_async(self):
|
||||
port = self.config['port']
|
||||
enable_webhook = self.config['enable-webhook']
|
||||
|
||||
if not enable_webhook:
|
||||
@@ -875,16 +1215,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
else:
|
||||
raise e
|
||||
else:
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
async def shutdown_trigger_placeholder():
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.quart_app.run_task(
|
||||
host='0.0.0.0',
|
||||
port=port,
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
# 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接
|
||||
|
||||
@@ -45,16 +45,6 @@ spec:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: port
|
||||
label:
|
||||
en_US: Webhook Port
|
||||
zh_Hans: Webhook端口
|
||||
description:
|
||||
en_US: Only valid when webhook mode is enabled, please fill in the webhook port
|
||||
zh_Hans: 仅在启用 Webhook 模式时有效,请填写 Webhook 端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2285
|
||||
- name: encrypt-key
|
||||
label:
|
||||
en_US: Encrypt Key
|
||||
@@ -75,6 +65,35 @@ spec:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: app_type
|
||||
label:
|
||||
en_US: App Type
|
||||
zh_Hans: 应用类型
|
||||
description:
|
||||
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
||||
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||
type: select
|
||||
options:
|
||||
- name: self
|
||||
label:
|
||||
en_US: Self-built Application
|
||||
zh_Hans: 自建应用
|
||||
- name: isv
|
||||
label:
|
||||
en_US: Store Application
|
||||
zh_Hans: 商店应用
|
||||
required: false
|
||||
default: self
|
||||
- name: bot_added_welcome
|
||||
label:
|
||||
en_US: Bot Welcome Message
|
||||
zh_Hans: 机器人进群欢迎语
|
||||
description:
|
||||
en_US: Welcome message when the bot is added to a group, supports Markdown format
|
||||
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
|
||||
type: text
|
||||
required: false
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./lark.py
|
||||
|
||||
@@ -437,9 +437,6 @@ class GewechatEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event['Data']['CreateTime'],
|
||||
|
||||
@@ -153,9 +153,6 @@ class NakuruProjectEventConverter(abstract_platform_adapter.AbstractEventConvert
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title=event.sender.title,
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=yiri_chain,
|
||||
time=event.time,
|
||||
|
||||
@@ -279,11 +279,6 @@ class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=int(
|
||||
datetime.datetime.strptime(event.member.joined_at, '%Y-%m-%dT%H:%M:%S%z').timestamp()
|
||||
),
|
||||
last_speak_timestamp=datetime.datetime.now().timestamp(),
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
|
||||
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
|
||||
@@ -312,9 +307,6 @@ class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=int(0),
|
||||
last_speak_timestamp=datetime.datetime.now().timestamp(),
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
|
||||
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
|
||||
|
||||
@@ -108,9 +108,6 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.timestamp,
|
||||
@@ -262,19 +259,6 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
|
||||
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
|
||||
|
||||
await self.logger.info('LINE Webhook 回调地址:')
|
||||
await self.logger.info(f' 本地地址: {webhook_url}')
|
||||
await self.logger.info(f' 公网地址: {webhook_url_public}')
|
||||
await self.logger.info('请在 LINE 后台配置此回调地址')
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'无法生成 webhook URL: {e}')
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -76,6 +76,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
||||
AppID=config['AppID'],
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
api_base_url=config.get('api_base_url', 'https://api.weixin.qq.com'),
|
||||
)
|
||||
elif config['Mode'] == 'passive':
|
||||
bot = OAClientForLongerResponse(
|
||||
@@ -86,6 +87,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
||||
LoadingMessage=config.get('LoadingMessage', ''),
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
api_base_url=config.get('api_base_url', 'https://api.weixin.qq.com'),
|
||||
)
|
||||
else:
|
||||
raise KeyError('请设置微信公众号通信模式')
|
||||
@@ -155,20 +157,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
|
||||
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
|
||||
|
||||
await self.logger.info('微信公众号 Webhook 回调地址:')
|
||||
await self.logger.info(f' 本地地址: {webhook_url}')
|
||||
await self.logger.info(f' 公网地址: {webhook_url_public}')
|
||||
await self.logger.info('请在微信公众号后台配置此回调地址')
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'无法生成 webhook URL: {e}')
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -53,6 +53,16 @@ spec:
|
||||
type: string
|
||||
required: true
|
||||
default: "AI正在思考中,请发送任意内容获取回复。"
|
||||
- name: api_base_url
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
description:
|
||||
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
|
||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项
|
||||
type: string
|
||||
required: false
|
||||
default: "https://api.weixin.qq.com"
|
||||
execution:
|
||||
python:
|
||||
path: ./officialaccount.py
|
||||
|
||||
@@ -94,9 +94,6 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())
|
||||
return platform_events.GroupMessage(
|
||||
@@ -117,9 +114,6 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())
|
||||
return platform_events.GroupMessage(
|
||||
@@ -247,20 +241,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
|
||||
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
|
||||
|
||||
await self.logger.info('QQ 官方机器人 Webhook 回调地址:')
|
||||
await self.logger.info(f' 本地地址: {webhook_url}')
|
||||
await self.logger.info(f' 公网地址: {webhook_url_public}')
|
||||
await self.logger.info('请在 QQ 官方机器人后台配置此回调地址')
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'无法生成 webhook URL: {e}')
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -76,9 +76,6 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
id=event.channel_id, name='MEMBER', permission=platform_entities.Permission.Member
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = int(datetime.datetime.utcnow().timestamp())
|
||||
return platform_events.GroupMessage(
|
||||
@@ -112,10 +109,7 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项,请查看文档或联系管理员')
|
||||
|
||||
bot = SlackClient(
|
||||
bot_token=config['bot_token'],
|
||||
signing_secret=config['signing_secret'],
|
||||
logger=logger,
|
||||
unified_mode=True
|
||||
bot_token=config['bot_token'], signing_secret=config['signing_secret'], logger=logger, unified_mode=True
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
@@ -194,24 +188,10 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
async def run_async(self):
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
|
||||
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
|
||||
|
||||
await self.logger.info(f"Slack 机器人 Webhook 回调地址:")
|
||||
await self.logger.info(f" 本地地址: {webhook_url}")
|
||||
await self.logger.info(f" 公网地址: {webhook_url_public}")
|
||||
await self.logger.info(f"请在 Slack 后台配置此回调地址")
|
||||
except Exception as e:
|
||||
await self.logger.warning(f"无法生成 webhook URL: {e}")
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
|
||||
@@ -120,9 +120,6 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=lb_message,
|
||||
time=event.message.date.timestamp(),
|
||||
@@ -200,6 +197,10 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
}
|
||||
if self.config['markdown_card'] is True:
|
||||
args['parse_mode'] = 'MarkdownV2'
|
||||
|
||||
if message_source.source_platform_object.message.message_thread_id:
|
||||
args['message_thread_id'] = message_source.source_platform_object.message.message_thread_id
|
||||
|
||||
if quote_origin:
|
||||
args['reply_to_message_id'] = message_source.source_platform_object.message.id
|
||||
|
||||
@@ -219,8 +220,6 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||
args = {}
|
||||
message_id = message_source.source_platform_object.message.id
|
||||
if quote_origin:
|
||||
args['reply_to_message_id'] = message_source.source_platform_object.message.id
|
||||
|
||||
component = components[0]
|
||||
if message_id not in self.msg_stream_id: # 当消息回复第一次时,发送新消息
|
||||
@@ -236,6 +235,12 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'chat_id': message_source.source_platform_object.effective_chat.id,
|
||||
'text': content,
|
||||
}
|
||||
if message_source.source_platform_object.message.message_thread_id:
|
||||
args['message_thread_id'] = message_source.source_platform_object.message.message_thread_id
|
||||
|
||||
if quote_origin:
|
||||
args['reply_to_message_id'] = message_source.source_platform_object.message.id
|
||||
|
||||
if self.config['markdown_card'] is True:
|
||||
args['parse_mode'] = 'MarkdownV2'
|
||||
|
||||
@@ -263,6 +268,24 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# self.seq = 1 # 消息回复结束之后重置seq
|
||||
self.msg_stream_id.pop(message_id) # 消息回复结束之后删除流式消息id
|
||||
|
||||
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
||||
if not isinstance(event.source_platform_object, Update):
|
||||
return None
|
||||
|
||||
message = event.source_platform_object.message
|
||||
if not message:
|
||||
return None
|
||||
|
||||
# specifically handle telegram forum topic and private thread(not supported by official client yet but supported by bot api)
|
||||
if message.message_thread_id:
|
||||
# check if it is a group
|
||||
if isinstance(event, platform_events.GroupMessage):
|
||||
return f'{event.group.id}#{message.message_thread_id}'
|
||||
elif isinstance(event, platform_events.FriendMessage):
|
||||
return f'{event.sender.id}#{message.message_thread_id}'
|
||||
|
||||
return None
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
is_stream = False
|
||||
if self.config.get('enable-stream-reply', None):
|
||||
|
||||
@@ -65,6 +65,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
outbound_message_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
|
||||
"""后端主动推送消息的队列"""
|
||||
|
||||
# 流式输出开关
|
||||
stream_enabled: bool = pydantic.Field(default=True, exclude=True)
|
||||
"""是否启用流式输出"""
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
super().__init__(
|
||||
config=config,
|
||||
@@ -77,6 +81,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
self.bot_account_id = 'websocketbot'
|
||||
self.outbound_message_queue = asyncio.Queue()
|
||||
self.stream_enabled = True
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -97,8 +102,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
# 推送到所有相关连接
|
||||
await self.outbound_message_queue.put(message_data)
|
||||
|
||||
await self.logger.info(f'Send message to {target_id}: {message}')
|
||||
|
||||
return message_data
|
||||
|
||||
async def reply_message(
|
||||
@@ -214,8 +217,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
return message_data.model_dump()
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
"""WebSocket始终支持流式输出"""
|
||||
return True
|
||||
"""根据stream_enabled标志返回是否支持流式输出"""
|
||||
return self.stream_enabled
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
@@ -242,7 +245,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
async def run_async(self):
|
||||
"""运行适配器"""
|
||||
await self.logger.info('WebSocket适配器已启动')
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -258,12 +260,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
except asyncio.CancelledError:
|
||||
await self.logger.info('WebSocket适配器已停止')
|
||||
raise
|
||||
|
||||
async def kill(self):
|
||||
"""停止适配器"""
|
||||
await self.logger.info('WebSocket适配器正在停止')
|
||||
pass
|
||||
|
||||
async def _process_image_components(self, message_chain_obj: list):
|
||||
"""
|
||||
@@ -318,11 +319,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
Args:
|
||||
connection: WebSocket连接对象
|
||||
message_data: 消息数据
|
||||
message_data: 消息数据,包含:
|
||||
- message: 消息链
|
||||
- stream: 是否启用流式输出 (可选,默认True)
|
||||
"""
|
||||
pipeline_uuid = connection.pipeline_uuid
|
||||
session_type = connection.session_type
|
||||
|
||||
# 获取stream参数,默认为True
|
||||
self.stream_enabled = message_data.get('stream', True)
|
||||
|
||||
# 选择会话
|
||||
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
||||
|
||||
|
||||
@@ -501,9 +501,6 @@ class WeChatPadEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event['create_time'],
|
||||
|
||||
@@ -15,6 +15,58 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
|
||||
|
||||
def split_string_by_bytes(text, limit=2048, encoding='utf-8'):
|
||||
"""
|
||||
Splits a string into a list of strings, where each part is at most 'limit' bytes.
|
||||
|
||||
Args:
|
||||
text (str): The original string to split.
|
||||
limit (int): The maximum byte size for each split part.
|
||||
encoding (str): The encoding to use (default is 'utf-8').
|
||||
|
||||
Returns:
|
||||
list: A list of split strings.
|
||||
"""
|
||||
# 1. Encode the entire string into bytes
|
||||
bytes_data = text.encode(encoding)
|
||||
total_len = len(bytes_data)
|
||||
|
||||
parts = []
|
||||
start = 0
|
||||
|
||||
while start < total_len:
|
||||
# 2. Determine the end index for the current chunk
|
||||
# It shouldn't exceed the total length
|
||||
end = min(start + limit, total_len)
|
||||
|
||||
# 3. Slice the byte array
|
||||
chunk = bytes_data[start:end]
|
||||
|
||||
# 4. Attempt to decode the chunk
|
||||
# Use errors='ignore' to drop any partial bytes at the end of the chunk
|
||||
# (e.g., if a 3-byte character was cut after the 2nd byte)
|
||||
part_str = chunk.decode(encoding, errors='ignore')
|
||||
|
||||
# 5. Calculate the actual byte length of the successfully decoded string
|
||||
# This tells us exactly where the valid character boundary ended
|
||||
part_bytes = part_str.encode(encoding)
|
||||
part_len = len(part_bytes)
|
||||
|
||||
# Safety check: Prevent infinite loop if limit is too small (e.g., limit=1 for a Chinese char)
|
||||
if part_len == 0 and end < total_len:
|
||||
# Force advance by 1 byte to consume the un-decodable byte or raise error
|
||||
# Here we just treat it as a part to avoid stuck loops, though it might be invalid
|
||||
start += 1
|
||||
continue
|
||||
|
||||
parts.append(part_str)
|
||||
|
||||
# 6. Move the start pointer by the actual length consumed
|
||||
start += part_len
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomClient):
|
||||
@@ -22,11 +74,15 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
|
||||
for msg in message_chain:
|
||||
if type(msg) is platform_message.Plain:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'content': msg.text,
|
||||
}
|
||||
chunks = split_string_by_bytes(msg.text)
|
||||
content_list.extend(
|
||||
[
|
||||
{
|
||||
'type': 'text',
|
||||
'content': chunk,
|
||||
}
|
||||
for chunk in chunks
|
||||
]
|
||||
)
|
||||
elif type(msg) is platform_message.Image:
|
||||
content_list.append(
|
||||
@@ -35,6 +91,20 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
'media_id': await bot.get_media_id(msg),
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Voice:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'voice',
|
||||
'media_id': await bot.get_media_id(msg),
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.File:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'file',
|
||||
'media_id': await bot.get_media_id(msg),
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Forward:
|
||||
for node in msg.node_list:
|
||||
content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))
|
||||
@@ -156,6 +226,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
contacts_secret=config['contacts_secret'],
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
@@ -185,6 +256,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
|
||||
elif content['type'] == 'image':
|
||||
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
elif content['type'] == 'voice':
|
||||
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
elif content['type'] == 'file':
|
||||
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
@@ -197,6 +272,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
||||
if content['type'] == 'image':
|
||||
await self.bot.send_image(user_id, agent_id, content['media'])
|
||||
if content['type'] == 'voice':
|
||||
await self.bot.send_voice(user_id, agent_id, content['media'])
|
||||
if content['type'] == 'file':
|
||||
await self.bot.send_file(user_id, agent_id, content['media'])
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
@@ -232,19 +311,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
async def run_async(self):
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
|
||||
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
|
||||
|
||||
await self.logger.info('企业微信 Webhook 回调地址:')
|
||||
await self.logger.info(f' 本地地址: {webhook_url}')
|
||||
await self.logger.info(f' 公网地址: {webhook_url_public}')
|
||||
await self.logger.info('请在企业微信后台配置此回调地址')
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'无法生成 webhook URL: {e}')
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -21,21 +21,21 @@ spec:
|
||||
- name: secret
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
zh_Hans: 密钥 (Secret)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hans: 令牌 (Token)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -46,6 +46,16 @@ spec:
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: api_base_url
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
description:
|
||||
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项
|
||||
type: string
|
||||
required: false
|
||||
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||
execution:
|
||||
python:
|
||||
path: ./wecom.py
|
||||
|
||||
@@ -28,9 +28,105 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
if event.type == 'group':
|
||||
yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))
|
||||
yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now()))
|
||||
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||||
if event.picurl != '':
|
||||
yiri_msg_list.append(platform_message.Image(base64=event.picurl))
|
||||
|
||||
if event.content:
|
||||
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||||
|
||||
images = []
|
||||
if event.images:
|
||||
images.extend([img for img in event.images if img])
|
||||
if not images and event.picurl:
|
||||
images.append(event.picurl)
|
||||
for image_base64 in images:
|
||||
if image_base64:
|
||||
yiri_msg_list.append(platform_message.Image(base64=image_base64))
|
||||
|
||||
file_info = event.file or {}
|
||||
if file_info:
|
||||
file_url = (
|
||||
file_info.get('download_url')
|
||||
or file_info.get('url')
|
||||
or file_info.get('fileurl')
|
||||
or file_info.get('path')
|
||||
)
|
||||
file_base64 = file_info.get('base64')
|
||||
file_name = file_info.get('filename') or file_info.get('name')
|
||||
file_size = file_info.get('filesize') or file_info.get('size')
|
||||
file_data = file_url or file_base64
|
||||
if file_data or file_name:
|
||||
file_kwargs = {}
|
||||
if file_data:
|
||||
file_kwargs['url'] = file_data
|
||||
if file_name:
|
||||
file_kwargs['name'] = file_name
|
||||
if file_size is not None:
|
||||
file_kwargs['size'] = file_size
|
||||
try:
|
||||
yiri_msg_list.append(platform_message.File(**file_kwargs))
|
||||
except Exception:
|
||||
# 兜底
|
||||
yiri_msg_list.append(platform_message.Unknown(text='[file message unsupported]'))
|
||||
|
||||
voice_info = event.voice or {}
|
||||
if voice_info:
|
||||
voice_payload = voice_info.get('base64') or voice_info.get('url')
|
||||
if voice_payload:
|
||||
if voice_info.get('base64') and not voice_payload.startswith('data:'):
|
||||
voice_payload = f'data:audio/mpeg;base64,{voice_info.get("base64")}'
|
||||
try:
|
||||
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
|
||||
except Exception:
|
||||
try:
|
||||
voice_kwargs = {'url': voice_payload}
|
||||
voice_name = voice_info.get('filename') or voice_info.get('name')
|
||||
voice_size = voice_info.get('filesize') or voice_info.get('size')
|
||||
if voice_name:
|
||||
voice_kwargs['name'] = voice_name
|
||||
if voice_size is not None:
|
||||
voice_kwargs['size'] = voice_size
|
||||
yiri_msg_list.append(platform_message.File(**voice_kwargs))
|
||||
except Exception:
|
||||
yiri_msg_list.append(platform_message.Unknown(text='[voice message unsupported]'))
|
||||
|
||||
video_info = event.video or {}
|
||||
if video_info:
|
||||
video_payload = (
|
||||
video_info.get('base64')
|
||||
or video_info.get('url')
|
||||
or video_info.get('download_url')
|
||||
or video_info.get('fileurl')
|
||||
)
|
||||
if video_payload:
|
||||
video_kwargs = {'url': video_payload}
|
||||
video_name = video_info.get('filename') or video_info.get('name')
|
||||
video_size = video_info.get('filesize') or video_info.get('size')
|
||||
if video_name:
|
||||
video_kwargs['name'] = video_name
|
||||
if video_size is not None:
|
||||
video_kwargs['size'] = video_size
|
||||
try:
|
||||
# 没有专门的视频类型,沿用 File 传递给上层
|
||||
yiri_msg_list.append(platform_message.File(**video_kwargs))
|
||||
except Exception:
|
||||
yiri_msg_list.append(platform_message.Unknown(text='[video message unsupported]'))
|
||||
|
||||
if event.msgtype == 'link' and event.link:
|
||||
link = event.link
|
||||
summary = '\n'.join(
|
||||
filter(
|
||||
None,
|
||||
[link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')],
|
||||
)
|
||||
)
|
||||
if summary:
|
||||
yiri_msg_list.append(platform_message.Plain(text=summary))
|
||||
|
||||
has_content_element = any(
|
||||
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
||||
)
|
||||
if not has_content_element:
|
||||
fallback_type = event.msgtype or 'unknown'
|
||||
yiri_msg_list.append(platform_message.Unknown(text=f'[unsupported wecom msgtype: {fallback_type}]'))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
@@ -67,9 +163,6 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = datetime.datetime.now().timestamp()
|
||||
return platform_events.GroupMessage(
|
||||
@@ -211,20 +304,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
|
||||
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
|
||||
|
||||
await self.logger.info('企业微信机器人 Webhook 回调地址:')
|
||||
await self.logger.info(f' 本地地址: {webhook_url}')
|
||||
await self.logger.info(f' 公网地址: {webhook_url_public}')
|
||||
await self.logger.info('请在企业微信后台配置此回调地址')
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'无法生成 webhook URL: {e}')
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user