mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-07 14:26:03 +00:00
Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e223edeb45 | ||
|
|
d2c3146334 | ||
|
|
7d9c8e3065 | ||
|
|
f12ed81e1e | ||
|
|
6d4d19b6d7 | ||
|
|
07b90f12a2 | ||
|
|
fd896c6974 | ||
|
|
1fbfa868fb | ||
|
|
ad05819c2e | ||
|
|
0c6f71738c | ||
|
|
af451e7006 | ||
|
|
59f20bcc73 | ||
|
|
7eca3cdfca | ||
|
|
c40354f838 | ||
|
|
21a5b4658a | ||
|
|
073acaa053 | ||
|
|
38759b229d | ||
|
|
efe32e34ae | ||
|
|
46db4de11a | ||
|
|
170a6756f4 | ||
|
|
7330732f62 | ||
|
|
b08e5ca09a | ||
|
|
dff80a0c0a | ||
|
|
f54ae4b91c | ||
|
|
e5b3cced1f | ||
|
|
101e04db6d | ||
|
|
b79edda3a7 | ||
|
|
a20d3d11e5 | ||
|
|
3b4c455813 | ||
|
|
c967a2aa82 | ||
|
|
79cc6da96f | ||
|
|
fee7d48dc3 | ||
|
|
8811fb647f | ||
|
|
37b017459d | ||
|
|
4889a3881b | ||
|
|
fe4f95b9a3 | ||
|
|
a2817f6524 | ||
|
|
b9560b26ff | ||
|
|
1ad7071aa0 | ||
|
|
96b041846d | ||
|
|
4054ba2a76 | ||
|
|
c7cb42bd79 | ||
|
|
894709d577 | ||
|
|
6823069103 | ||
|
|
699545a196 | ||
|
|
f0061817ea | ||
|
|
688202e7d1 | ||
|
|
d46b762d03 | ||
|
|
0963fd5443 | ||
|
|
6471770737 | ||
|
|
314b7d15bb | ||
|
|
c758908745 | ||
|
|
767137aaa0 | ||
|
|
acb2ce6a40 | ||
|
|
67784708d6 | ||
|
|
1bd9c334aa | ||
|
|
17bbc8bf10 | ||
|
|
4a4c0921a4 | ||
|
|
e425cf079a | ||
|
|
245e798b79 | ||
|
|
27fdccce16 | ||
|
|
484643c0ee | ||
|
|
ec61459619 | ||
|
|
66ef744447 | ||
|
|
10d3a9cc92 | ||
|
|
885320e9ae | ||
|
|
ed02ac4710 | ||
|
|
e4841edbaf | ||
|
|
ef7a06b0db | ||
|
|
6fe20c1812 | ||
|
|
9e8c8f79df | ||
|
|
01d06898fb | ||
|
|
0a669c7016 | ||
|
|
b251fc4b89 | ||
|
|
075c85e2bc | ||
|
|
62b63ca2ca | ||
|
|
3680a80248 | ||
|
|
6713b57d01 | ||
|
|
ea13ef87f2 | ||
|
|
59bd581e88 | ||
|
|
cba83a62e8 | ||
|
|
f412127fb0 | ||
|
|
5273bbb23f | ||
|
|
0ceab3f6a5 | ||
|
|
aedc097188 | ||
|
|
18b27dd9ef | ||
|
|
3f50a56623 | ||
|
|
1fcdbd472f | ||
|
|
547006cb4a | ||
|
|
92bf9a7ea5 | ||
|
|
832efb4069 | ||
|
|
8f1847d480 | ||
|
|
fe619e415f | ||
|
|
0154ea6cd3 | ||
|
|
8db55267d8 | ||
|
|
b9662250a6 | ||
|
|
d9378c3a88 | ||
|
|
86a4d1bf0b | ||
|
|
ce6e79db8e | ||
|
|
d53e2cb9a0 | ||
|
|
c1168745b7 | ||
|
|
69b87a0d8a | ||
|
|
6637b153f1 | ||
|
|
e768fc6116 | ||
|
|
2442d3bf52 | ||
|
|
42d78817f4 | ||
|
|
4b9f25a05d | ||
|
|
d1f0e07cc0 | ||
|
|
78e55509ae | ||
|
|
2c28635a39 | ||
|
|
5f3cecfbe2 | ||
|
|
12df9d6ee9 | ||
|
|
195f6efeff | ||
|
|
564d829e25 | ||
|
|
58c1916712 | ||
|
|
a8fba46040 | ||
|
|
3115d6f6dd | ||
|
|
323481d69b | ||
|
|
5a5c4295b1 | ||
|
|
88111d87ac | ||
|
|
4e5a6ee79a | ||
|
|
05c684d757 | ||
|
|
2838020580 | ||
|
|
9b34ae2db4 | ||
|
|
f8010a20eb | ||
|
|
917edb3413 | ||
|
|
10425ede34 | ||
|
|
e4b40a8fa0 | ||
|
|
0b8ab4b54b | ||
|
|
49239e0e08 | ||
|
|
aec2a30445 | ||
|
|
c8915ca964 | ||
|
|
a715eddd06 | ||
|
|
2f9c235b41 | ||
|
|
cc4d8838eb | ||
|
|
fa0a77f09f | ||
|
|
fd6a7b73d4 | ||
|
|
bf0848d60b | ||
|
|
e06fac2bb7 | ||
|
|
bec61427a0 | ||
|
|
5fae7b2eb0 | ||
|
|
2eebdfe16a | ||
|
|
9cd3544d59 | ||
|
|
de4d14fee3 | ||
|
|
f29c568381 | ||
|
|
af3f557055 | ||
|
|
b894842736 | ||
|
|
e190029e1f | ||
|
|
e4940a8050 | ||
|
|
617c95ebc4 | ||
|
|
1cdd428bcc | ||
|
|
71ac719aee | ||
|
|
4621e6cc9f | ||
|
|
66087f83e1 | ||
|
|
25f9330491 | ||
|
|
14b1e0d33b | ||
|
|
83ccb33fd3 | ||
|
|
05bcf543ba | ||
|
|
7cd063bb5d | ||
|
|
8f1317b39e | ||
|
|
77a0de5ef0 | ||
|
|
875227a2fe | ||
|
|
2317392ee5 | ||
|
|
c7efa4dd7f | ||
|
|
e701daa8e0 | ||
|
|
1ae99199b2 | ||
|
|
7c067a1cb3 | ||
|
|
478bc62576 | ||
|
|
a740eb8ee9 | ||
|
|
f8aedd02b3 | ||
|
|
ea638cab80 | ||
|
|
7129dd536e | ||
|
|
1b1cc7769b | ||
|
|
44b8354dfd | ||
|
|
55ec9d11ae | ||
|
|
5b3d3801b5 | ||
|
|
9f1ea75d09 | ||
|
|
6e37aae636 | ||
|
|
921d12f596 | ||
|
|
6bf6deaefd | ||
|
|
1201949f2c | ||
|
|
1c419e3591 | ||
|
|
b0a9be77b0 | ||
|
|
e02ade5a30 | ||
|
|
1a51ba8e7e | ||
|
|
e7b22d6ebf | ||
|
|
dddfa8ac79 | ||
|
|
99e2976826 | ||
|
|
71e44f0e54 | ||
|
|
4c904c2375 | ||
|
|
498d030da9 | ||
|
|
c111bf1714 | ||
|
|
6570f276d2 | ||
|
|
42e1e038bd | ||
|
|
d0e54a45c7 | ||
|
|
23fa47b07e | ||
|
|
4902c1d3b2 | ||
|
|
a6f96e5209 | ||
|
|
37c41bcfe4 | ||
|
|
9e223949a7 | ||
|
|
267bd72c63 | ||
|
|
af0d00e5e9 | ||
|
|
244e16c491 | ||
|
|
cad259fe39 | ||
|
|
bc3199bf29 | ||
|
|
127dc455c3 | ||
|
|
e8dc6fde53 | ||
|
|
4a97895dea | ||
|
|
3c0495fc51 | ||
|
|
dfd25deb68 |
11
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
11
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
name: 漏洞反馈
|
name: 漏洞反馈
|
||||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://docs.langbot.app/zh/workshop/network-details.html
|
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
@@ -10,6 +10,15 @@ body:
|
|||||||
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: 部署版本
|
||||||
|
description: 请选择您使用的 LangBot 部署版本。
|
||||||
|
options:
|
||||||
|
- 社区版
|
||||||
|
- 云服务
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 异常情况
|
label: 异常情况
|
||||||
|
|||||||
11
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
11
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://docs.langbot.app/en/workshop/network-details.html
|
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
@@ -10,6 +10,15 @@ body:
|
|||||||
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Deployment version
|
||||||
|
description: Please select the LangBot deployment version you are using.
|
||||||
|
options:
|
||||||
|
- Community Edition
|
||||||
|
- Cloud Service
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Exception
|
label: Exception
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd /tmp/langbot_build_web/web
|
cd /tmp/langbot_build_web/web
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npx vite build
|
||||||
- name: Package Output
|
- name: Package Output
|
||||||
run: |
|
run: |
|
||||||
cp -r /tmp/langbot_build_web/web/out ./web
|
cp -r /tmp/langbot_build_web/web/dist ./web
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
25
.github/workflows/check-i18n.yml
vendored
Normal file
25
.github/workflows/check-i18n.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Check i18n Keys
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-i18n:
|
||||||
|
name: Check i18n Key Consistency
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Check i18n keys against en-US reference
|
||||||
|
run: node web/scripts/check-i18n.mjs
|
||||||
4
.github/workflows/publish-to-pypi.yml
vendored
4
.github/workflows/publish-to-pypi.yml
vendored
@@ -29,8 +29,8 @@ jobs:
|
|||||||
npm install -g pnpm
|
npm install -g pnpm
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
mkdir -p ../src/langbot/web/out
|
mkdir -p ../src/langbot/web/dist
|
||||||
cp -r out ../src/langbot/web/
|
cp -r dist ../src/langbot/web/
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|||||||
115
.github/workflows/run-tests.yml
vendored
115
.github/workflows/run-tests.yml
vendored
@@ -4,25 +4,25 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, ready_for_review, synchronize]
|
types: [opened, ready_for_review, synchronize]
|
||||||
paths:
|
paths:
|
||||||
- 'pkg/**'
|
- 'src/langbot/**'
|
||||||
- 'tests/**'
|
- 'tests/**'
|
||||||
- '.github/workflows/run-tests.yml'
|
- '.github/workflows/run-tests.yml'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
- 'run_tests.sh'
|
- 'run_tests.sh'
|
||||||
|
- 'scripts/test-*.sh'
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
paths:
|
- 'feat/**'
|
||||||
- 'pkg/**'
|
# No path filter on push: every push to the branches above runs the
|
||||||
- 'tests/**'
|
# full unit-test suite. feat/** branches in particular must be tested
|
||||||
- '.github/workflows/run-tests.yml'
|
# on every push (they accumulate large changes before a PR exists).
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'run_tests.sh'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -39,28 +39,13 @@ jobs:
|
|||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: |
|
uses: astral-sh/setup-uv@v4
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: uv sync --dev
|
||||||
uv sync --dev
|
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit + smoke tests
|
||||||
run: |
|
run: uv run pytest tests/unit_tests/ tests/smoke/ -q --tb=short
|
||||||
bash run_tests.sh
|
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
|
||||||
if: matrix.python-version == '3.12'
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
files: ./coverage.xml
|
|
||||||
flags: unit-tests
|
|
||||||
name: unit-tests-coverage
|
|
||||||
fail_ci_if_error: false
|
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|
||||||
- name: Test Summary
|
- name: Test Summary
|
||||||
if: always()
|
if: always()
|
||||||
@@ -69,3 +54,79 @@ jobs:
|
|||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Python Version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY
|
echo "Python Version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
integration:
|
||||||
|
name: Fast Integration Tests
|
||||||
|
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 fast integration tests
|
||||||
|
run: uv run pytest tests/integration/ -m "not slow" -q --tb=short
|
||||||
|
|
||||||
|
- name: Integration Test Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Integration Tests Results" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
name: Coverage Gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test, integration]
|
||||||
|
|
||||||
|
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 coverage (unit + smoke)
|
||||||
|
run: |
|
||||||
|
uv run pytest tests/unit_tests/ tests/smoke/ \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=xml \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-fail-under=18 \
|
||||||
|
-q --tb=short
|
||||||
|
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
files: ./coverage.xml
|
||||||
|
flags: unit-tests
|
||||||
|
name: coverage-report
|
||||||
|
fail_ci_if_error: false
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
- name: Coverage Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Coverage Results" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Threshold: 18%" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
78
.github/workflows/test-migrations.yml
vendored
Normal file
78
.github/workflows/test-migrations.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: Test Migrations
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- 'src/langbot/pkg/persistence/**'
|
||||||
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
|
- 'tests/integration/persistence/**'
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
paths:
|
||||||
|
- 'src/langbot/pkg/persistence/**'
|
||||||
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
|
- 'tests/integration/persistence/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-migrations-sqlite:
|
||||||
|
name: Migrations (SQLite)
|
||||||
|
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 SQLite migration tests
|
||||||
|
run: uv run pytest tests/integration/persistence/test_migrations.py -q --tb=short
|
||||||
|
|
||||||
|
test-migrations-postgres:
|
||||||
|
name: Migrations (PostgreSQL)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: langbot
|
||||||
|
POSTGRES_PASSWORD: langbot
|
||||||
|
POSTGRES_DB: langbot_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd="pg_isready -U langbot"
|
||||||
|
--health-interval=5s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=5
|
||||||
|
|
||||||
|
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 PostgreSQL migration tests
|
||||||
|
env:
|
||||||
|
TEST_POSTGRES_URL: postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test
|
||||||
|
run: uv run pytest tests/integration/persistence/test_migrations_postgres.py -q --tb=short
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -47,8 +47,12 @@ plugins.bak
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
src/langbot/web/
|
src/langbot/web/
|
||||||
|
testsdk/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
|
# Next.js build cache (legacy)
|
||||||
|
web/.next/
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ repos:
|
|||||||
# Run the formatter of backend.
|
# Run the formatter of backend.
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: v3.1.0
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
types_or: [javascript, jsx, ts, tsx, css, scss]
|
|
||||||
additional_dependencies:
|
|
||||||
- prettier@3.1.0
|
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
- id: prettier
|
||||||
|
name: prettier
|
||||||
|
entry: npx --prefix web prettier --write --ignore-unknown
|
||||||
|
language: system
|
||||||
|
types_or: [javascript, jsx, ts, tsx, css, scss]
|
||||||
|
|
||||||
- id: lint-staged
|
- id: lint-staged
|
||||||
name: lint-staged
|
name: lint-staged
|
||||||
entry: cd web && pnpm lint-staged
|
entry: cd web && pnpm lint-staged
|
||||||
|
|||||||
143
AGENTS.md
143
AGENTS.md
@@ -1,81 +1,134 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file is for guiding code agents (like Claude Code, GitHub Copilot, OpenAI Codex, etc.) to work in LangBot project.
|
This file guides code agents (Claude Code, GitHub Copilot, OpenAI Codex, etc.) working in the LangBot project. `CLAUDE.md` is a symlink to this file.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
LangBot is a open-source LLM native instant messaging bot development platform, aiming to provide an out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, supporting global instant messaging platforms, and providing rich API interfaces, supporting custom development.
|
LangBot is an open-source, LLM-native instant-messaging bot development platform. It aims to provide an out-of-the-box IM bot development experience with Agent, RAG, MCP and other LLM application capabilities, supporting mainstream global IM platforms and exposing rich APIs for custom development.
|
||||||
|
|
||||||
LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts:
|
LangBot has a comprehensive web frontend — almost every operation can be performed through it.
|
||||||
|
|
||||||
- `./src/langbot`: The main python package of the project, below are the main modules in this package:
|
- **Python**: `>=3.11,<4.0`, dependencies managed by `uv`. Package version is in `pyproject.toml`.
|
||||||
- `./pkg`: The core python package of the project backend.
|
- **Frontend**: `web/` is a **Vite + React Router 7 + shadcn/ui + Tailwind CSS** SPA, managed by `pnpm`. (Note: this is NOT Next.js — the `dev` script is `vite`.)
|
||||||
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
|
- **Backend framework**: Quart (the async flavour of Flask). The HTTP API and the pre-built web UI are both served by the backend on `http://127.0.0.1:5300`.
|
||||||
- `./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
|
## Repository Layout
|
||||||
|
|
||||||
We use `uv` to manage dependencies.
|
```
|
||||||
|
LangBot/
|
||||||
|
├── main.py # Entrypoint shim -> langbot.__main__.main()
|
||||||
|
├── pyproject.toml # Python project + deps (uv), pins langbot-plugin==<x.y.z>
|
||||||
|
├── src/langbot/
|
||||||
|
│ ├── __main__.py # Real entrypoint, CLI args (--standalone-runtime, --standalone-box, --debug)
|
||||||
|
│ ├── pkg/ # Core backend package
|
||||||
|
│ │ ├── api/ # HTTP API controllers + services (Quart)
|
||||||
|
│ │ ├── core/ # App bootstrap, stages, task manager
|
||||||
|
│ │ ├── platform/ # IM platform adapters, bot managers, session managers
|
||||||
|
│ │ ├── provider/ # LLM providers, requesters, tool providers
|
||||||
|
│ │ ├── pipeline/ # Pipelines, stages, query pool
|
||||||
|
│ │ ├── plugin/ # Bridge connecting LangBot to the plugin runtime (see below)
|
||||||
|
│ │ ├── box/ # Code-sandbox subsystem (Docker / nsjail / E2B backends)
|
||||||
|
│ │ ├── skill/ # Skill subsystem
|
||||||
|
│ │ ├── rag/ , vector/ # RAG + vector store
|
||||||
|
│ │ ├── command/ # Built-in commands
|
||||||
|
│ │ ├── persistence/ # ORM models + Alembic migrations (SQLite & PostgreSQL)
|
||||||
|
│ │ ├── storage/ # Object/file storage abstractions
|
||||||
|
│ │ ├── config/, entity/, discover/, utils/, telemetry/, survey/
|
||||||
|
│ ├── libs/ # Vendored SDKs (qq_official_api, wecom_api, etc.)
|
||||||
|
│ └── templates/ # Config/component templates (e.g. templates/config.yaml)
|
||||||
|
├── web/ # Frontend SPA (Vite + React Router 7 + shadcn + Tailwind)
|
||||||
|
└── docker/ # docker-compose deployment files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Environment Setup
|
||||||
|
|
||||||
|
Full guide lives in the wiki: **["开发配置" / Dev Config](https://docs.langbot.app/zh/develop/dev-config)**. Summary:
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install uv
|
pip install uv
|
||||||
uv sync --dev
|
uv sync --dev # uv creates a .venv/ for you; point your editor's interpreter at it
|
||||||
|
uv run main.py # serves API + web UI on http://127.0.0.1:5300
|
||||||
```
|
```
|
||||||
|
|
||||||
Start the backend and run the project in development mode.
|
On first run the config file is generated at `data/config.yaml`. DB is SQLite by default (zero setup); PostgreSQL is supported. Migrations run automatically on startup.
|
||||||
|
|
||||||
```bash
|
### Frontend
|
||||||
uv run main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Then you can access the project at `http://127.0.0.1:5300`.
|
Requires Node.js + [pnpm](https://pnpm.io/installation).
|
||||||
|
|
||||||
## Frontend Development
|
|
||||||
|
|
||||||
We use `pnpm` to manage dependencies.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web
|
cd web
|
||||||
cp .env.example .env
|
cp .env.example .env # Windows: copy .env.example .env
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev # http://127.0.0.1:3000 (npm install / npm run dev also work)
|
||||||
```
|
```
|
||||||
|
|
||||||
Then you can access the project at `http://127.0.0.1:3000`.
|
`pnpm dev` reads `VITE_API_BASE_URL` from `web/.env` so the dev frontend can reach the backend on port `5300`. In production the frontend is pre-built into static files served by the backend on the same origin.
|
||||||
|
|
||||||
## Plugin System Architecture
|
### Code formatting
|
||||||
|
|
||||||
LangBot is composed of various internal components such as Large Language Model tools, commands, messaging platform adapters, LLM requesters, and more. To meet extensibility and flexibility requirements, we have implemented a production-grade plugin system.
|
The repo runs lint + format checks in CI. Install the pre-commit hooks so the same checks run locally before each commit:
|
||||||
|
|
||||||
Each plugin runs in an independent process, managed uniformly by the Plugin Runtime. It has two operating modes: `stdio` and `websocket`. When LangBot is started directly by users (not running in a container), it uses `stdio` mode, which is common for personal users or lightweight environments. When LangBot runs in a container, it uses `websocket` mode, designed specifically for production environments.
|
```bash
|
||||||
|
uv run pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
Plugin Runtime automatically starts each installed plugin and interacts through stdio. In plugin development scenarios, developers can use the lbp command-line tool to start plugins and connect to the running Runtime via WebSocket for debugging.
|
## Plugin System
|
||||||
|
|
||||||
> Plugin SDK, CLI, Runtime, and entities definitions shared between LangBot and plugins are contained in the [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk) repository.
|
LangBot's plugin system (Plugin SDK, CLI `lbp`, Plugin Runtime, and the shared entity/API definitions) lives in a **separate repository**: [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk). LangBot depends on it via the pinned `langbot-plugin` package in `pyproject.toml`.
|
||||||
|
|
||||||
## Some Development Tips and Standards
|
### Architecture (what to know inside this repo)
|
||||||
|
|
||||||
- LangBot is a global project, any comments in code should be in English, and user experience should be considered in all aspects.
|
- Plugins run as independent processes managed by the **Plugin Runtime**. The Runtime supports two control transports: `stdio` and `websocket`.
|
||||||
- Thus you should consider the i18n support in all aspects.
|
- When LangBot is started directly by a user (not in a container), it spawns and connects to the Runtime over **stdio** (lightweight/personal use).
|
||||||
- LangBot is widely adopted in both toC and toB scenarios, so you should consider the compatibility and security in all aspects.
|
- When LangBot runs in a container, it connects to a standalone Runtime over **WebSocket** (production).
|
||||||
- If you were asked to make a commit, please follow the commit message format:
|
- The bridge code lives in `src/langbot/pkg/plugin/` (`connector.py`, `handler.py`).
|
||||||
- format: <type>(<scope>): <subject>
|
- Relevant config (`data/config.yaml`): `plugin.runtime_ws_url` (e.g. `ws://langbot_plugin_runtime:5400/control/ws`). Start LangBot with `--standalone-runtime` to make it connect to an externally-launched Runtime over WebSocket instead of spawning one over stdio.
|
||||||
- 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.
|
### Debugging the Plugin Runtime / CLI / SDK
|
||||||
- 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.
|
This is documented in detail in the **SDK repo's `AGENTS.md`** and in the wiki page **["调试插件运行时、CLI、SDK" / Plugin Runtime](https://docs.langbot.app/zh/develop/plugin-runtime)**. The short version:
|
||||||
|
|
||||||
|
- Clone `LangBot` and `langbot-plugin-sdk` as siblings under one parent dir so the editor resolves shared entities.
|
||||||
|
- Start a standalone Runtime from the SDK repo: `uv run --no-sync lbp rt` (control port `5400`, debug port `5401`).
|
||||||
|
- To make LangBot use a locally-modified SDK: from the SDK dir, with LangBot's `.venv` active, run `uv pip install .`, then launch LangBot with `uv run --no-sync main.py --standalone-runtime` (keep `--no-sync` so your local SDK isn't overwritten).
|
||||||
|
|
||||||
|
### Debugging the Box (sandbox) runtime
|
||||||
|
|
||||||
|
The Box subsystem (`src/langbot/pkg/box/`) is the code sandbox. It picks the first available backend among **Docker / nsjail / E2B**. The standalone Box runtime is launched via the SDK CLI: `lbp box`. Backend selection details, the `lbp box` flags, and the SDK-side architecture are documented in the SDK repo's `AGENTS.md`.
|
||||||
|
|
||||||
|
Relevant config (`data/config.yaml`, `box:` section): `box.enabled` (master switch — disabling it also disables the native sandbox tools, skill add/edit, and stdio-mode MCP servers), `box.backend` (`'local'` = Docker/nsjail auto-pick, or `'docker'` / `'nsjail'` / `'e2b'`; also settable via `BOX__BACKEND`), and `box.runtime.endpoint` (external Box runtime base URL, e.g. `ws://127.0.0.1:5410`; empty = local auto-managed runtime). Like the plugin runtime, LangBot can connect to an externally-launched Box runtime by setting that endpoint and starting with `--standalone-box`.
|
||||||
|
|
||||||
|
> A common false "No supported sandbox backend (Docker / nsjail / E2B) is available" comes from Docker being installed and running but the current user not being in the `docker` group → `docker info` gets `permission denied` on the socket. Fix: `sudo usermod -aG docker <user>` and restart the backend in a shell that has the new group.
|
||||||
|
|
||||||
|
## Development Standards
|
||||||
|
|
||||||
|
- LangBot is a global project: **all code comments and docstrings must be in English**, and every user-facing string must support **i18n** (`en_US` + `zh_Hans` at minimum, plus `ja_JP` where the repo already has it).
|
||||||
|
- LangBot is adopted in both toC and toB scenarios — always consider compatibility and security.
|
||||||
|
- **Commit message format**: `<type>(<scope>): <subject>`
|
||||||
|
- `type`: one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, etc.
|
||||||
|
- `scope`: the affected package/module/file/class.
|
||||||
|
- `subject`: concise description of the change.
|
||||||
|
|
||||||
|
### Database migrations (Alembic)
|
||||||
|
|
||||||
|
LangBot uses [Alembic](https://alembic.sqlalchemy.org/) for migrations, supporting both SQLite and PostgreSQL from a single set of scripts. Migration files live in `src/langbot/pkg/persistence/alembic/versions/`.
|
||||||
|
|
||||||
|
If you change ORM model definitions, generate a migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run from the project root (requires data/config.yaml to exist)
|
||||||
|
uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"
|
||||||
|
```
|
||||||
|
|
||||||
|
Review and edit the generated script before committing. Migrations execute automatically on startup. `autogenerate` detects schema changes (add/drop columns, tables, type changes) but **data migrations** (e.g. mutating JSON field contents) must be hand-written into the generated script. `env.py` sets `render_as_batch=True`, so SQLite's ALTER TABLE limits are handled automatically — no need to branch per database. More in the wiki ["开发配置"](https://docs.langbot.app/zh/develop/dev-config#数据库迁移).
|
||||||
|
|
||||||
## Some Principles
|
## Some Principles
|
||||||
|
|
||||||
- Keep it simple, stupid.
|
- Keep it simple, stupid.
|
||||||
- Entities should not be multiplied unnecessarily
|
- Entities should not be multiplied unnecessarily.
|
||||||
- 八荣八耻
|
- 八荣八耻
|
||||||
|
|
||||||
以瞎猜接口为耻,以认真查询为荣。
|
以瞎猜接口为耻,以认真查询为荣。
|
||||||
|
|||||||
20
Dockerfile
20
Dockerfile
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
|
|
||||||
RUN cd web && npm install && npm run build
|
RUN cd web && npm install && npx vite build
|
||||||
|
|
||||||
FROM python:3.12.7-slim
|
FROM python:3.12.7-slim
|
||||||
|
|
||||||
@@ -12,12 +12,24 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
COPY --from=node /app/web/out ./web/out
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
RUN apt update \
|
RUN apt-get update \
|
||||||
&& apt install gcc -y \
|
&& apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \
|
||||||
|
# Install the Docker CLI (client only) so the optional langbot_box
|
||||||
|
# service can drive the mounted host Docker socket and create sandbox
|
||||||
|
# containers. The same image powers langbot / plugin_runtime / box; only
|
||||||
|
# box uses the client. Arch-aware via dpkg so multi-arch builds work.
|
||||||
|
&& install -m 0755 -d /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
|
||||||
|
&& chmod a+r /etc/apt/keyrings/docker.asc \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
||||||
&& python -m pip install --no-cache-dir uv \
|
&& python -m pip install --no-cache-dir uv \
|
||||||
&& uv sync \
|
&& uv sync \
|
||||||
|
&& apt-get purge -y --auto-remove curl gnupg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& touch /.dockerenv
|
&& touch /.dockerenv
|
||||||
|
|
||||||
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
||||||
36
Makefile
Normal file
36
Makefile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# LangBot Makefile
|
||||||
|
# Quick developer commands
|
||||||
|
|
||||||
|
.PHONY: test test-quick test-integration-fast test-coverage test-all-local lint
|
||||||
|
|
||||||
|
# Run all tests (full suite with coverage)
|
||||||
|
test:
|
||||||
|
bash run_tests.sh
|
||||||
|
|
||||||
|
# Quick self-test for developers (lint + unit + smoke, no real credentials needed)
|
||||||
|
test-quick:
|
||||||
|
bash scripts/test-quick.sh
|
||||||
|
|
||||||
|
# Fast integration tests (SQLite/API/Pipeline, no external services)
|
||||||
|
test-integration-fast:
|
||||||
|
bash scripts/test-integration-fast.sh
|
||||||
|
|
||||||
|
# Coverage gate (all tests, enforces minimum threshold)
|
||||||
|
test-coverage:
|
||||||
|
bash scripts/test-coverage.sh
|
||||||
|
|
||||||
|
# Full local quality gate (quick + integration + coverage)
|
||||||
|
test-all-local:
|
||||||
|
bash scripts/test-quick.sh
|
||||||
|
bash scripts/test-integration-fast.sh
|
||||||
|
bash scripts/test-coverage.sh
|
||||||
|
|
||||||
|
# Run linting only
|
||||||
|
lint:
|
||||||
|
ruff check src/langbot/ tests/
|
||||||
|
ruff format --check src/langbot/ tests/
|
||||||
|
|
||||||
|
# Fix linting issues
|
||||||
|
lint-fix:
|
||||||
|
ruff check --fix src/langbot/ tests/
|
||||||
|
ruff format src/langbot/ tests/
|
||||||
42
README.md
42
README.md
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Website</a> |
|
<a href="https://langbot.app">Website</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features">Features</a> |
|
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide">Docs</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
||||||
@@ -38,14 +38,16 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
|||||||
|
|
||||||
### Key Capabilities
|
### Key Capabilities
|
||||||
|
|
||||||
- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||||
- **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
- **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
- **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises.
|
- **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises.
|
||||||
- **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support.
|
- **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support.
|
||||||
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
||||||
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
||||||
|
|
||||||
[→ Learn more about all features](https://docs.langbot.app/en/insight/features)
|
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**More options:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt) · [Kubernetes](./docker/README_K8S.md)
|
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,24 +86,26 @@ docker compose up -d
|
|||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Official |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Official |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Official |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Official |
|
||||||
| QQ | ✅ | Personal & Official API |
|
| QQ | ✅ | Personal & Official API (Channel, DM, Group) |
|
||||||
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
||||||
| WeChat | ✅ | Personal & Official Account |
|
| WeChat | ✅ | Personal & Official Account |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Official |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Official |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Official |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Supports multiple bridged platforms such as Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, and more |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Supported LLMs & Integrations
|
## Supported LLMs & Integrations
|
||||||
|
|
||||||
| Provider | Type | Status |
|
| Provider | Type | Status |
|
||||||
|----------|------|--------|
|
| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ |
|
||||||
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
@@ -123,15 +127,16 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://docs.langbot.app/en/insight/features)
|
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why LangBot?
|
## Why LangBot?
|
||||||
|
|
||||||
| Use Case | How LangBot Helps |
|
| Use Case | How LangBot Helps |
|
||||||
|----------|-------------------|
|
| --------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
|
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
|
||||||
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
|
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
|
||||||
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
|
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
|
||||||
@@ -142,10 +147,11 @@ docker compose up -d
|
|||||||
## Live Demo
|
## Live Demo
|
||||||
|
|
||||||
**Try it now:** https://demo.langbot.dev/
|
**Try it now:** https://demo.langbot.dev/
|
||||||
|
|
||||||
- Email: `demo@langbot.app`
|
- Email: `demo@langbot.app`
|
||||||
- Password: `langbot123456`
|
- Password: `langbot123456`
|
||||||
|
|
||||||
*Note: Public demo environment. Do not enter sensitive information.*
|
_Note: Public demo environment. Do not enter sensitive information._
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
38
README_CN.md
38
README_CN.md
@@ -13,7 +13,7 @@
|
|||||||
[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)
|
[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)
|
[](https://discord.gg/wdNEHETs87)
|
||||||
[](https://qm.qq.com/q/DxZZcNxM1W)
|
[](https://qm.qq.com/q/IrlV8QFacU)
|
||||||
[](https://deepwiki.com/langbot-app/LangBot)
|
[](https://deepwiki.com/langbot-app/LangBot)
|
||||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||||
@@ -21,11 +21,11 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官网</a> |
|
<a href="https://langbot.app">官网</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/guide.html">文档</a> |
|
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
||||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">插件市场</a> |
|
<a href="https://space.langbot.app">扩展市场</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -38,14 +38,16 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
|||||||
|
|
||||||
### 核心能力
|
### 核心能力
|
||||||
|
|
||||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
|
||||||
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||||
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||||
|
|
||||||
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [宝塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,13 +89,16 @@ docker compose up -d
|
|||||||
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 个人微信、微信公众号 |
|
| 微信 | ✅ | 个人微信、微信公众号 |
|
||||||
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
||||||
| 飞书 | ✅ | |
|
| 飞书 | ✅ | 官方 |
|
||||||
| 钉钉 | ✅ | |
|
| 钉钉 | ✅ | 官方 |
|
||||||
| Discord | ✅ | |
|
| Satori | ✅ | |
|
||||||
| Telegram | ✅ | |
|
| Discord | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
| Telegram | ✅ | 官方 |
|
||||||
| LINE | ✅ | |
|
| Slack | ✅ | 官方 |
|
||||||
| KOOK | ✅ | |
|
| LINE | ✅ | 官方 |
|
||||||
|
| KOOK | ✅ | 官方 |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,8 +129,9 @@ docker compose up -d
|
|||||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
|
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
[→ 查看完整集成列表](https://docs.langbot.app/zh/insight/features.html)
|
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
### TTS(语音合成)
|
### TTS(语音合成)
|
||||||
|
|
||||||
|
|||||||
35
README_ES.md
35
README_ES.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Inicio</a> |
|
<a href="https://langbot.app">Inicio</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Características</a> |
|
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Documentación</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
||||||
|
|
||||||
@@ -37,14 +37,16 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
|||||||
|
|
||||||
### Capacidades Clave
|
### Capacidades Clave
|
||||||
|
|
||||||
- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com).
|
||||||
- **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
- **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
- **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas.
|
- **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas.
|
||||||
- **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/).
|
- **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/).
|
||||||
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
||||||
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
||||||
|
|
||||||
[→ Conocer más sobre todas las funcionalidades](https://docs.langbot.app/en/insight/features.html)
|
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Más opciones:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plataforma | Estado | Notas |
|
| Plataforma | Estado | Notas |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Oficial |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Oficial |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Oficial |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Oficial |
|
||||||
| QQ | ✅ | Personal y API Oficial |
|
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) |
|
||||||
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
||||||
| WeChat | ✅ | Personal y Cuenta Oficial |
|
| WeChat | ✅ | Personal y Cuenta Oficial |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Oficial |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Oficial |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Oficial |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +126,9 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://docs.langbot.app/en/insight/features.html)
|
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
35
README_FR.md
35
README_FR.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Accueil</a> |
|
<a href="https://langbot.app">Accueil</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a> |
|
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Documentation</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
||||||
|
|
||||||
@@ -37,14 +37,16 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
|||||||
|
|
||||||
### Capacités Clés
|
### Capacités Clés
|
||||||
|
|
||||||
- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||||
- **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
- **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
- **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises.
|
- **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises.
|
||||||
- **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/).
|
- **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/).
|
||||||
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
||||||
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
||||||
|
|
||||||
[→ En savoir plus sur toutes les fonctionnalités](https://docs.langbot.app/en/insight/features.html)
|
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Plus d'options :** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manuel](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plateforme | Statut | Notes |
|
| Plateforme | Statut | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Officiel |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Officiel |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Officiel |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Officiel |
|
||||||
| QQ | ✅ | Personnel & API Officielle |
|
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) |
|
||||||
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
||||||
| WeChat | ✅ | Personnel & Compte Officiel |
|
| WeChat | ✅ | Personnel & Compte Officiel |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Officiel |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Officiel |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Officiel |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +126,9 @@ docker compose up -d
|
|||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||||
|
|
||||||
[→ Voir toutes les intégrations](https://docs.langbot.app/en/insight/features.html)
|
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
37
README_JP.md
37
README_JP.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">ホーム</a> |
|
<a href="https://langbot.app">ホーム</a> |
|
||||||
<a href="https://docs.langbot.app/ja/insight/features.html">機能</a> |
|
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
||||||
<a href="https://docs.langbot.app/ja/insight/guide.html">ドキュメント</a> |
|
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
||||||
<a href="https://docs.langbot.app/ja/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||||
|
|
||||||
@@ -37,14 +37,16 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
|||||||
|
|
||||||
### 主な機能
|
### 主な機能
|
||||||
|
|
||||||
- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) と深く統合。
|
- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com) と深く統合。
|
||||||
- **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。
|
- **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。
|
||||||
- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。
|
- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。
|
||||||
- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。
|
- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。
|
||||||
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
||||||
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
||||||
|
|
||||||
[→ すべての機能について詳しく見る](https://docs.langbot.app/ja/insight/features.html)
|
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
||||||
|
|
||||||
|
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**その他:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| プラットフォーム | ステータス | 備考 |
|
| プラットフォーム | ステータス | 備考 |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 公式 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 公式 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 公式 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 公式 |
|
||||||
| QQ | ✅ | 個人 & 公式API |
|
| QQ | ✅ | 個人・公式API(チャンネル・DM・グループ) |
|
||||||
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
||||||
| WeChat | ✅ | 個人 & 公式アカウント |
|
| WeChat | ✅ | 個人・公式アカウント |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | 公式 |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | 公式 |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | 公式 |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix、Satori |
|
||||||
|
| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +126,9 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://docs.langbot.app/en/insight/features.html)
|
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
35
README_KO.md
35
README_KO.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">홈</a> |
|
<a href="https://langbot.app">홈</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">기능</a> |
|
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">문서</a> |
|
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||||
|
|
||||||
@@ -37,14 +37,16 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
|||||||
|
|
||||||
### 핵심 기능
|
### 핵심 기능
|
||||||
|
|
||||||
- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) 심층 통합.
|
- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com) 심층 통합.
|
||||||
- **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원.
|
- **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원.
|
||||||
- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.
|
- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.
|
||||||
- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원.
|
- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원.
|
||||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
||||||
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
||||||
|
|
||||||
[→ 모든 기능 자세히 보기](https://docs.langbot.app/en/insight/features.html)
|
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**더 많은 옵션:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| 플랫폼 | 상태 | 비고 |
|
| 플랫폼 | 상태 | 비고 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 공식 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 공식 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 공식 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 공식 |
|
||||||
| QQ | ✅ | 개인 및 공식 API |
|
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) |
|
||||||
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
||||||
| WeChat | ✅ | 개인 및 공식 계정 |
|
| WeChat | ✅ | 개인 및 공식 계정 |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | 공식 |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | 공식 |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | 공식 |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +126,9 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://docs.langbot.app/en/insight/features.html)
|
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
35
README_RU.md
35
README_RU.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Главная</a> |
|
<a href="https://langbot.app">Главная</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Возможности</a> |
|
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Документация</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||||
|
|
||||||
@@ -37,14 +37,16 @@ LangBot — это **платформа с открытым исходным к
|
|||||||
|
|
||||||
### Ключевые возможности
|
### Ключевые возможности
|
||||||
|
|
||||||
- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||||
- **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
- **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде.
|
- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде.
|
||||||
- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/).
|
- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/).
|
||||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
||||||
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
||||||
|
|
||||||
[→ Подробнее обо всех возможностях](https://docs.langbot.app/en/insight/features.html)
|
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Другие варианты:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Ручная установка](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Платформа | Статус | Примечания |
|
| Платформа | Статус | Примечания |
|
||||||
|-----------|--------|------------|
|
|-----------|--------|------------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Официальный |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Официальный |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Официальный |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Официальный |
|
||||||
| QQ | ✅ | Личный и официальный API |
|
| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) |
|
||||||
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
||||||
| WeChat | ✅ | Личный и официальный аккаунт |
|
| WeChat | ✅ | Личный и официальный аккаунт |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Официальный |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Официальный |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Официальный |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Поддерживает несколько платформ через мосты, включая Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip и другие |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +126,9 @@ docker compose up -d
|
|||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||||
|
|
||||||
[→ Смотреть все интеграции](https://docs.langbot.app/en/insight/features.html)
|
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
35
README_TW.md
35
README_TW.md
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官網</a> |
|
<a href="https://langbot.app">官網</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/guide.html">文件</a> |
|
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
||||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">外掛市場</a> |
|
<a href="https://space.langbot.app">外掛市場</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||||
|
|
||||||
@@ -39,14 +39,16 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
|||||||
|
|
||||||
### 核心能力
|
### 核心能力
|
||||||
|
|
||||||
- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、 [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
|
||||||
- **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
- **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||||
- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。
|
- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。
|
||||||
- **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。
|
- **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。
|
||||||
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
||||||
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
||||||
|
|
||||||
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手動部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [寶塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,17 +87,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| 平台 | 狀態 | 備註 |
|
| 平台 | 狀態 | 備註 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| Discord | ✅ | 官方 |
|
||||||
|
| Telegram | ✅ | 官方 |
|
||||||
|
| Slack | ✅ | 官方 |
|
||||||
|
| LINE | ✅ | 官方 |
|
||||||
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 個人微信、微信公眾號 |
|
|
||||||
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
||||||
| 飛書 | ✅ | |
|
| 微信 | ✅ | 個人微信、微信公眾號 |
|
||||||
| 釘釘 | ✅ | |
|
| 飛書 | ✅ | 官方 |
|
||||||
| Discord | ✅ | |
|
| 釘釘 | ✅ | 官方 |
|
||||||
| Telegram | ✅ | |
|
| KOOK | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
|
||||||
| LINE | ✅ | |
|
|
||||||
| KOOK | ✅ | |
|
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ docker compose up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
### TTS(語音合成)
|
### TTS(語音合成)
|
||||||
|
|
||||||
@@ -139,7 +144,7 @@ docker compose up -d
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||||
|
|
||||||
[→ 查看完整整合列表](https://docs.langbot.app/zh/insight/features.html)
|
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
35
README_VI.md
35
README_VI.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Trang chủ</a> |
|
<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://link.langbot.app/en/docs/features">Tính năng</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Tài liệu</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
||||||
|
|
||||||
@@ -37,14 +37,16 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
|||||||
|
|
||||||
### Khả năng chính
|
### Khả năng chính
|
||||||
|
|
||||||
- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||||
- **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
- **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
- **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng.
|
- **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng.
|
||||||
- **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/).
|
- **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/).
|
||||||
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
||||||
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
||||||
|
|
||||||
[→ Tìm hiểu thêm về tất cả tính năng](https://docs.langbot.app/en/insight/features.html)
|
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Thêm tùy chọn:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Thủ công](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Nền tảng | Trạng thái | Ghi chú |
|
| Nền tảng | Trạng thái | Ghi chú |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Chính thức |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Chính thức |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Chính thức |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Chính thức |
|
||||||
| QQ | ✅ | Cá nhân & API chính thức |
|
| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) |
|
||||||
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
||||||
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Chính thức |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Chính thức |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Chính thức |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Hỗ trợ nhiều nền tảng qua bridge như Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip và hơn thế nữa |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,8 +126,9 @@ docker compose up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||||
|
|
||||||
[→ Xem tất cả tích hợp](https://docs.langbot.app/en/insight/features.html)
|
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ spec:
|
|||||||
### 参考资源
|
### 参考资源
|
||||||
|
|
||||||
- [LangBot 官方文档](https://docs.langbot.app)
|
- [LangBot 官方文档](https://docs.langbot.app)
|
||||||
- [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
- [Docker 部署文档](https://link.langbot.app/zh/docs/docker)
|
||||||
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -625,5 +625,5 @@ spec:
|
|||||||
### References
|
### References
|
||||||
|
|
||||||
- [LangBot Official Documentation](https://docs.langbot.app)
|
- [LangBot Official Documentation](https://docs.langbot.app)
|
||||||
- [Docker Deployment Guide](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
- [Docker Deployment Guide](https://link.langbot.app/zh/docs/docker)
|
||||||
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
||||||
|
|||||||
@@ -18,6 +18,40 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- langbot_network
|
- langbot_network
|
||||||
|
|
||||||
|
# The Box sandbox runtime is optional. It is only started when you run
|
||||||
|
# ``docker compose --profile box up`` (or ``docker compose --profile all
|
||||||
|
# up``). With Box off, LangBot keeps the dashboard / skills list visible
|
||||||
|
# (read-only) but disables sandbox tools, skill add/edit and stdio MCP —
|
||||||
|
# set ``box.enabled: false`` in ``data/config.yaml`` (or
|
||||||
|
# ``BOX__ENABLED=false`` in the langbot service env below) to match.
|
||||||
|
langbot_box:
|
||||||
|
image: rockchin/langbot:latest
|
||||||
|
container_name: langbot_box
|
||||||
|
profiles: ["box", "all"]
|
||||||
|
volumes:
|
||||||
|
# Keep the source and target path identical because langbot_box uses the
|
||||||
|
# host Docker socket to create sandbox containers. Override
|
||||||
|
# LANGBOT_BOX_ROOT with an absolute path if you do not want the default.
|
||||||
|
- ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
|
# Mount container runtime socket for Box sandbox backend.
|
||||||
|
# Uncomment the one that matches your container runtime:
|
||||||
|
# - /var/run/podman/podman.sock:/var/run/podman/podman.sock # Podman
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # Docker
|
||||||
|
restart: on-failure
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
# The Box runtime does NOT read box.local.* from config.yaml or env; it
|
||||||
|
# receives its configuration from LangBot via the INIT RPC action.
|
||||||
|
# Do not add LANGBOT_BOX_* / BOX__* here — they would be silently ignored.
|
||||||
|
# Launched through the same CLI entry point as the plugin runtime
|
||||||
|
# (`langbot_plugin.cli.__init__ <subcommand>`). WebSocket is the default
|
||||||
|
# control transport — mirrors `rt`, which also runs with no flag. Pass
|
||||||
|
# `-s` / `--stdio-control` only for the stdio mode LangBot uses outside
|
||||||
|
# containers.
|
||||||
|
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"]
|
||||||
|
networks:
|
||||||
|
- langbot_network
|
||||||
|
|
||||||
langbot:
|
langbot:
|
||||||
image: rockchin/langbot:latest
|
image: rockchin/langbot:latest
|
||||||
container_name: langbot
|
container_name: langbot
|
||||||
@@ -26,6 +60,13 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
|
# Unified env-override convention: SECTION__SUBSECTION__KEY overrides the
|
||||||
|
# matching config.yaml field (see LoadConfigStage). These map onto
|
||||||
|
# box.local.* and are forwarded to the Box runtime via INIT RPC.
|
||||||
|
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
|
- BOX__LOCAL__DEFAULT_WORKSPACE=default
|
||||||
|
- BOX__LOCAL__SKILLS_ROOT=skills
|
||||||
|
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
ports:
|
ports:
|
||||||
- 5300:5300 # For web ui and webhook callback
|
- 5300:5300 # For web ui and webhook callback
|
||||||
- 2280-2285:2280-2285 # For platform reverse connection
|
- 2280-2285:2280-2285 # For platform reverse connection
|
||||||
|
|||||||
595
docs/review/box-architecture.md
Normal file
595
docs/review/box-architecture.md
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
# Box 系统架构深度分析
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-02
|
||||||
|
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||||
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
> 相关文档: [SaaS 阻塞项](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 全局架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ LangBot 主进程 │
|
||||||
|
│ │
|
||||||
|
│ LocalAgentRunner ──> ToolManager ──> NativeToolLoader │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ exec / read / write / edit │
|
||||||
|
│ │ │ glob / grep │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ├──> MCPLoader ──> BoxStdioSession │
|
||||||
|
│ │ │ (shared 容器, 多 process) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ├──> SkillToolLoader (activate 工具) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ├──> SkillAuthoringToolLoader │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ └──> PluginToolLoader │
|
||||||
|
│ │ │
|
||||||
|
│ BoxService (门面) │
|
||||||
|
│ ├─ Profile 管理 (locked 字段) │
|
||||||
|
│ ├─ Host mount 校验 (allowed_mount_roots) │
|
||||||
|
│ ├─ Workspace quota 检查 │
|
||||||
|
│ ├─ 输出截断 (head+tail) │
|
||||||
|
│ ├─ Session ID 模板解析 (resolve_box_session_id) │
|
||||||
|
│ ├─ 技能挂载组装 (build_skill_extra_mounts) │
|
||||||
|
│ ├─ 重连循环 (_reconnect_loop, 指数退避) │
|
||||||
|
│ └─ BoxRuntimeConnector │
|
||||||
|
│ ├─ 心跳 loop (20s ping) │
|
||||||
|
│ └─ ActionRPCBoxClient │
|
||||||
|
│ │ Action RPC (stdio 或 WebSocket) │
|
||||||
|
│ │
|
||||||
|
│ SkillManager (skill_mgr) │
|
||||||
|
│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Box Runtime 进程 (SDK 侧) │
|
||||||
|
│ │
|
||||||
|
│ BoxServerHandler (Action RPC 处理, INIT 配置注入) │
|
||||||
|
│ │ │
|
||||||
|
│ BoxRuntime (session 管理 / 进程生命周期 / TTL reaper) │
|
||||||
|
│ │ └─ session.managed_processes: dict[pid, _ManagedProcess]
|
||||||
|
│ │ │
|
||||||
|
│ Backend (启动时根据 box.backend 配置选择): │
|
||||||
|
│ DockerBackend ──┐ │
|
||||||
|
│ PodmanBackend ──┤── CLISandboxBackend │
|
||||||
|
│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │
|
||||||
|
│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │
|
||||||
|
│ │
|
||||||
|
│ BoxSkillStore │
|
||||||
|
│ ├─ list / get / create / update / delete │
|
||||||
|
│ ├─ scan_skill_directory / read_skill_file / write_skill_file │
|
||||||
|
│ └─ preview_skill_zip / install_skill_zip (zip 或 GitHub) │
|
||||||
|
│ │
|
||||||
|
│ aiohttp 单端口服务 (默认 :5410): │
|
||||||
|
│ /rpc/ws — Action RPC │
|
||||||
|
│ /v1/sessions/{id}/managed-process/ws — 默认 process │
|
||||||
|
│ /v1/sessions/{id}/managed-process/{pid}/ws — 指定 process │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 容器 / 沙箱 (Docker/Podman 容器, nsjail sandbox, 或 E2B 远程沙箱) │
|
||||||
|
│ - 隔离文件系统 / 网络 / PID 命名空间 │
|
||||||
|
│ - 资源限制 (CPU, 内存, PID 数, 可选 workspace 配额) │
|
||||||
|
│ - 主挂载 (host_path → mount_path) + 任意条 extra_mounts │
|
||||||
|
│ └─ Skills 通过 extra_mounts 挂在 /workspace/.skills/<name> │
|
||||||
|
│ - exec: 用户命令在此执行 │
|
||||||
|
│ - managed process: 多个长驻进程并存 (MCP Server / 自定义服务) │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心设计原则**:
|
||||||
|
- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller)
|
||||||
|
- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process
|
||||||
|
- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. LangBot 侧模块
|
||||||
|
|
||||||
|
### 2.1 BoxService (`pkg/box/service.py`, 722 行)
|
||||||
|
|
||||||
|
应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板:
|
||||||
|
|
||||||
|
主要公开方法(按定义顺序):
|
||||||
|
|
||||||
|
```
|
||||||
|
BoxService
|
||||||
|
├─ initialize() 连接 Box Runtime + 默认 workspace 准备
|
||||||
|
├─ _on_runtime_disconnect(connector) 触发重连
|
||||||
|
├─ _reconnect_loop(connector) 指数退避重连
|
||||||
|
├─ available (property) 连接状态
|
||||||
|
│
|
||||||
|
├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id
|
||||||
|
├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表
|
||||||
|
│
|
||||||
|
├─ execute_tool(parameters, query) Agent 调用 exec 时的入口
|
||||||
|
│ ├─ _apply_profile / build_spec
|
||||||
|
│ ├─ _validate_host_mount
|
||||||
|
│ ├─ _enforce_workspace_quota (phase=pre)
|
||||||
|
│ ├─ client.execute(spec)
|
||||||
|
│ ├─ _enforce_workspace_quota (phase=post)
|
||||||
|
│ └─ _truncate (stdout/stderr)
|
||||||
|
│
|
||||||
|
├─ execute_spec_payload(spec_payload, ...) 内部入口(其他 loader 调用)
|
||||||
|
├─ create_session(spec_payload, ...) 显式创建 session
|
||||||
|
├─ start_managed_process(session_id, ...) 启动 managed process
|
||||||
|
├─ get_managed_process(session_id, pid) 查询进程状态(pid 默认 'default')
|
||||||
|
├─ stop_managed_process(session_id, pid) 单独停止某个 managed process
|
||||||
|
├─ get_managed_process_websocket_url(...) 返回 WS attach URL
|
||||||
|
│
|
||||||
|
├─ list_skills() / get_skill(name) Skill 元数据
|
||||||
|
├─ create_skill / update_skill / delete_skill Skill CRUD
|
||||||
|
├─ scan_skill_directory(path) 扫描目录
|
||||||
|
├─ list_skill_files / read_skill_file / write_skill_file
|
||||||
|
├─ preview_skill_zip / install_skill_zip zip / GitHub 安装
|
||||||
|
│
|
||||||
|
├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止
|
||||||
|
├─ get_status() / get_sessions() / get_recent_errors()
|
||||||
|
└─ get_system_guidance() LLM 系统提示
|
||||||
|
```
|
||||||
|
|
||||||
|
**Profile 系统**: 4 个内置 Profile(`default` / `offline_readonly` / `network_basic` / `network_extended`),`locked` frozenset 字段不可被 LLM 覆盖。参数合并顺序:Profile defaults → LLM 请求参数 → locked 强制值。
|
||||||
|
|
||||||
|
**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。
|
||||||
|
|
||||||
|
**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。
|
||||||
|
|
||||||
|
### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行)
|
||||||
|
|
||||||
|
管理与 Box Runtime 的通信连接:
|
||||||
|
|
||||||
|
- **本地 stdio**: Unix/macOS 默认路径,fork `python -m langbot_plugin.cli.__init__ box -s --ws-control-port {port}` 子进程(与 plugin runtime 统一走 `lbp` CLI 入口)
|
||||||
|
- **本地 subprocess + WS**: Windows 本地(asyncio ProactorEventLoop 不支持 stdio pipe)
|
||||||
|
- **远程 WebSocket**: Docker 部署 / `box.runtime.endpoint` 显式配置时,连接 `ws://{host}:{port}/rpc/ws`
|
||||||
|
- **同步等待**: `asyncio.Event` + `wait_for(timeout=30s)` 模式确认连接
|
||||||
|
- **心跳**: `_heartbeat_loop()` 每 20s 调用 `ping()`,失败仅 DEBUG 日志(断开检测靠 connection close)
|
||||||
|
- **重连**: `runtime_disconnect_callback` 由 BoxService 提供,触发 `_reconnect_loop`
|
||||||
|
- **INIT 注入**: 连接建立后立即下发当前 `box.*` 配置子树(剔除 `runtime` 私有字段),Runtime 据此初始化 backend
|
||||||
|
|
||||||
|
> **历史改进**: 2026-04-16 版本本文档曾列 P0 「Box 无心跳 / 无重连」,已修复(commit `2dfd9d5d`、`c6882cf`、`5029d9c` 等)。
|
||||||
|
|
||||||
|
### 2.3 BoxWorkspaceSession 工具 (`pkg/box/workspace.py`, 413 行)
|
||||||
|
|
||||||
|
此文件目前提供两类能力:
|
||||||
|
|
||||||
|
1. **路径与命令重写工具函数** — `normalize_host_path` / `rewrite_mounted_path` / `unwrap_venv_path` / `rewrite_venv_command` / `infer_workspace_host_path`,被 MCP loader 与 Skill 路径解析共用。
|
||||||
|
2. **`BoxWorkspaceSession`** — 围绕 BoxService 的轻量包装,专供 MCP-in-Box 场景使用(管理一个共享 session 的 session_id、构建挂载 payload、stage host 文件到共享 workspace)。
|
||||||
|
|
||||||
|
**变化点**: 早期 Skill exec 会为每个 skill 创建独立 BoxWorkspaceSession(独占 session);当前实现已转为 `extra_mounts` 模式,Skill 不再独占容器,只追加挂载。这部分 wrapping 逻辑已从 native loader 移除。
|
||||||
|
|
||||||
|
### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 仍是死代码
|
||||||
|
|
||||||
|
三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [SaaS 阻塞项 S2](./box-issues.md)。
|
||||||
|
|
||||||
|
### 2.5 SkillManager (`pkg/skill/manager.py`, 186 行)
|
||||||
|
|
||||||
|
```
|
||||||
|
SkillManager
|
||||||
|
├─ initialize() 调用 reload_skills()
|
||||||
|
├─ reload_skills() 先从 Box runtime list_skills(),
|
||||||
|
│ 不可用则回落 data/skills/ 扫描
|
||||||
|
├─ refresh_skill_from_disk() 单 skill 重新加载
|
||||||
|
├─ get_skill_by_name(name)
|
||||||
|
└─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径
|
||||||
|
```
|
||||||
|
|
||||||
|
skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。
|
||||||
|
|
||||||
|
### 2.6 Skill activation (`pkg/skill/activation.py`, 33 行) + Skill loader 辅助
|
||||||
|
|
||||||
|
历史上 skill 通过 LLM 在文本中输出 `[ACTIVATE_SKILL:name]` 标记激活;当前已改为 **Tool Call 机制**:
|
||||||
|
|
||||||
|
- `SkillToolLoader` (`pkg/provider/tools/loaders/skill.py`, 157 行) 暴露 `activate` 工具,参数为 skill 名
|
||||||
|
- 工具实现调用 `register_activated_skill(query, skill_data)`,将激活态写入 `query.variables['_activated_skills']`
|
||||||
|
- 这种 KV-cache-friendly 模式对齐 Claude Code 设计;详见 [box-session-scope.md §4.3](./box-session-scope.md) 的 Tool Call 描述
|
||||||
|
|
||||||
|
`activation.py` 现仅保留对外辅助函数(pipeline 层调用 loader 的 `register_activated_skill`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. SDK 侧模块
|
||||||
|
|
||||||
|
### 3.1 BoxRuntime (`box/runtime.py`, 599 行)
|
||||||
|
|
||||||
|
核心编排器,管理 session 生命周期与 backend 调度:
|
||||||
|
|
||||||
|
```
|
||||||
|
Session 生命周期:
|
||||||
|
|
||||||
|
Client EXEC / CREATE_SESSION
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
_get_or_create_session(spec)
|
||||||
|
├─ _reap_expired_sessions_locked() 清理 TTL 过期 session
|
||||||
|
├─ 已存在? → _assert_session_compatible() → 复用
|
||||||
|
├─ Backend session 失踪? → 重建 (commit c6882cf)
|
||||||
|
└─ 新建? → backend.start_session(spec) → 创建容器
|
||||||
|
│ └─ 应用 spec.extra_mounts (多挂载)
|
||||||
|
▼
|
||||||
|
execute(spec)
|
||||||
|
├─ 获取 session lock (每 session 独立)
|
||||||
|
├─ backend.exec(session, spec) 在容器中执行命令
|
||||||
|
├─ 更新 last_used_at
|
||||||
|
└─ 超时? → 销毁 session
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Session 保持存活直到:
|
||||||
|
├─ TTL 过期 (默认 300s,下次操作时清理)
|
||||||
|
├─ 执行超时 (自动销毁)
|
||||||
|
├─ 客户端 DELETE_SESSION
|
||||||
|
└─ SHUTDOWN
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键设计**:
|
||||||
|
- 每 session 有独立 `asyncio.Lock`,同一 session 内的命令串行执行
|
||||||
|
- 每 session 维护 `managed_processes: dict[process_id, _ManagedProcess]`,支持多个长驻进程并存(MCP / 自定义)
|
||||||
|
- 全局 `_lock` 保护 `_sessions` dict 的读写
|
||||||
|
- 兼容性检查:比较核心 spec 字段,`image` 字段对不支持自定义镜像的 backend(nsjail/E2B)会跳过
|
||||||
|
|
||||||
|
**Backend 选择 (`_select_backend`)**: 优先级
|
||||||
|
1. 显式 `box.backend` 配置(`docker` / `nsjail` / `e2b`)
|
||||||
|
2. `local` (默认) → Docker / Podman / nsjail CLI 顺序探测
|
||||||
|
3. `get_status` 调用时若当前 backend 不可用,会尝试重新选择 (commit `e5617c7`)
|
||||||
|
|
||||||
|
### 3.2 Backend 系统
|
||||||
|
|
||||||
|
#### CLISandboxBackend (`box/backend.py`, 411 行)
|
||||||
|
|
||||||
|
Docker / Podman 公共基类:
|
||||||
|
|
||||||
|
```
|
||||||
|
start_session(spec):
|
||||||
|
1. validate_sandbox_security(spec)
|
||||||
|
2. docker/podman run -d --rm --name <name>
|
||||||
|
--network none (可选)
|
||||||
|
--cpus/--memory/--pids-limit
|
||||||
|
--read-only + --tmpfs /tmp
|
||||||
|
-v <host>:<mount>:<mode> 主挂载
|
||||||
|
-v <extra.host>:<extra.mount>:.. 额外挂载 (extra_mounts)
|
||||||
|
<image> sh -lc 'while true; do sleep 3600; done'
|
||||||
|
3. 返回 BoxSessionInfo
|
||||||
|
|
||||||
|
exec(session, spec):
|
||||||
|
docker/podman exec -e KEY=VAL <container>
|
||||||
|
sh -lc 'mkdir -p <workdir> && cd <workdir> && <cmd>'
|
||||||
|
|
||||||
|
start_managed_process(session, spec):
|
||||||
|
docker/podman exec -i <container>
|
||||||
|
sh -lc 'mkdir -p <cwd> && cd <cwd> && exec <command> <args>'
|
||||||
|
返回 asyncio.subprocess.Process (stdin/stdout PIPE)
|
||||||
|
```
|
||||||
|
|
||||||
|
容器以 idle 进程启动,实际命令通过 `docker exec` 执行。`--rm` 确保容器退出时自动清理。
|
||||||
|
|
||||||
|
**Windows 支持**: backend 内对 Windows 路径处理与 subprocess 调用做了适配(commit `120817a`)。
|
||||||
|
|
||||||
|
**孤儿清理**: 启动时枚举 `langbot.box=true` 标签的容器,instance_id 不匹配的强制删除。
|
||||||
|
|
||||||
|
#### NsjailBackend (`box/nsjail_backend.py`, 552 行)
|
||||||
|
|
||||||
|
轻量级 Linux 沙箱(无容器引擎依赖):
|
||||||
|
|
||||||
|
- 使用 namespace 隔离(user/mount/pid/ipc/uts/cgroup/net)
|
||||||
|
- 挂载宿主 `/usr`/`/lib`/`/bin`/`/sbin` 只读 + 选定 `/etc` 条目
|
||||||
|
- 每 session 创建独立目录(workspace/tmp/home)
|
||||||
|
- 资源限制: cgroup v2 优先,fallback 到 rlimit
|
||||||
|
- **CLI 兼容**: 通过 `shutil.which(self._nsjail_bin)` 检测系统安装版 nsjail;不存在时再尝试容器内 nsjail(commit `686fcc0`、`feed530`)
|
||||||
|
- **无自定义镜像**: 使用宿主 OS,`image` 字段固定为 `'host'`,兼容性检查跳过 image
|
||||||
|
|
||||||
|
#### E2BBackend (`box/e2b_backend.py`, 429 行)
|
||||||
|
|
||||||
|
云沙箱后端(commit `75b547f` 引入):
|
||||||
|
|
||||||
|
- 通过 `e2b` SDK 与 E2B 平台通信
|
||||||
|
- 配置:`box.e2b.api_key` / `api_url` / `template`
|
||||||
|
- 支持 `extra_mounts`(commit `0fea9b1` 同步上传文件)
|
||||||
|
- 无本地容器引擎依赖,适合无 Docker 的部署或 SaaS 多租户场景
|
||||||
|
- 不支持自定义 image 字段,由 template 控制
|
||||||
|
|
||||||
|
### 3.3 Server (`box/server.py`, 508 行)
|
||||||
|
|
||||||
|
单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口):
|
||||||
|
|
||||||
|
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理所有 action,包括 `INIT` 配置注入、skill store 操作等
|
||||||
|
2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws` 与 `/v1/sessions/{id}/managed-process/{pid}/ws`): 双向桥接 WebSocket ↔ 指定 managed process stdin/stdout
|
||||||
|
|
||||||
|
stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attach;Action RPC 走 stdin/stdout。
|
||||||
|
|
||||||
|
### 3.4 Client (`box/client.py`, 377 行)
|
||||||
|
|
||||||
|
`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用:
|
||||||
|
|
||||||
|
- 25+ 方法对应 25+ 个 RPC action(exec / session / managed-process / skill / status / shutdown)
|
||||||
|
- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型
|
||||||
|
- `execute()` timeout = 300s,其他默认 15s
|
||||||
|
- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用
|
||||||
|
|
||||||
|
包级别 `__init__.py` 显式导出:`BoxRuntimeClient`、`ActionRPCBoxClient`(commit `df9c722`)。
|
||||||
|
|
||||||
|
### 3.5 Actions (`box/actions.py`, 34 行)
|
||||||
|
|
||||||
|
`LangBotToBoxAction` 枚举共定义 **25 个** action:
|
||||||
|
|
||||||
|
| 类别 | Actions |
|
||||||
|
|------|---------|
|
||||||
|
| 控制 | `INIT`、`HEALTH`、`STATUS`、`GET_BACKEND_INFO`、`SHUTDOWN` |
|
||||||
|
| 执行 | `EXEC` |
|
||||||
|
| Session | `CREATE_SESSION` / `GET_SESSION` / `GET_SESSIONS` / `DELETE_SESSION` |
|
||||||
|
| Managed Process | `START_MANAGED_PROCESS` / `GET_MANAGED_PROCESS` / `STOP_MANAGED_PROCESS` |
|
||||||
|
| Skill | `LIST_SKILLS` / `GET_SKILL` / `CREATE_SKILL` / `UPDATE_SKILL` / `DELETE_SKILL` / `SCAN_SKILL_DIRECTORY` / `LIST_SKILL_FILES` / `READ_SKILL_FILE` / `WRITE_SKILL_FILE` / `PREVIEW_SKILL_ZIP` / `INSTALL_SKILL_ZIP` |
|
||||||
|
|
||||||
|
### 3.6 Models (`box/models.py`, 331 行)
|
||||||
|
|
||||||
|
核心数据模型:
|
||||||
|
|
||||||
|
| 模型 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `BoxNetworkMode` | `OFF` / `ON` |
|
||||||
|
| `BoxExecutionStatus` | `COMPLETED` / `TIMED_OUT` |
|
||||||
|
| `BoxHostMountMode` | `NONE` / `READ_ONLY` / `READ_WRITE` |
|
||||||
|
| `BoxManagedProcessStatus` | `RUNNING` / `EXITED` |
|
||||||
|
| `BoxMountSpec` | 单条挂载(host_path/mount_path/mode)— **新增** |
|
||||||
|
| `BoxSpec` | 执行请求;新增 `extra_mounts: list[BoxMountSpec]`、`persistent`、`workspace_quota_mb` |
|
||||||
|
| `BoxProfile` | 4 个内置 Profile + `locked` frozenset |
|
||||||
|
| `BoxSessionInfo` | Session 状态(含 backend_name/created_at/last_used_at) |
|
||||||
|
| `BoxManagedProcessSpec` | 长驻进程参数(process_id/command/args/env/cwd) |
|
||||||
|
| `BoxManagedProcessInfo` | 进程状态(status/exit_code/stderr_preview/attached) |
|
||||||
|
| `BoxExecutionResult` | 执行结果(status/exit_code/stdout/stderr/duration_ms) |
|
||||||
|
|
||||||
|
`BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。
|
||||||
|
|
||||||
|
### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行)
|
||||||
|
|
||||||
|
新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime:
|
||||||
|
|
||||||
|
```
|
||||||
|
BoxSkillStore
|
||||||
|
├─ list_skills() / get_skill(name)
|
||||||
|
├─ create_skill(data) / update_skill(name, data) / delete_skill(name)
|
||||||
|
├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表
|
||||||
|
├─ list_skill_files(name, path) 浏览 skill 内文件树
|
||||||
|
├─ read_skill_file(name, path) / write_skill_file(name, path, content)
|
||||||
|
├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容
|
||||||
|
└─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root
|
||||||
|
└─ 支持 source_subdir / target_suffix(commit 1aa043f)
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone` 拉取,再走 `install_skill_zip` 或 directory 路径。Skill 文件存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),容器内对应 `/workspace/.skills/`。
|
||||||
|
|
||||||
|
### 3.8 Security (`box/security.py`, 52 行)
|
||||||
|
|
||||||
|
`validate_sandbox_security()`: 黑名单校验 host_path,阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。
|
||||||
|
|
||||||
|
**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [SaaS 阻塞项 S5](./box-issues.md)。
|
||||||
|
|
||||||
|
### 3.9 Errors (`box/errors.py`, 33 行)
|
||||||
|
|
||||||
|
| 异常类型 | 含义 |
|
||||||
|
|----------|------|
|
||||||
|
| `BoxError` | 基类 |
|
||||||
|
| `BoxValidationError` | spec/参数校验失败 |
|
||||||
|
| `BoxBackendUnavailableError` | 无可用 backend |
|
||||||
|
| `BoxRuntimeUnavailableError` | Runtime 服务不可用 |
|
||||||
|
| `BoxSessionConflictError` | session 已存在但 spec 不兼容 |
|
||||||
|
| `BoxSessionNotFoundError` | session 不存在 |
|
||||||
|
| `BoxManagedProcessConflictError` | session 已有同名 process |
|
||||||
|
| `BoxManagedProcessNotFoundError` | process 不存在 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 工具系统集成
|
||||||
|
|
||||||
|
### 4.1 ToolManager 编排 (`toolmgr.py`)
|
||||||
|
|
||||||
|
```
|
||||||
|
ToolManager.initialize()
|
||||||
|
├─ NativeToolLoader (exec / read / write / edit / glob / grep)
|
||||||
|
├─ PluginToolLoader (插件工具)
|
||||||
|
├─ MCPLoader (MCP Server 工具)
|
||||||
|
├─ SkillToolLoader (activate 工具 — Tool Call 激活)
|
||||||
|
└─ SkillAuthoringToolLoader (Skill CRUD)
|
||||||
|
|
||||||
|
工具调用优先级: native → plugin → mcp → skill → skill_authoring
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Native Tools (`native.py`, 846 行)
|
||||||
|
|
||||||
|
| 工具 | 是否在 Box 中执行 | 是否访问宿主文件系统 |
|
||||||
|
|------|:---:|:---:|
|
||||||
|
| `exec` | 是 | 否 |
|
||||||
|
| `read` | **否** | **是** — 直接 `open()` 宿主文件 |
|
||||||
|
| `write` | **否** | **是** — 直接 `open()` 宿主文件 |
|
||||||
|
| `edit` | **否** | **是** — 直接 `open()` 宿主文件 |
|
||||||
|
| `glob` | **否** | **是** — 直接遍历宿主目录 |
|
||||||
|
| `grep` | **否** | **是** — 直接读宿主文件 |
|
||||||
|
|
||||||
|
**沙箱边界不对称**: 这是刻意的设计权衡 — `read`/`write`/`edit`/`glob`/`grep` 绕过沙箱以获得性能(避免容器 I/O 开销与跨进程拷贝),但意味着 LLM 可以直接读写 `allowed_mount_roots` 下任何文件。Skill 路径经 `_resolve_host_path()` 重写,禁止穿越 `package_root`。
|
||||||
|
|
||||||
|
**exec 的 Skill 分支**: 命令中引用 `/workspace/.skills/<name>` 的 skill 时:
|
||||||
|
1. 验证 skill 已激活
|
||||||
|
2. 单次 exec 只能引用一个 skill 包
|
||||||
|
3. 若 skill 是 Python 项目(有 `requirements.txt` 或 `pyproject.toml`),命令会被 venv bootstrap 包裹(在 skill 挂载点内创建 `.venv`)
|
||||||
|
4. 调用 `box_service.execute_tool()` → 走默认 session_id 与已组装好的 `extra_mounts`,**不再为每 skill 起独立 session**
|
||||||
|
|
||||||
|
### 4.3 MCP-in-Box (`mcp_stdio.py`, 354 行)
|
||||||
|
|
||||||
|
`BoxStdioSessionRuntime` 让 MCP stdio 服务器在 Box 容器中运行,**共享 session、多 process**模式(commit `529088e`):
|
||||||
|
|
||||||
|
```
|
||||||
|
initialize()
|
||||||
|
1. 复用/创建共享 session (session_id = _build_box_session_id())
|
||||||
|
- persistent=True,长期保持
|
||||||
|
2. workspace.execute_raw(install_cmd) 安装依赖 (可选)
|
||||||
|
3. 将每个 MCP server 文件 stage 到 /workspace/.mcp/<process_id>/
|
||||||
|
4. workspace.start_managed_process(process_id=<server>)
|
||||||
|
5. websocket_client(ws_url) 通过 WS relay 连接
|
||||||
|
6. ClientSession.initialize() MCP 协议握手
|
||||||
|
```
|
||||||
|
|
||||||
|
配置 (`MCPServerBoxConfig`): `network='on'` (MCP 服务器通常需要网络),`host_path_mode='ro'` (默认只读),`startup_timeout_sec=120` (留时间给 pip install)。
|
||||||
|
|
||||||
|
每条 MCP server 是同一 session 中的一个 managed process,独立的 `process_id`、独立 attach URL,互不阻塞。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 启动与生命周期
|
||||||
|
|
||||||
|
### 5.1 启动顺序 (`build_app.py`)
|
||||||
|
|
||||||
|
```
|
||||||
|
BuildAppStage.run(ap)
|
||||||
|
├─ ... (persistence, models, sessions) ...
|
||||||
|
│
|
||||||
|
├─ BoxService(ap)
|
||||||
|
├─ box_service.initialize()
|
||||||
|
│ └─ connector.initialize()
|
||||||
|
│ ├─ [stdio] fork box subprocess
|
||||||
|
│ ├─ [subprocess+WS] Windows 本地
|
||||||
|
│ └─ [remote WS] connect URL
|
||||||
|
│ └─ 启动心跳 _heartbeat_task
|
||||||
|
├─ ap.box_service = box_service
|
||||||
|
│
|
||||||
|
├─ ToolManager(ap)
|
||||||
|
├─ tool_mgr.initialize()
|
||||||
|
│ ├─ NativeToolLoader (检查 box_service.available)
|
||||||
|
│ ├─ PluginToolLoader
|
||||||
|
│ ├─ MCPLoader (Box 可用时,stdio MCP 走沙箱)
|
||||||
|
│ └─ SkillAuthoringToolLoader
|
||||||
|
├─ ap.tool_mgr = tool_mgr
|
||||||
|
│
|
||||||
|
├─ ... (platform, pipeline) ...
|
||||||
|
├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表)
|
||||||
|
└─ ... (RAG, HTTP, plugins) ...
|
||||||
|
```
|
||||||
|
|
||||||
|
BoxService 在 ToolManager **之前**初始化。ToolManager 创建 loader 时检查 `box_service.available`。
|
||||||
|
|
||||||
|
### 5.2 初始化失败处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
await self._runtime_connector.initialize()
|
||||||
|
self._available = True
|
||||||
|
except Exception as e:
|
||||||
|
self._available = False
|
||||||
|
logger.warning(f"Box runtime unavailable: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。
|
||||||
|
|
||||||
|
### 5.3 销毁流程
|
||||||
|
|
||||||
|
```
|
||||||
|
app.dispose()
|
||||||
|
└─ box_service.dispose()
|
||||||
|
├─ connector.dispose()
|
||||||
|
│ ├─ cancel _heartbeat_task
|
||||||
|
│ ├─ cancel _handler_task / _ctrl_task
|
||||||
|
│ └─ terminate subprocess (SIGTERM)
|
||||||
|
└─ loop.create_task(client.shutdown())
|
||||||
|
└─ RPC SHUTDOWN → Box Runtime 清理所有容器
|
||||||
|
```
|
||||||
|
|
||||||
|
Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的直接杀进程更安全。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 配置
|
||||||
|
|
||||||
|
### config.yaml (重构后)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
box:
|
||||||
|
enabled: true # 整个 Box 子系统的总开关。设为 false 时:
|
||||||
|
# - 不连接远程 Box runtime,不 fork 本地 stdio 子进程
|
||||||
|
# - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM
|
||||||
|
# - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝
|
||||||
|
# - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响)
|
||||||
|
# - skill 列表/读取保持只读可用
|
||||||
|
# BOX__ENABLED 环境变量可覆盖(统一约定)
|
||||||
|
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
|
||||||
|
# 由 box.backend / BOX__BACKEND 选择后端
|
||||||
|
runtime:
|
||||||
|
endpoint: '' # 外部 Runtime 的 WS 基地址 'ws://host:5410'
|
||||||
|
# 留空 = 本地自管 Runtime
|
||||||
|
local:
|
||||||
|
profile: 'default'
|
||||||
|
image: '' # 覆盖 profile 默认 image
|
||||||
|
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
|
||||||
|
default_workspace: '' # 默认 '<host_root>/default'
|
||||||
|
skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root)
|
||||||
|
allowed_mount_roots: # 默认 ['<host_root>']
|
||||||
|
- './data/box'
|
||||||
|
- '/tmp'
|
||||||
|
workspace_quota_mb: null # 配额覆盖,null = 走 profile
|
||||||
|
e2b:
|
||||||
|
api_key: '' # 也可走 E2B_API_KEY 环境变量
|
||||||
|
api_url: '' # 自托管 E2B 时填写
|
||||||
|
template: '' # 默认 template ID
|
||||||
|
```
|
||||||
|
|
||||||
|
> **重大变更**: 较 2026-04-16 文档,配置结构完全重组(commit `eefdea4`)。原字段 `box.profile` / `box.runtime_url` / `box.shared_host_root` / `box.allowed_host_mount_roots` 全部迁入 `box.local.*` 子表,新增 `box.backend` 与 `box.e2b.*` 配置组。
|
||||||
|
|
||||||
|
### docker-compose.yaml
|
||||||
|
|
||||||
|
`langbot_box` 服务受 compose profile 控制,默认 `docker compose up` **不会**启动它。需要 sandbox 时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile box up # 启动 langbot + langbot_box + plugin runtime
|
||||||
|
docker compose --profile all up # 同上
|
||||||
|
docker compose up # 只起 langbot + plugin runtime (box 关闭)
|
||||||
|
```
|
||||||
|
|
||||||
|
若不起 `langbot_box`,需要同步在 `data/config.yaml` 中设 `box.enabled: false`(或 langbot 容器 env 加 `BOX__ENABLED=false`),否则 LangBot 会一直尝试连接不存在的 Box runtime 并报错。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# langbot_box 的关键 volume
|
||||||
|
volumes:
|
||||||
|
- ${LANGBOT_BOX_ROOT}:${LANGBOT_BOX_ROOT} # 工作区挂载(源/目标同路径)
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # Docker backend 复用宿主 docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关闭/连接失败时的行为矩阵
|
||||||
|
|
||||||
|
`box.enabled = false` 与"启用但连接失败"在用户可观察行为上**完全一致**——都通过 `BoxService.available = False` 表达,只是 `get_status` 多返回 `enabled` 字段供前端区分文案。
|
||||||
|
|
||||||
|
| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) |
|
||||||
|
|---|---|---|
|
||||||
|
| native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** |
|
||||||
|
| `activate` / `register_skill` 工具 | 暴露给 LLM | **不暴露** |
|
||||||
|
| stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio |
|
||||||
|
| http/sse MCP server | 正常 | 正常(不依赖 Box) |
|
||||||
|
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback |
|
||||||
|
| Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) |
|
||||||
|
| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 |
|
||||||
|
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner |
|
||||||
|
| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) |
|
||||||
|
|
||||||
|
> 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。
|
||||||
|
|
||||||
|
### Pipeline 配置 (templates/metadata/pipeline/ai.yaml)
|
||||||
|
|
||||||
|
`local-agent.config.box-session-id-template` 控制 session 作用域,预设:
|
||||||
|
|
||||||
|
- `{launcher_type}_{launcher_id}` — 每个会话 (推荐,默认)
|
||||||
|
- `{launcher_type}_{launcher_id}_{sender_id}` — 群聊每个用户
|
||||||
|
- `{launcher_type}_{launcher_id}_{conversation_id}` — 每个对话上下文
|
||||||
|
- `{query_id}` — 每条消息(完全隔离)
|
||||||
|
|
||||||
|
详见 [box-session-scope.md](./box-session-scope.md)。
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 | 前端 |
|
||||||
|
|------|------|------|:---:|
|
||||||
|
| `/api/v1/box/status` | GET | 可用性、Profile、后端信息 | ✅ 监控页 |
|
||||||
|
| `/api/v1/box/sessions` | GET | 活跃 session 列表 | ❌ |
|
||||||
|
| `/api/v1/box/errors` | GET | 最近 50 条错误 | ❌ |
|
||||||
|
| `/api/v1/skills` 等 | GET/POST/PUT/DELETE | Skill CRUD、文件浏览、zip/GitHub 安装、preview | ✅ Skill 管理页 |
|
||||||
|
|
||||||
|
前端 `web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx` 已接入 `/api/v1/box/status`,展示 backend 名称、profile 与活跃 session 数。Sessions 与 errors API 仍未接入。
|
||||||
76
docs/review/box-issues.md
Normal file
76
docs/review/box-issues.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Box 系统 — SaaS 发布前阻塞项
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-02
|
||||||
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
> 相关文档: [架构分析](./box-architecture.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
||||||
|
|
||||||
|
## 范围说明
|
||||||
|
|
||||||
|
**自部署社区版已具备发布条件**:默认 stdio 模式、box 为可选项;box 关闭 / 不可用时后端、前端、工具、skill、stdio-MCP 均能干净降级(清晰报错、不崩溃);配置向后兼容(旧 `data/config.yaml` 可直接启动);无新增 ORM 模型、无迁移欠债;市场安装失败不会破坏实例。CI 全绿。
|
||||||
|
|
||||||
|
本清单**只保留发布 SaaS / 多租户 / 公网暴露前必须处理的阻塞项**。社区版(可信、单运营者、内网)不受这些项阻塞——它们的风险面在"不可信调用方能直接触达 Box 控制面"或"多租户共享资源"的场景才成立。
|
||||||
|
|
||||||
|
## 已解决(社区版发布前)
|
||||||
|
|
||||||
|
| 项 | 处理 |
|
||||||
|
|----|------|
|
||||||
|
| 工具调用循环无上限 (原 #13) | `localagent.py` 增加 `MAX_TOOL_CALL_ROUNDS=128`,超限优雅终止(`cafef1a3`) |
|
||||||
|
| 配额校验同步遍历阻塞事件循环 (原 #10) | `_enforce_workspace_quota` 改 async,工作区遍历走 `asyncio.to_thread`(`cafef1a3`) |
|
||||||
|
| `host_path` 挂载白名单 (原 #3 的 LangBot 侧) | `pkg/box/service.py` `allowed_mount_roots` 白名单,空列表时拒绝一切宿主挂载 |
|
||||||
|
| 重复的 `_is_path_under` (原 #12) | 已去重,仅保留一处定义 |
|
||||||
|
| 重连 / 心跳 / Windows 兼容 / nsjail image 字段 / 前端 Box 状态接入 | 见上一轮 review 记录,均已合入 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SaaS 阻塞项
|
||||||
|
|
||||||
|
### S1. Box 控制面无认证 — Critical
|
||||||
|
|
||||||
|
- **位置**: SDK `box/server.py` — Action RPC WS (`/rpc/ws`) 与 managed-process relay (`/v1/sessions/{id}/managed-process/{pid}/ws`)
|
||||||
|
- **现状**: 两个 WS handler 在 `ws.prepare` 后直接服务,无任何 token / 鉴权;box 默认绑定 `0.0.0.0:5410`。任何能触达该端口者可发起 `EXEC`、创建 session、attach 任意 session 的 managed-process stdin/stdout、甚至 `SHUTDOWN`。LangBot→box 的 INIT 也未下发任何凭证。
|
||||||
|
- **缓解现状**: 默认 `docker-compose.yaml` 的 `langbot_box` 未把 5410 发布到宿主(爆炸半径限于内网 bridge);但 box 挂载了 `/var/run/docker.sock`,同网络的任意服务(含被攻破的插件)→ 宿主 root。若运营者把 5410 发布到宿主或独立以 `0.0.0.0` 起 box,则完全裸奔。
|
||||||
|
- **要求**: INIT 时下发 token,两个 WS 路由按连接校验(query/header)。这是 SaaS 的**头号**阻塞项。
|
||||||
|
|
||||||
|
### S2. 无 exec 授权模型(policy.py 死代码) — High
|
||||||
|
|
||||||
|
- **位置**: LangBot `pkg/box/policy.py`(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy` 全项目无引用);`pkg/provider/tools/loaders/native.py`;`pkg/provider/tools/toolmgr.py`
|
||||||
|
- **现状**: 原生工具(`exec/read/write/edit/glob/grep`)按"box 是否可用"全有或全无地暴露,**无 per-pipeline 的 exec 网关 / 工具白名单 / 沙箱模式 / 权限提升控制**。只要 box 可用,任何使用 local-agent + 函数调用模型的 pipeline 都能跑任意 shell。
|
||||||
|
- **要求**: 接入 policy.py(或等价机制),按 pipeline 控制是否暴露 `exec`、可用工具白名单、沙箱网络/只读模式。
|
||||||
|
|
||||||
|
### S3. 会话资源无界(DoS) — High
|
||||||
|
|
||||||
|
- **#5 session 数量无上限**: SDK `box/runtime.py` `_get_or_create_session` 的 `_sessions` dict 无容量限制——可变 `session_id` 的恶意调用可无限创建容器,耗尽宿主 CPU/内存/PID/磁盘。
|
||||||
|
- **#8 无定时回收**: 过期 session 仅在 `_get_or_create_session` 时机会性清理,无独立周期任务;一波创建后转静默会永久泄漏容器。
|
||||||
|
- **要求**: `max_sessions` 上限(拒绝或 LRU),加独立周期 reaper(如 60s)。
|
||||||
|
|
||||||
|
### S4. 工作区配额无内核级限制(TOCTOU) — Med-High
|
||||||
|
|
||||||
|
- **位置**: LangBot `pkg/box/service.py` `_enforce_workspace_quota`(应用层 read-then-check);SDK 侧 `workspace_quota_mb` 仅记录/透传,无 `--storage-opt size=` 等内核/FS 限额
|
||||||
|
- **现状**: 执行前后两次检查之间存在竞态窗口;单条命令(`dd`/`fallocate`)可在检查间隙撑爆磁盘,事后检查只能补救。
|
||||||
|
- **要求**: Docker `--storage-opt size=` 做内核级限制,或 Redis 原子计数预留式配额。
|
||||||
|
|
||||||
|
### S5. 挂载校验缺口 — Med-High
|
||||||
|
|
||||||
|
- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX`;`box/backend.py` 的 `extra_mounts` 处理
|
||||||
|
- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr`、`/opt`、`/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`,**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦。
|
||||||
|
- **要求**: SDK 黑名单加入 `/`(或改白名单);`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。
|
||||||
|
|
||||||
|
### S6. 容器加固缺失 — Med
|
||||||
|
|
||||||
|
- **位置**: SDK `box/backend.py` 的 `docker run` 组装
|
||||||
|
- **现状**: 未设置 `--cap-drop=ALL`、`--security-opt=no-new-privileges`、非 root `--user`;叠加挂载 docker.sock,逃逸面偏大。
|
||||||
|
- **要求**: 默认加上上述加固 flag(需回归常用 skill 不被破坏)。
|
||||||
|
|
||||||
|
### S7. 全局锁内执行慢操作(扩展性) — Med
|
||||||
|
|
||||||
|
- **位置**: SDK `box/runtime.py` `_get_or_create_session`:`self._lock` 持有期间调用 `backend.start_session()`(`docker run` / nsjail 启动 / E2B `Sandbox.create`)
|
||||||
|
- **影响**: 冷启动(镜像拉取数秒、E2B >1s)期间串行阻塞所有并发请求——多租户负载下整个 Box runtime 停顿。降级表现是延迟而非失败。
|
||||||
|
- **要求**: 锁内只做状态检查与注册,容器创建移到锁外。
|
||||||
|
|
||||||
|
### S8. 其他硬化 / 跟进 — Low
|
||||||
|
|
||||||
|
- **#9** SDK `box/server.py` 直接读 `runtime._sessions` 私有字段、绕过锁,并发下可能读到不一致状态——应加公共访问方法。
|
||||||
|
- **#16** `pkg/provider/tools/toolmgr.py` `execute_func_call` 按优先级分发,plugin/MCP 若有同名 `exec/read/write/...` 工具会被静默遮蔽——应加命名空间或冲突告警。
|
||||||
|
- **#4** SDK `box/runtime.py` INIT/handshake 与 backend 实例化的残留竞态(仅"纯远程 WS box 先启动、LangBot 后连"场景成立;stdio/compose 路径下 config 经 env 在 spawn 时已就位,无竞态)——应在 INIT 完成前拒绝业务 action。
|
||||||
|
- **#11** `extra_mounts` 在容器创建时固定(SDK `runtime.py` 兼容性检查不含 extra_mounts);长生命周期共享 session 后续新激活的 skill 不会挂上(当前缓解:创建时挂上 pipeline 绑定的全部 skill)——动态绑定场景需销毁重建或文档说明。
|
||||||
|
- **#21** 集成测试未进 CI:容器实际执行、E2B 真机、managed-process WS attach 仅本地可跑。安全关键路径缺自动化覆盖——SaaS 前建议加 Docker-in-Docker CI stage 或合并前手动 checklist。
|
||||||
402
docs/review/box-session-scope.md
Normal file
402
docs/review/box-session-scope.md
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
# Box Session Scope Design
|
||||||
|
|
||||||
|
> Date: 2026-04-18 (last reviewed 2026-06-02)
|
||||||
|
> Status (2026-06-02): the self-hosted community edition is release-ready (box optional, clean degradation, no migration debt). Tool-call loop cap, async quota scan, and the host_path mount allowlist have landed. Remaining multi-tenant / security hardening is tracked in [box-issues.md](./box-issues.md).
|
||||||
|
> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
> Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Implementation Status (2026-05-19)
|
||||||
|
|
||||||
|
This document was authored as a design proposal. The current `feat/sandbox` branch
|
||||||
|
has shipped the design largely as written:
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `BoxMountSpec` + `BoxSpec.extra_mounts` | ✅ Shipped | SDK `box/models.py` |
|
||||||
|
| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) |
|
||||||
|
| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` |
|
||||||
|
| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` |
|
||||||
|
| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` |
|
||||||
|
| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch |
|
||||||
|
| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` |
|
||||||
|
| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` |
|
||||||
|
| Per-tenant / quota integration with templates | ❌ Not started | See [box-tob-analysis.md](./box-tob-analysis.md) |
|
||||||
|
|
||||||
|
The "Phase 2 deferred" note in §10 is **out of date** — MCP unification went in on
|
||||||
|
the same line. Pipeline-scoped (not user-scoped) MCP container is the realized
|
||||||
|
behavior: each pipeline's MCP servers share one `mcp-<pipeline>` session, and
|
||||||
|
user exec sessions use the template-derived id.
|
||||||
|
|
||||||
|
The remaining open work is multi-tenant overlays (tenant_id in session_id,
|
||||||
|
quota counters keyed by tenant), tracked in the toB analysis doc rather than here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problems
|
||||||
|
|
||||||
|
### 1.1 Default exec: per-message containers
|
||||||
|
|
||||||
|
Currently, `BoxService.execute_tool()` sets `session_id = str(query.query_id)` — an
|
||||||
|
auto-incrementing integer per incoming message. Every user message creates a new sandbox
|
||||||
|
container. Dependencies installed and in-container state are lost between messages.
|
||||||
|
|
||||||
|
### 1.2 Three isolated container pools
|
||||||
|
|
||||||
|
Default exec, skills, and MCP servers each manage their own containers with
|
||||||
|
independent session IDs:
|
||||||
|
|
||||||
|
| Path | Session ID | Container |
|
||||||
|
|--------------|-----------------------------------------------|-------------|
|
||||||
|
| Default exec | `str(query_id)` (per message) | Ephemeral |
|
||||||
|
| Skill exec | `skill-{launcher}_{id}-{skill_name}` | Per skill |
|
||||||
|
| MCP stdio | `mcp-{server_uuid}` | Per server |
|
||||||
|
|
||||||
|
This means a single logical user interaction can spawn 3+ containers that cannot
|
||||||
|
share state, see each other's files, or reuse installed dependencies.
|
||||||
|
|
||||||
|
### 1.3 Single bind mount limitation
|
||||||
|
|
||||||
|
`BoxSpec` currently supports only **one** `host_path` → `mount_path` bind mount.
|
||||||
|
This prevents mounting both a default workspace and skill directories into the
|
||||||
|
same container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Concept Model
|
||||||
|
|
||||||
|
```
|
||||||
|
Platform Message
|
||||||
|
→ Query (query_id: int, auto-increment, per message)
|
||||||
|
→ Session (launcher_type + launcher_id, per chat window)
|
||||||
|
→ Conversation (uuid, per dialogue context within a Session)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Concept | Key | Example | Scope |
|
||||||
|
|---------------|-------------------------------------|----------------------------|------------------------------|
|
||||||
|
| Query | `query_id` | `42` | Single message |
|
||||||
|
| Session | `launcher_type` + `launcher_id` | `group_123456` | Chat window (group or PM) |
|
||||||
|
| Conversation | `conversation_id` (UUID) | `a1b2c3d4-...` | Dialogue context within a Session |
|
||||||
|
| Sender | `sender_id` | `789` | Individual user |
|
||||||
|
|
||||||
|
Note: in a **group chat**, all users share the same Session (keyed by `group_id`). The
|
||||||
|
individual sender is tracked as `sender_id` but does not affect Session/Conversation routing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Target Scenarios
|
||||||
|
|
||||||
|
| # | Scenario | Box Granularity | Desired `session_id` |
|
||||||
|
|----|--------------------------------|------------------------------------------|---------------------------------------------------------|
|
||||||
|
| 1 | Personal assistant | 1 Box per user, long-lived | `{launcher_type}_{launcher_id}` |
|
||||||
|
| 2 | Customer service | 1 Box per customer, cross-pipeline | `{launcher_type}_{launcher_id}` |
|
||||||
|
| 3 | Internal employee tool | 1 Box per employee | `{launcher_type}_{launcher_id}` |
|
||||||
|
| 4 | Group chat shared assistant | 1 Box per group | `{launcher_type}_{launcher_id}` |
|
||||||
|
| 5 | Group chat isolated per user | 1 Box per user within a group | `{launcher_type}_{launcher_id}_{sender_id}` |
|
||||||
|
| 6 | Teaching (cross-channel) | 1 Box per student across groups/PMs | `{sender_id}` |
|
||||||
|
| 7 | One-off execution | 1 Box per message (current behavior) | `{query_id}` |
|
||||||
|
| 8 | Multi-project development | 1 Box per conversation context | `{launcher_type}_{launcher_id}_{conversation_id}` |
|
||||||
|
|
||||||
|
No single fixed granularity covers all scenarios. A template-based approach is needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Design Overview
|
||||||
|
|
||||||
|
Two key changes:
|
||||||
|
|
||||||
|
1. **Unified container**: exec, skills, and MCP all share the same container per
|
||||||
|
session scope. No more separate container pools.
|
||||||
|
2. **Configurable session scope**: `session_id` is generated from a template with
|
||||||
|
pipeline variables, configurable per pipeline.
|
||||||
|
|
||||||
|
### 4.1 Unified Container with Multiple Mounts
|
||||||
|
|
||||||
|
A single container per session scope is created on first use. It has:
|
||||||
|
|
||||||
|
- **Primary mount**: default workspace at `/workspace` (from `default_host_workspace`)
|
||||||
|
- **Skill mounts**: each pipeline-bound skill's `package_root` mounted at
|
||||||
|
`/workspace/.skills/{skill_name}/`
|
||||||
|
- **MCP servers**: run as managed processes inside the same container
|
||||||
|
|
||||||
|
```
|
||||||
|
Container (session_id = "group_123456")
|
||||||
|
/workspace/ ← default workspace (bind mount, rw)
|
||||||
|
/workspace/.skills/web-search/ ← skill package (bind mount, rw)
|
||||||
|
/workspace/.skills/data-analysis/ ← skill package (bind mount, rw)
|
||||||
|
[managed process: mcp-server-a] ← MCP server running inside
|
||||||
|
[managed process: mcp-server-b] ← MCP server running inside
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires extending `BoxSpec` to support multiple mounts (see §5).
|
||||||
|
|
||||||
|
### 4.2 Session ID Template
|
||||||
|
|
||||||
|
A new field `box-session-id-template` in the `local-agent` pipeline runner config
|
||||||
|
controls the session scope:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# templates/metadata/pipeline/ai.yaml (under local-agent.config)
|
||||||
|
- name: box-session-id-template
|
||||||
|
label:
|
||||||
|
en_US: Sandbox Scope
|
||||||
|
zh_Hans: 沙箱作用域
|
||||||
|
description:
|
||||||
|
en_US: >-
|
||||||
|
Determines how sandbox environments are shared. Use variables to
|
||||||
|
control isolation granularity.
|
||||||
|
zh_Hans: >-
|
||||||
|
决定沙箱环境的共享方式。使用变量控制隔离粒度。
|
||||||
|
type: select
|
||||||
|
required: false
|
||||||
|
default: "{launcher_type}_{launcher_id}"
|
||||||
|
options:
|
||||||
|
- value: "{launcher_type}_{launcher_id}"
|
||||||
|
label:
|
||||||
|
en_US: Per chat (Recommended)
|
||||||
|
zh_Hans: 每个会话(推荐)
|
||||||
|
- value: "{launcher_type}_{launcher_id}_{sender_id}"
|
||||||
|
label:
|
||||||
|
en_US: Per user in chat
|
||||||
|
zh_Hans: 会话中每个用户
|
||||||
|
- value: "{launcher_type}_{launcher_id}_{conversation_id}"
|
||||||
|
label:
|
||||||
|
en_US: Per conversation context
|
||||||
|
zh_Hans: 每个对话上下文
|
||||||
|
- value: "{query_id}"
|
||||||
|
label:
|
||||||
|
en_US: Per message (isolated)
|
||||||
|
zh_Hans: 每条消息(完全隔离)
|
||||||
|
```
|
||||||
|
|
||||||
|
Available template variables (populated by PreProcessor in `query.variables`):
|
||||||
|
|
||||||
|
| Variable | Source | Example |
|
||||||
|
|---------------------|---------------------------------|----------------------|
|
||||||
|
| `{launcher_type}` | `query.session.launcher_type` | `person` / `group` |
|
||||||
|
| `{launcher_id}` | `query.session.launcher_id` | `123456` |
|
||||||
|
| `{sender_id}` | `query.sender_id` | `789` |
|
||||||
|
| `{conversation_id}` | `conversation.uuid` | `a1b2c3d4-...` |
|
||||||
|
| `{query_id}` | `query.query_id` | `42` |
|
||||||
|
|
||||||
|
Default `{launcher_type}_{launcher_id}` covers scenarios 1–4 out of the box.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. SDK Changes: Multi-Mount BoxSpec
|
||||||
|
|
||||||
|
### 5.1 Model Extension
|
||||||
|
|
||||||
|
```python
|
||||||
|
# box/models.py
|
||||||
|
|
||||||
|
class BoxMountSpec(pydantic.BaseModel):
|
||||||
|
"""A single bind mount specification."""
|
||||||
|
host_path: str
|
||||||
|
mount_path: str
|
||||||
|
mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE
|
||||||
|
|
||||||
|
class BoxSpec(pydantic.BaseModel):
|
||||||
|
# ... existing fields ...
|
||||||
|
host_path: str | None = None # Primary mount (backward compat)
|
||||||
|
host_path_mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE
|
||||||
|
mount_path: str = DEFAULT_BOX_MOUNT_PATH
|
||||||
|
extra_mounts: list[BoxMountSpec] = [] # NEW: additional mounts
|
||||||
|
```
|
||||||
|
|
||||||
|
`extra_mounts` is additive — the existing `host_path` / `mount_path` pair remains
|
||||||
|
the primary mount for backward compatibility.
|
||||||
|
|
||||||
|
### 5.2 Backend: Apply Extra Mounts
|
||||||
|
|
||||||
|
```python
|
||||||
|
# box/backend.py — CLISandboxBackend.start_session()
|
||||||
|
|
||||||
|
# Primary mount (unchanged)
|
||||||
|
if spec.host_path is not None and spec.host_path_mode != BoxHostMountMode.NONE:
|
||||||
|
args.extend(['-v', f'{spec.host_path}:{spec.mount_path}:{spec.host_path_mode.value}'])
|
||||||
|
|
||||||
|
# Extra mounts (NEW)
|
||||||
|
for mount in spec.extra_mounts:
|
||||||
|
if mount.mode != BoxHostMountMode.NONE:
|
||||||
|
args.extend(['-v', f'{mount.host_path}:{mount.mount_path}:{mount.mode.value}'])
|
||||||
|
```
|
||||||
|
|
||||||
|
Same pattern for nsjail backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. LangBot Changes
|
||||||
|
|
||||||
|
### 6.1 Session ID Resolution
|
||||||
|
|
||||||
|
In `BoxService.execute_tool()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Before:
|
||||||
|
spec_payload.setdefault('session_id', str(query.query_id))
|
||||||
|
|
||||||
|
# After:
|
||||||
|
template = (query.pipeline_config or {}).get('ai', {}) \
|
||||||
|
.get('local-agent', {}).get('box-session-id-template',
|
||||||
|
'{launcher_type}_{launcher_id}')
|
||||||
|
variables = query.variables or {}
|
||||||
|
session_id = template.format_map(collections.defaultdict(
|
||||||
|
lambda: 'unknown', variables
|
||||||
|
))
|
||||||
|
spec_payload.setdefault('session_id', session_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Skill Exec: Use Same Container
|
||||||
|
|
||||||
|
Currently `native.py:_invoke_exec` creates a separate `BoxWorkspaceSession` per
|
||||||
|
skill with `host_path=package_root`. Instead:
|
||||||
|
|
||||||
|
1. Use the **same session_id** as default exec (from the template).
|
||||||
|
2. Pass the skill's `package_root` as an **extra mount** at
|
||||||
|
`/workspace/.skills/{skill_name}/` instead of replacing `/workspace`.
|
||||||
|
3. The container already has the default workspace at `/workspace`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# native.py — _invoke_exec, skill branch (REVISED)
|
||||||
|
|
||||||
|
# Same session_id as default exec
|
||||||
|
session_id = resolve_box_session_id(query)
|
||||||
|
|
||||||
|
spec_payload = {
|
||||||
|
'cmd': rewritten_command,
|
||||||
|
'workdir': rewritten_workdir,
|
||||||
|
'session_id': session_id,
|
||||||
|
'extra_mounts': [{
|
||||||
|
'host_path': package_root,
|
||||||
|
'mount_path': f'/workspace/.skills/{selected_skill_name}',
|
||||||
|
'mode': 'rw',
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
result = await self.ap.box_service.execute_spec_payload(spec_payload, query)
|
||||||
|
```
|
||||||
|
|
||||||
|
The virtual path `/workspace/.skills/{name}` no longer needs rewriting at the
|
||||||
|
command level — it maps directly to the bind mount path inside the container.
|
||||||
|
|
||||||
|
### 6.3 MCP: Use Same Container
|
||||||
|
|
||||||
|
MCP servers should run inside the same container as exec and skills. Changes:
|
||||||
|
|
||||||
|
1. `BoxStdioSessionRuntime` uses the pipeline's session_id template instead of
|
||||||
|
`mcp-{server_uuid}`.
|
||||||
|
2. MCP server's working directory is a subdirectory (e.g. `/workspace/.mcp/{name}/`).
|
||||||
|
3. MCP server's dependencies are mounted or installed into that subdirectory.
|
||||||
|
4. The MCP server runs as a managed process inside the shared container.
|
||||||
|
|
||||||
|
Since MCP servers start at LangBot boot (not per-query), the session must be
|
||||||
|
created eagerly. The container will be kept alive by the managed process
|
||||||
|
exemption in TTL reaping (`runtime.py:259`).
|
||||||
|
|
||||||
|
**Note**: MCP sessions are pipeline-scoped (not per-launcher), so their session_id
|
||||||
|
should be a **fixed identifier per pipeline** rather than the user-facing template.
|
||||||
|
This means one shared MCP container per pipeline, with user exec sessions separate.
|
||||||
|
|
||||||
|
Alternatively, in a future iteration, MCP managed processes could be launched
|
||||||
|
lazily into the user's container on first MCP tool call. This is more complex
|
||||||
|
but maximizes sharing. For V1, keeping MCP containers at pipeline scope is
|
||||||
|
simpler and more predictable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Mount Layout Summary
|
||||||
|
|
||||||
|
### Default exec (no skills activated)
|
||||||
|
|
||||||
|
```
|
||||||
|
Container (session_id from template)
|
||||||
|
/workspace/ ← default_host_workspace (rw)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exec with activated skills
|
||||||
|
|
||||||
|
```
|
||||||
|
Container (same session_id)
|
||||||
|
/workspace/ ← default_host_workspace (rw)
|
||||||
|
/workspace/.skills/web-search/ ← skill package_root (rw)
|
||||||
|
/workspace/.skills/data-analysis/ ← skill package_root (rw)
|
||||||
|
```
|
||||||
|
|
||||||
|
Extra mounts are **additive** — they are added when the container is first
|
||||||
|
created (or on the first exec that references a skill). Since Docker bind
|
||||||
|
mounts are specified at container creation time, skills must be known at
|
||||||
|
creation time.
|
||||||
|
|
||||||
|
**Resolution**: When creating a container, inject `extra_mounts` for **all
|
||||||
|
pipeline-bound skills** (from `extensions_preferences`), not just the
|
||||||
|
currently activated one. This way any skill can be activated later without
|
||||||
|
recreating the container.
|
||||||
|
|
||||||
|
### MCP servers (V1: pipeline-scoped)
|
||||||
|
|
||||||
|
```
|
||||||
|
Container (session_id = "mcp-pipeline-{pipeline_uuid}")
|
||||||
|
/workspace/ ← MCP shared workspace
|
||||||
|
/workspace/.mcp/server-a/ ← MCP server A files
|
||||||
|
/workspace/.mcp/server-b/ ← MCP server B files
|
||||||
|
[managed process: server-a]
|
||||||
|
[managed process: server-b]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Data Migration
|
||||||
|
|
||||||
|
Existing pipelines do not have `box-session-id-template`. The backend uses
|
||||||
|
`.get(..., default)` so missing keys fall back to `{launcher_type}_{launcher_id}`.
|
||||||
|
This changes behavior from per-message to per-launcher for existing pipelines.
|
||||||
|
|
||||||
|
Recommendation: **accept the behavior change** — per-launcher is the more
|
||||||
|
intuitive default, and the old per-message behavior was rarely desired.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Cloud Quota Implications
|
||||||
|
|
||||||
|
| Scope | Typical concurrent containers |
|
||||||
|
|-----------------------------------------------|-------------------------------|
|
||||||
|
| `{query_id}` (per message) | Many, short-lived |
|
||||||
|
| `{launcher_type}_{launcher_id}` (per chat) | = active chat count |
|
||||||
|
| `{sender_id}` (per user) | = active user count |
|
||||||
|
| `{conversation_id}` (per conversation) | Between per-chat and per-msg |
|
||||||
|
|
||||||
|
With the unified container model, each scope value maps to exactly **one**
|
||||||
|
container (instead of potentially 3+ per-message). This significantly reduces
|
||||||
|
resource usage.
|
||||||
|
|
||||||
|
Quota enforcement point: `BoxRuntime._get_or_create_session()` in the SDK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Session scope + skill unification (this PR)
|
||||||
|
|
||||||
|
1. **SDK**: Extend `BoxSpec` with `extra_mounts: list[BoxMountSpec]`.
|
||||||
|
2. **SDK**: Update Docker/nsjail backends to apply extra mounts.
|
||||||
|
3. **LangBot**: Add `box-session-id-template` to `local-agent` YAML metadata
|
||||||
|
and default pipeline config JSON.
|
||||||
|
4. **LangBot**: Update `BoxService.execute_tool()` to use template interpolation.
|
||||||
|
5. **LangBot**: Update `native.py:_invoke_exec` skill branch to use same
|
||||||
|
session_id + extra mounts instead of separate `BoxWorkspaceSession`.
|
||||||
|
6. **LangBot**: On container creation, inject extra mounts for all
|
||||||
|
pipeline-bound skills.
|
||||||
|
7. **Frontend**: No code change — `DynamicFormComponent` renders `select` fields.
|
||||||
|
8. **Tests**: Unit tests for template interpolation and multi-mount specs.
|
||||||
|
|
||||||
|
### Phase 2: MCP unification (future)
|
||||||
|
|
||||||
|
1. Refactor `BoxStdioSessionRuntime` to use pipeline-scoped shared container.
|
||||||
|
2. MCP servers become managed processes in the shared container.
|
||||||
|
3. Support multiple concurrent managed processes per container.
|
||||||
|
|
||||||
|
MCP unification is deferred because it requires changes to the managed process
|
||||||
|
model (currently 1 managed process per session) and has startup ordering
|
||||||
|
concerns (MCP servers start at boot, before any user query determines
|
||||||
|
a session_id).
|
||||||
122
docs/review/box-test-coverage.md
Normal file
122
docs/review/box-test-coverage.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Box 系统测试覆盖分析
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-02
|
||||||
|
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||||
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 测试文件清单
|
||||||
|
|
||||||
|
### LangBot 仓库
|
||||||
|
|
||||||
|
| 文件 | 行数 | CI 运行 | 覆盖范围 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| `tests/unit_tests/box/test_box_connector.py` | 106 | 是 | Connector 传输决策、WS relay URL、dispose、心跳/重连 |
|
||||||
|
| `tests/unit_tests/box/test_box_service.py` | 1224 | 是 | Service 核心逻辑(最全面) |
|
||||||
|
| `tests/unit_tests/box/test_workspace.py` | 147 | 是 | WorkspaceSession 路径重写、payload 构建 |
|
||||||
|
| `tests/unit_tests/provider/test_mcp_box_integration.py` | 707 | 是 | MCP Box 配置、路径重写、payload、shared-session/multi-process、runtime info |
|
||||||
|
| `tests/unit_tests/provider/test_localagent_sandbox_exec.py` | 444 | 是 | LocalAgent exec 流程、流式、Skill 激活 (Tool Call) |
|
||||||
|
| `tests/unit_tests/provider/test_tool_manager_native.py` | 249 | 是 | ToolManager 路由、native tool CRUD、路径穿越、6 工具暴露 |
|
||||||
|
| `tests/unit_tests/provider/test_skill_tools.py` | 582 | 是 | Skill 管理、Tool Call 激活、路径、authoring CRUD |
|
||||||
|
| `tests/unit_tests/test_skill_service.py` | 396 | 是 | HTTP service:skill CRUD、zip/GitHub install、文件浏览 |
|
||||||
|
| `tests/unit_tests/test_paths.py` | 23 | 是 | paths 工具 |
|
||||||
|
| `tests/unit_tests/test_preproc.py` | 134 | 是 | PreProcessor 注入 session 变量、bound skill 解析 |
|
||||||
|
| `tests/unit_tests/pipeline/test_chat_handler_logging.py` | 78 | 是 | Chat handler 日志相关回归 |
|
||||||
|
| `tests/integration_tests/box/test_box_integration.py` | 329 | **否** | 真实容器执行、超时、网络隔离 |
|
||||||
|
| `tests/integration_tests/box/test_box_mcp_integration.py` | 368 | **否** | Managed process、WS attach、shared-session 清理 |
|
||||||
|
|
||||||
|
### SDK 仓库
|
||||||
|
|
||||||
|
| 文件 | 行数 | CI 运行 | 覆盖范围 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| `tests/box/test_backend_selection.py` | 255 | 是 | 显式 backend / local 模式探测顺序 / 配置变更触发 reselect |
|
||||||
|
| `tests/box/test_nsjail_backend.py` | 452 | 是 | nsjail 可用性、安装版 CLI vs 容器内 CLI、session、arg 构建、资源限制 |
|
||||||
|
| `tests/box/test_e2b_backend.py` | 482 | 是 | E2B SDK mock、session 生命周期、extra_mounts 同步 |
|
||||||
|
| `tests/box/test_skill_store.py` | 88 | 是 | zip preview/install、基础 file CRUD |
|
||||||
|
|
||||||
|
**总计**: 17 个测试文件, ~6,500 行测试代码; 其中 2 个集成测试(约 700 行)在 CI 中不运行。
|
||||||
|
|
||||||
|
> 较 2026-04-16 版增加:`test_skill_service.py`、`test_paths.py`、`test_preproc.py`、`test_chat_handler_logging.py` (LangBot),`test_backend_selection.py`、`test_e2b_backend.py`、`test_skill_store.py` (SDK)。`test_nsjail_backend.py` 增加 CLI 兼容性 case (commit `feed530`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 覆盖良好的区域
|
||||||
|
|
||||||
|
| 区域 | 质量 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| BoxRuntime session 管理 | 优秀 | session 复用、冲突检测、TTL 配置、消失 session 重建 |
|
||||||
|
| BoxService Profile 系统 | 优秀 | 4 个内置 Profile、locked/unlocked 字段、timeout clamp |
|
||||||
|
| BoxService host mount 安全 | 优秀 | allowed_mount_roots、disallowed_roots、shared host root |
|
||||||
|
| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 |
|
||||||
|
| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr |
|
||||||
|
| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 |
|
||||||
|
| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 |
|
||||||
|
| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error |
|
||||||
|
| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 |
|
||||||
|
| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file |
|
||||||
|
| BoxHostMountMode.NONE | 良好 | 枚举校验、workdir 约束 |
|
||||||
|
| NsjailBackend | 良好 | 可用性、安装版 vs 容器内、session 生命周期、arg 构建、资源限制 |
|
||||||
|
| E2BBackend | 良好 | mock SDK、session/extra_mounts 同步 |
|
||||||
|
| Backend selection | 良好 | 显式 backend 优先级、local 探测顺序、配置变更触发 reselect |
|
||||||
|
| MCP Box 集成 | 良好 | config model、路径重写、payload、shared-session 多 process |
|
||||||
|
| Native tool loader | 良好 | 6 工具(exec/read/write/edit/glob/grep)、路径穿越拦截 |
|
||||||
|
| LocalAgent exec 流程 | 良好 | 完整 tool call 循环、流式、system prompt 注入、Tool Call 激活 |
|
||||||
|
| Skill 系统 | 良好 | 加载、Tool Call 激活、marker、路径解析、authoring CRUD、HTTP service |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 覆盖缺失的区域
|
||||||
|
|
||||||
|
### 3.1 零测试 / 严重不足
|
||||||
|
|
||||||
|
| 区域 | 源文件 | 影响 |
|
||||||
|
|------|--------|------|
|
||||||
|
| **`security.py`** | SDK `box/security.py` (52 行) | `validate_sandbox_security()` 无任何测试。阻止 `/etc`/`/proc`/Docker socket 等危险挂载的安全函数从未被验证 |
|
||||||
|
| **`policy.py`** | `pkg/box/policy.py` (98 行) | 三层安全策略无测试(也是死代码) |
|
||||||
|
| **`skill_store.py` 边缘场景** | SDK `box/skill_store.py` (647 行) vs 测试 88 行 | GitHub 安装路径、`source_subdir` / `target_suffix` 组合、损坏 zip、文件冲突等场景未覆盖 |
|
||||||
|
|
||||||
|
### 3.2 未测试的关键路径
|
||||||
|
|
||||||
|
| 区域 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **Session TTL 过期** | 测试配置了 `session_ttl_sec` 但从未推进时间验证过期清理 |
|
||||||
|
| **并发 session 访问** | 无并发 exec / 并发创建 / race condition 测试 |
|
||||||
|
| **Container backend (Docker)** | 仅通过集成测试覆盖(CI 不运行),单元测试全用 FakeBackend |
|
||||||
|
| **E2B 真实 sandbox** | 单测全是 mock,未对接真实 E2B API |
|
||||||
|
| **BoxRuntime shutdown()** | 在 test cleanup 中调用但未验证行为 |
|
||||||
|
| **BoxServerHandler 错误路径** | 畸形请求、未知 action 类型 |
|
||||||
|
| **WS relay** | 仅在集成测试中覆盖(CI 不运行) |
|
||||||
|
| **NsjailBackend managed process** | 完全未测试 |
|
||||||
|
| **MCP stdio 完整生命周期** | 依赖安装 → 进程启动 → 健康检查 → 多 process 并发 → 重试 |
|
||||||
|
| **BoxService start/stop_managed_process** | 单 process 流转有单测,多 process 互不阻塞主要靠集成测试 |
|
||||||
|
| **重连指数退避** | connector 单测覆盖回调接线,未实际跑完整重连周期 |
|
||||||
|
|
||||||
|
### 3.3 边缘情况缺失
|
||||||
|
|
||||||
|
| 区域 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| BoxSpec 校验 | 无效 session_id 格式、超长命令、env 特殊字符 |
|
||||||
|
| BoxSpec.extra_mounts | 重复 mount_path、与 host_path 冲突、绝对 vs 相对路径 |
|
||||||
|
| BoxExecutionResult | 仅 COMPLETED 和 TIMED_OUT,无 ERROR 状态测试 |
|
||||||
|
| 多后端 fallback | local 模式探测顺序仅靠 mock,无真实 Docker 不可用 → nsjail 真机 fallback 测试 |
|
||||||
|
| Profile YAML 加载 | 测试用硬编码字符串,未从真实 config.yaml 加载 |
|
||||||
|
| INIT 配置变更触发 backend 重建 | 单测仅在初始化场景验证 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 集成测试 vs CI 的差距
|
||||||
|
|
||||||
|
CI 仅运行 `tests/unit_tests/`,以下场景**从未在自动化中验证**:
|
||||||
|
|
||||||
|
- 真实容器的创建/执行/销毁
|
||||||
|
- 容器网络隔离(`--network none`)
|
||||||
|
- 容器资源限制生效(cpus/memory/pids_limit)
|
||||||
|
- Managed process 的 WS 双向 I/O
|
||||||
|
- 多 process 同 session 并发 I/O
|
||||||
|
- 孤儿容器清理
|
||||||
|
- Session 删除清理容器
|
||||||
|
- 进程退出检测
|
||||||
|
- E2B 真实 sandbox 行为
|
||||||
|
|
||||||
|
**建议**: 在 CI 中加一个可选的 Docker-in-Docker 集成测试 stage,至少覆盖核心执行路径(exec / MCP attach / session 销毁)。
|
||||||
167
docs/review/box-tob-analysis.md
Normal file
167
docs/review/box-tob-analysis.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Box 系统 toB 商业化分析
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-02
|
||||||
|
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||||
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 现有优势
|
||||||
|
|
||||||
|
| 能力 | toB 价值 | 代码位置 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **沙箱隔离执行** | 企业安全运行不受信代码的基础能力 | SDK `box/backend.py` |
|
||||||
|
| **多后端支持** | 适配不同企业容器基础设施 (Podman/Docker/nsjail/E2B) | SDK `box/runtime.py` `_select_backend()` |
|
||||||
|
| **E2B 云沙箱** | SaaS / 无 Docker 部署的兜底执行环境 | SDK `box/e2b_backend.py` |
|
||||||
|
| **连接自愈** | 心跳 + 自动重连,单点 Box runtime 故障可恢复 | `pkg/box/connector.py` `_heartbeat_loop`, `pkg/box/service.py` `_reconnect_loop` |
|
||||||
|
| **Profile + locked 字段** | 运维锁定安全边界,LLM/用户无法绕过 | `pkg/box/service.py`, SDK `box/models.py` |
|
||||||
|
| **资源限制** | CPU/内存/PID 数限制防止资源滥用 | SDK `backend.py` `--cpus/--memory/--pids-limit` |
|
||||||
|
| **Workspace quota** | 磁盘用量控制 | `pkg/box/service.py` `_enforce_workspace_quota` |
|
||||||
|
| **静默降级** | Box 不可用不影响其他功能,降低部署门槛 | `pkg/box/service.py:78` `_available=False` |
|
||||||
|
| **孤儿容器清理** | 防止泄漏的容器持续占用资源 | SDK `backend.py` `cleanup_orphaned_containers` |
|
||||||
|
| **网络隔离** | `--network none` 防止数据外泄 | SDK `backend.py` start_session |
|
||||||
|
| **只读根文件系统** | `--read-only` 防止容器被持久篡改 | SDK `backend.py` start_session |
|
||||||
|
| **Host path 白名单** | `allowed_host_mount_roots` 限制可挂载目录 | `pkg/box/service.py` `_validate_host_mount` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. toB 差距分析
|
||||||
|
|
||||||
|
### 2.1 安全与合规
|
||||||
|
|
||||||
|
| 维度 | 现状 | toB 要求 | 优先级 |
|
||||||
|
|------|------|---------|--------|
|
||||||
|
| **WS relay 认证** | 无认证,任何人可 attach | 至少 token 认证 | **P0** |
|
||||||
|
| **安全策略** | policy.py 是死代码,实际无细粒度控制 | 工具级 allow/deny、沙箱模式控制 | **P0** |
|
||||||
|
| **审计日志** | 仅内存中 50 条 `_recent_errors` | 持久化审计:谁何时执行了什么、结果如何 | **P0** |
|
||||||
|
| **Host path 校验** | 黑名单策略,`/` 未拦截 | 白名单策略,默认拒绝 | **P1** |
|
||||||
|
| **数据驻留** | 无控制 | GDPR / 等保要求的数据隔离 | **P2** |
|
||||||
|
|
||||||
|
### 2.2 多租户
|
||||||
|
|
||||||
|
| 维度 | 现状 | toB 要求 | 优先级 |
|
||||||
|
|------|------|---------|--------|
|
||||||
|
| **租户隔离** | 无租户概念 | BoxSpec/Profile 绑定 tenant_id | **P0** |
|
||||||
|
| **RBAC** | 仅 token 认证 | admin/operator/viewer 角色权限 | **P0** |
|
||||||
|
| **资源配额** | 单一 workspace quota | 每租户 CPU 时间/内存/并发/执行次数配额 | **P1** |
|
||||||
|
| **Session 隔离** | 所有 session 共享 dict | 按租户分区,互不可见 | **P1** |
|
||||||
|
|
||||||
|
### 2.3 可靠性
|
||||||
|
|
||||||
|
| 维度 | 现状 | toB 要求 | 优先级 |
|
||||||
|
|------|------|---------|--------|
|
||||||
|
| **连接恢复** | 已实现:20s 心跳 + `_reconnect_loop` 指数退避 | 已满足基本要求 | 已有 |
|
||||||
|
| **Session 清理** | 机会性(仅新建时触发) | 定时清理 + 独立 reaper | **P1** |
|
||||||
|
| **水平扩展** | 单 Box Runtime 实例 | 多实例负载均衡(按 tenant 路由) | **P1** |
|
||||||
|
| **优雅降级** | 已有(_available=False) | 已满足基本要求 | 已有 |
|
||||||
|
| **Backend 自愈** | 已实现:`get_status` 时若 backend 不可用会重新选择 | 已满足基本要求 | 已有 |
|
||||||
|
|
||||||
|
### 2.4 可观测性
|
||||||
|
|
||||||
|
| 维度 | 现状 | toB 要求 | 优先级 |
|
||||||
|
|------|------|---------|--------|
|
||||||
|
| **监控指标** | 无 Prometheus metrics | session 数/执行延迟/资源用量/错误率 | **P1** |
|
||||||
|
| **结构化日志** | Python logging, 无结构化 | JSON 格式日志,含 trace_id/tenant_id | **P1** |
|
||||||
|
| **前端面板** | 监控页接入 `/api/v1/box/status`(backend 名 + 活跃 session 数);`sessions` / `errors` 仍未接入 | 完整状态面板 + 历史错误/审计列表 | **P2** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. SaaS 部署架构建议
|
||||||
|
|
||||||
|
### 3.1 方案 A: 共享 Box Runtime Pool (快速上线)
|
||||||
|
|
||||||
|
```
|
||||||
|
LangBot Instance ──> Box Runtime (共享)
|
||||||
|
├─ tenant_id 标签隔离
|
||||||
|
├─ Redis 配额计数器
|
||||||
|
└─ Container labels: langbot.tenant_id=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
- **优点**: 改动最小,加 tenant_id 到 BoxSpec/labels 即可
|
||||||
|
- **缺点**: 容器引擎共享,安全隔离弱
|
||||||
|
|
||||||
|
### 3.2 方案 B: 每租户 K8s Namespace + gVisor (推荐中期)
|
||||||
|
|
||||||
|
```
|
||||||
|
LangBot ──> K8s API
|
||||||
|
├─ namespace: tenant-xxx
|
||||||
|
│ ├─ RuntimeClass: gVisor (runsc)
|
||||||
|
│ ├─ ResourceQuota
|
||||||
|
│ └─ NetworkPolicy
|
||||||
|
└─ namespace: tenant-yyy
|
||||||
|
└─ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- **优点**: 强隔离(namespace + gVisor),原生 K8s 配额
|
||||||
|
- **缺点**: 需要重写 backend 为 K8s Job,部署复杂度高
|
||||||
|
|
||||||
|
### 3.3 方案 C: K8s Job 直接编排 (长期)
|
||||||
|
|
||||||
|
```
|
||||||
|
LangBot ──> K8s Job per execution
|
||||||
|
├─ 每次执行创建 Job
|
||||||
|
├─ Pod Security Standards
|
||||||
|
├─ 自动调度和资源分配
|
||||||
|
└─ Job TTL Controller 自动清理
|
||||||
|
```
|
||||||
|
|
||||||
|
- **优点**: 最强隔离,天然水平扩展
|
||||||
|
- **缺点**: 冷启动延迟,架构重写
|
||||||
|
|
||||||
|
**推荐演进路径**: A → B → C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 配额体系建议
|
||||||
|
|
||||||
|
### 三层配额
|
||||||
|
|
||||||
|
| 层 | 实现 | 作用 |
|
||||||
|
|----|------|------|
|
||||||
|
| **内核层** | Docker `--cpus`/`--memory`/`--storage-opt` | 硬性资源上限,不可绕过 |
|
||||||
|
| **应用层** | Redis 原子计数器 | 并发 session 数/执行次数/CPU 时间预算 |
|
||||||
|
| **计费层** | 月度聚合 | 按租户计费(session-hours/execution-count) |
|
||||||
|
|
||||||
|
### Profile 与套餐映射
|
||||||
|
|
||||||
|
| 套餐 | Profile | locked 字段 | 配额 |
|
||||||
|
|------|---------|------------|------|
|
||||||
|
| Free | `offline_readonly` | network, host_path_mode, rootfs | 10 exec/天, 0.5 CPU, 256MB |
|
||||||
|
| Pro | `default` | (无) | 100 exec/天, 1 CPU, 512MB |
|
||||||
|
| Enterprise | `network_extended` | (按需) | 无限, 2 CPU, 1GB, 自定义镜像 |
|
||||||
|
|
||||||
|
### TOCTOU 配额修复
|
||||||
|
|
||||||
|
当前 `_enforce_workspace_quota` 的 TOCTOU 问题可通过两种方式解决:
|
||||||
|
|
||||||
|
1. **预留式配额** (应用层): Redis `INCRBY` 预扣额度 → 执行 → 成功则扣减,失败则回滚
|
||||||
|
2. **内核级限制** (Docker): `--storage-opt size=500m` 直接限制容器可写层大小
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 优先实施路线
|
||||||
|
|
||||||
|
### Phase 1 (2-4 周): 安全基线
|
||||||
|
|
||||||
|
- [ ] WS relay 加 token 认证
|
||||||
|
- [ ] 接入或删除 policy.py
|
||||||
|
- [x] ~~Box 加重连和心跳~~(已完成,见 [box-issues.md 已解决](./box-issues.md))
|
||||||
|
- [ ] 审计日志持久化(至少写文件/数据库)
|
||||||
|
- [ ] `security.py` 加 `/` 拦截,考虑白名单
|
||||||
|
- [ ] INIT 与 backend 初始化顺序整理(避免 backend 在配置到达前实例化)
|
||||||
|
|
||||||
|
### Phase 2 (4-8 周): 多租户基础
|
||||||
|
|
||||||
|
- [ ] BoxSpec 加 `tenant_id` 字段
|
||||||
|
- [ ] 容器 labels 加 tenant 标识
|
||||||
|
- [ ] Redis 配额计数器(并发/执行次数/时间)
|
||||||
|
- [ ] RBAC 基础框架
|
||||||
|
- [ ] 定时 session reaper
|
||||||
|
|
||||||
|
### Phase 3 (8-16 周): 生产就绪
|
||||||
|
|
||||||
|
- [ ] Prometheus metrics exporter
|
||||||
|
- [ ] 前端 Box 状态面板
|
||||||
|
- [ ] K8s backend 支持 (方案 B)
|
||||||
|
- [ ] 结构化日志 (JSON, trace_id)
|
||||||
|
- [ ] 水平扩展支持
|
||||||
222
docs/review/box-vs-plugin-runtime.md
Normal file
222
docs/review/box-vs-plugin-runtime.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Box Runtime vs Plugin Runtime: 连接架构对比
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-02
|
||||||
|
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||||
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总体差异
|
||||||
|
|
||||||
|
| 维度 | Plugin Runtime | Box Runtime |
|
||||||
|
|------|---------------|-------------|
|
||||||
|
| **继承关系** | `PluginRuntimeConnector(ManagedRuntimeConnector)` | `BoxRuntimeConnector`(独立类) |
|
||||||
|
| **传输分支** | 3 条 (Docker/WS, Win32/subprocess+WS, Unix/stdio) | 3 条 (本地 stdio, Win32/subprocess+WS, 远程 WS) |
|
||||||
|
| **心跳** | 20s ping loop | 20s ping loop(`_heartbeat_loop`) |
|
||||||
|
| **重连** | WS 模式: sleep 3s → re-initialize | 由 BoxService `_reconnect_loop` 处理,指数退避 |
|
||||||
|
| **Handler 类型** | `RuntimeConnectionHandler` (1132 行, 25+ action) | 基础 `Handler` + `BoxServerHandler`(SDK 端 25 action) |
|
||||||
|
| **Client 抽象** | Handler 即 API | 独立 `ActionRPCBoxClient` 封装 Handler |
|
||||||
|
| **启用/禁用** | `is_enable_plugin` 开关 | 无开关(可用/不可用由初始化结果决定) |
|
||||||
|
| **初始化失败** | 异常上抛 | 静默降级 `_available=False` |
|
||||||
|
| **Shutdown** | 直接杀进程 | RPC SHUTDOWN → 清理容器 → 再杀进程 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 传输决策
|
||||||
|
|
||||||
|
### Plugin: 3-路决策
|
||||||
|
|
||||||
|
```python
|
||||||
|
# pkg/plugin/connector.py:106-165
|
||||||
|
if get_platform() == 'docker' or use_websocket_to_connect_plugin_runtime():
|
||||||
|
# Docker/WS → ws://langbot_plugin_runtime:5400/control/ws
|
||||||
|
elif get_platform() == 'win32':
|
||||||
|
# Windows → 起子进程(无 pipe) + ws://localhost:5400/control/ws
|
||||||
|
else:
|
||||||
|
# Unix/Mac → StdioClientController(python -m langbot_plugin.cli rt -s)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Box: 3-路决策
|
||||||
|
|
||||||
|
```python
|
||||||
|
# pkg/box/connector.py
|
||||||
|
if self._uses_websocket():
|
||||||
|
if platform.get_platform() == 'win32' and not self.configured_runtime_url:
|
||||||
|
await self._start_subprocess_then_ws() # subprocess + ws://localhost:5410/rpc/ws
|
||||||
|
else:
|
||||||
|
await self._connect_remote_ws() # ws://{host}:5410/rpc/ws
|
||||||
|
else:
|
||||||
|
await self._start_local_stdio() # StdioClientController
|
||||||
|
```
|
||||||
|
|
||||||
|
> 历史:2026-04-16 版本本文档曾把 Box 描述为 2 路决策(缺 Windows 分支)。现已对齐 Plugin 的 3 路设计。
|
||||||
|
|
||||||
|
### 决策矩阵
|
||||||
|
|
||||||
|
| 环境 | Plugin | Box |
|
||||||
|
|------|--------|-----|
|
||||||
|
| Docker | WS → `:5400` | WS → `:5410/rpc/ws` |
|
||||||
|
| `--standalone-box` | N/A | WS → `localhost:5410/rpc/ws` |
|
||||||
|
| Windows 非 Docker | subprocess + WS (`:5400`) | subprocess + WS (`localhost:5410/rpc/ws`) |
|
||||||
|
| Unix/Mac 非 Docker | stdio | stdio |
|
||||||
|
| 手动配置 URL | 通过配置项 | WS → 用户配置的 URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 连接建立
|
||||||
|
|
||||||
|
### 同步模式差异
|
||||||
|
|
||||||
|
**Plugin**: `new_connection_callback` 内直接 ping + await handler_task,`initialize()` 通过 `create_task()` 异步启动,不阻塞等待连接。
|
||||||
|
|
||||||
|
**Box**: 使用 `asyncio.Event` + `wait_for(timeout=30s)` 模式,`initialize()` 同步等待连接成功或超时。
|
||||||
|
|
||||||
|
### Box stdio 路径
|
||||||
|
|
||||||
|
```
|
||||||
|
connector._start_local_stdio()
|
||||||
|
├─ connected = asyncio.Event()
|
||||||
|
├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', N])
|
||||||
|
├─ _ctrl_task = create_task(ctrl.run(callback))
|
||||||
|
│ callback:
|
||||||
|
│ handler = Handler(connection) ← 基础 Handler, 无 disconnect_callback
|
||||||
|
│ client.set_handler(handler)
|
||||||
|
│ _handler_task = create_task(handler.run())
|
||||||
|
│ call_action(PING, {}) ← 握手, timeout=15s
|
||||||
|
│ connected.set() ← 通知外层
|
||||||
|
│ await _handler_task ← 阻塞直到断开
|
||||||
|
└─ await wait_for(connected.wait(), 30s) ← 同步等待
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin stdio 路径
|
||||||
|
|
||||||
|
```
|
||||||
|
connector.initialize()
|
||||||
|
├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli', 'rt', '-s'])
|
||||||
|
├─ task = ctrl.run(callback)
|
||||||
|
│ callback:
|
||||||
|
│ disconnect_callback:
|
||||||
|
│ [WS] → runtime_disconnect_callback → 重连
|
||||||
|
│ [stdio] → 仅日志, 不重连
|
||||||
|
│ handler = RuntimeConnectionHandler(conn, disconnect_cb, ap)
|
||||||
|
│ create_task(handler.run())
|
||||||
|
│ handler.ping() ← 握手, timeout=10s
|
||||||
|
│ await handler_task ← 阻塞直到断开
|
||||||
|
├─ create_task(heartbeat_loop()) ← 20s ping loop
|
||||||
|
└─ create_task(task) ← 不等待连接
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 心跳与重连
|
||||||
|
|
||||||
|
### 心跳
|
||||||
|
|
||||||
|
| 维度 | Plugin | Box |
|
||||||
|
|------|--------|-----|
|
||||||
|
| 有心跳? | 是 | 是(`connector.py` `_heartbeat_loop`) |
|
||||||
|
| 间隔 | 20s | 20s |
|
||||||
|
| 失败处理 | 仅 DEBUG 日志,不触发重连 | 仅 DEBUG 日志,依赖 connection close 触发重连 |
|
||||||
|
| 生命周期 | 整个应用生命周期 | 连接建立后启动;`dispose()` 时 cancel |
|
||||||
|
|
||||||
|
### 重连
|
||||||
|
|
||||||
|
| 维度 | Plugin | Box |
|
||||||
|
|------|--------|-----|
|
||||||
|
| Docker/WS 断开 | `runtime_disconnect_callback` → sleep 3s → re-initialize | `runtime_disconnect_callback` → `BoxService._reconnect_loop()`(指数退避) |
|
||||||
|
| WS 连接失败 | 同上 | 同上;初次失败时 `_available=False`,重连成功后恢复 |
|
||||||
|
| stdio 断开 | 仅日志,不重连 | 接同样回调;stdio 重连需重新 fork 子进程 |
|
||||||
|
| 重连退避 | 固定 3s,无 backoff | 指数退避 |
|
||||||
|
|
||||||
|
> 历史:2026-04-16 版本本文档曾把心跳与重连标记为 Box 缺失。这两项已在 commit `2dfd9d5d` / `c6882cf` / `5029d9c` 等修复(详见 [box-issues.md 已解决](./box-issues.md))。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 共享 IO 层
|
||||||
|
|
||||||
|
两者复用同一套 SDK IO 基础设施:
|
||||||
|
|
||||||
|
```
|
||||||
|
Handler ← ABC (runtime/io/handler.py)
|
||||||
|
├── RuntimeConnectionHandler (Plugin 用, LangBot 侧)
|
||||||
|
├── ControlConnectionHandler (Plugin 用, SDK 侧)
|
||||||
|
├── BoxServerHandler (Box 用, SDK 侧)
|
||||||
|
└── 匿名 Handler 实例 (Box 用, LangBot 侧)
|
||||||
|
|
||||||
|
Connection ← ABC
|
||||||
|
├── StdioConnection (stdio: 16KB chunks, 应用层分帧协议)
|
||||||
|
└── WebSocketConnection (WS: 64KB chunks, 原生 WS 分帧)
|
||||||
|
|
||||||
|
Controller ← ABC
|
||||||
|
├── StdioClientController (fork 子进程, pipe stdin/stdout)
|
||||||
|
├── StdioServerController (接管当前进程 stdin/stdout)
|
||||||
|
├── WebSocketClientController (连接 WS 服务端)
|
||||||
|
└── WebSocketServerController (监听 WS 端口)
|
||||||
|
```
|
||||||
|
|
||||||
|
共享的核心机制:
|
||||||
|
- `call_action()` / `call_action_generator()` — RPC 调用/流式调用
|
||||||
|
- `ActionRequest` / `ActionResponse` — 请求/响应协议
|
||||||
|
- `seq_id` 关联 — 并发请求复用单连接
|
||||||
|
- `CommonAction.PING` — 两者都用于初始握手
|
||||||
|
- 文件传输 (`send_file`) — Plugin 用,Box 不用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 端口方案
|
||||||
|
|
||||||
|
| 服务 | Plugin | Box |
|
||||||
|
|------|--------|-----|
|
||||||
|
| Action RPC (stdio) | stdin/stdout | stdin/stdout |
|
||||||
|
| Action RPC (WS) | `:5400` | `:5410/rpc/ws` |
|
||||||
|
| 辅助服务 | debug WS `:5401` | managed process WS relay `:5410/v1/sessions/{id}/managed-process/ws` |
|
||||||
|
|
||||||
|
**Box 特点**: 单端口 aiohttp 服务(默认 5410),通过路径区分 Action RPC 和 managed process relay。即使在 stdio 模式,也在 `:5410` 启动 aiohttp 用于 managed process attach。Plugin 在 stdio 模式不开额外端口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 销毁对比
|
||||||
|
|
||||||
|
### Plugin
|
||||||
|
|
||||||
|
```python
|
||||||
|
dispose():
|
||||||
|
if stdio: ctrl.process.terminate()
|
||||||
|
_dispose_subprocess() # Windows 子进程
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Box
|
||||||
|
|
||||||
|
```python
|
||||||
|
connector.dispose():
|
||||||
|
_handler_task.cancel()
|
||||||
|
_ctrl_task.cancel()
|
||||||
|
_subprocess.terminate()
|
||||||
|
|
||||||
|
service.dispose():
|
||||||
|
connector.dispose()
|
||||||
|
loop.create_task(client.shutdown()) # RPC SHUTDOWN → 清理所有容器
|
||||||
|
```
|
||||||
|
|
||||||
|
Box 的 RPC SHUTDOWN 确保容器被正确停止,不会成为孤儿。Plugin 直接杀进程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 改进建议
|
||||||
|
|
||||||
|
### P0
|
||||||
|
|
||||||
|
1. **两者都加 WS 认证**: 至少 token 认证(INIT 时下发,连接时校验)
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
2. **考虑 Box 继承 ManagedRuntimeConnector**: 复用 `_start_runtime_subprocess` / `_wait_until_ready` / `_dispose_subprocess`,减少重复代码
|
||||||
|
3. **Plugin 重连加退避**: 固定 3s 无 backoff 可能造成日志洪水,建议向 Box 的指数退避看齐
|
||||||
|
4. **统一连接管理模式**: Event-based (Box) vs direct-await (Plugin),考虑收敛为一种
|
||||||
|
|
||||||
|
### 已完成(自上一轮)
|
||||||
|
|
||||||
|
- ~~Box 加重连~~(commit `2dfd9d5d`)
|
||||||
|
- ~~Box 加心跳~~(20s loop 与 Plugin 一致)
|
||||||
|
- ~~Box 加 Windows 支持~~(commit `120817a` / `fafb7a4`)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.4"
|
version = "4.10.0"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -8,7 +8,7 @@ requires-python = ">=3.11,<4.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aiocqhttp>=1.4.4",
|
"aiocqhttp>=1.4.4",
|
||||||
"aiofiles>=24.1.0",
|
"aiofiles>=24.1.0",
|
||||||
"aiohttp>=3.11.18",
|
"aiohttp>=3.14.0",
|
||||||
"aioshutil>=1.5",
|
"aioshutil>=1.5",
|
||||||
"aiosqlite>=0.21.0",
|
"aiosqlite>=0.21.0",
|
||||||
"anthropic>=0.51.0",
|
"anthropic>=0.51.0",
|
||||||
@@ -16,40 +16,42 @@ dependencies = [
|
|||||||
"async-lru>=2.0.5",
|
"async-lru>=2.0.5",
|
||||||
"certifi>=2025.4.26",
|
"certifi>=2025.4.26",
|
||||||
"colorlog~=6.6.0",
|
"colorlog~=6.6.0",
|
||||||
"cryptography>=44.0.3",
|
"cryptography>=46.0.7",
|
||||||
"dashscope>=1.25.10",
|
"dashscope>=1.25.10",
|
||||||
"dingtalk-stream>=0.24.0",
|
"dingtalk-stream>=0.24.0",
|
||||||
"discord-py>=2.5.2",
|
"discord-py>=2.5.2",
|
||||||
"pynacl>=1.5.0", # Required for Discord voice support
|
"pynacl>=1.5.0", # Required for Discord voice support
|
||||||
"gewechat-client>=0.1.5",
|
"gewechat-client>=0.1.5",
|
||||||
"lark-oapi>=1.4.15",
|
"lark-oapi>=1.5.5",
|
||||||
"mcp>=1.25.0",
|
"mcp>=1.25.0",
|
||||||
"nakuru-project-idk>=0.0.2.1",
|
"nakuru-project-idk>=0.0.2.1",
|
||||||
"ollama>=0.4.8",
|
"ollama>=0.4.8",
|
||||||
"openai>1.0.0",
|
"openai>1.0.0",
|
||||||
"pillow>=11.2.1",
|
"pillow>=12.2.0",
|
||||||
"psutil>=7.0.0",
|
"psutil>=7.0.0",
|
||||||
"pycryptodome>=3.22.0",
|
"pycryptodome>=3.22.0",
|
||||||
"pydantic>2.0",
|
"pydantic>2.0",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.12.0",
|
||||||
"python-telegram-bot>=22.0",
|
"python-telegram-bot>=22.0",
|
||||||
"pyyaml>=6.0.2",
|
"pyyaml>=6.0.2",
|
||||||
"qq-botpy-rc>=1.2.1.6",
|
"qq-botpy-rc>=1.2.1.6",
|
||||||
|
"qrcode>=7.4",
|
||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.33.0",
|
||||||
"slack-sdk>=3.35.0",
|
"slack-sdk>=3.35.0",
|
||||||
|
"alembic>=1.15.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.40",
|
"sqlalchemy[asyncio]>=2.0.40",
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
"telegramify-markdown>=0.5.1",
|
"telegramify-markdown>=0.5.1",
|
||||||
"tiktoken>=0.9.0",
|
"tiktoken>=0.9.0",
|
||||||
"urllib3>=2.4.0",
|
"urllib3>=2.7.0",
|
||||||
"websockets>=15.0.1",
|
"websockets>=15.0.1",
|
||||||
"python-socks>=2.7.1", # dingtalk missing dependency
|
"python-socks>=2.7.1", # dingtalk missing dependency
|
||||||
"pip>=25.1.1",
|
"pip>=26.1",
|
||||||
"ruff>=0.11.9",
|
"ruff>=0.11.9",
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"uv>=0.7.11",
|
"uv>=0.11.15",
|
||||||
"mypy>=1.16.0",
|
"mypy>=1.16.0",
|
||||||
"PyPDF2>=3.0.1",
|
"PyPDF2>=3.0.1",
|
||||||
"python-docx>=1.1.0",
|
"python-docx>=1.1.0",
|
||||||
@@ -60,13 +62,18 @@ dependencies = [
|
|||||||
"ebooklib>=0.18",
|
"ebooklib>=0.18",
|
||||||
"html2text>=2024.2.26",
|
"html2text>=2024.2.26",
|
||||||
"langchain>=0.2.0",
|
"langchain>=0.2.0",
|
||||||
"langchain-text-splitters>=0.0.1",
|
"langchain-core>=1.3.3",
|
||||||
|
"langsmith>=0.8.0",
|
||||||
|
"python-multipart>=0.0.27",
|
||||||
|
"Mako>=1.3.12",
|
||||||
|
"langchain-text-splitters>=1.1.2",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.5",
|
"langbot-plugin==0.4.1",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
|
"matrix-nio>=0.25.2",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
"boto3>=1.35.0",
|
"boto3>=1.35.0",
|
||||||
"pymilvus>=2.6.4",
|
"pymilvus>=2.6.4",
|
||||||
@@ -111,12 +118,13 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"moto>=5.2.1",
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"pytest>=8.4.1",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.0.0",
|
"pytest-asyncio>=1.0.0",
|
||||||
"pytest-cov>=7.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
"ruff>=0.11.9",
|
"ruff>=0.11.9",
|
||||||
@@ -215,4 +223,3 @@ skip-magic-trailing-comma = false
|
|||||||
|
|
||||||
# Like Black, automatically detect the appropriate line ending.
|
# Like Black, automatically detect the appropriate line ending.
|
||||||
line-ending = "auto"
|
line-ending = "auto"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ python_files = test_*.py
|
|||||||
python_classes = Test*
|
python_classes = Test*
|
||||||
python_functions = test_*
|
python_functions = test_*
|
||||||
|
|
||||||
|
# Python path for imports
|
||||||
|
pythonpath = . tests
|
||||||
|
|
||||||
# Test paths
|
# Test paths
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
|
|
||||||
@@ -22,7 +25,9 @@ markers =
|
|||||||
asyncio: mark test as async
|
asyncio: mark test as async
|
||||||
unit: mark test as unit test
|
unit: mark test as unit test
|
||||||
integration: mark test as integration test
|
integration: mark test as integration test
|
||||||
|
smoke: mark test as smoke test
|
||||||
slow: mark test as slow running
|
slow: mark test as slow running
|
||||||
|
e2e: mark test as end-to-end test (requires real LangBot process)
|
||||||
|
|
||||||
# Coverage options (when using pytest-cov)
|
# Coverage options (when using pytest-cov)
|
||||||
[coverage:run]
|
[coverage:run]
|
||||||
|
|||||||
65
scripts/test-coverage.sh
Executable file
65
scripts/test-coverage.sh
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Coverage gate script
|
||||||
|
# Runs all tests with coverage, enforcing minimum coverage threshold
|
||||||
|
# Uses separate pytest invocations to avoid sys.modules pollution between test types
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== LangBot Coverage Gate ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Coverage threshold (baseline from current coverage, conservative buffer)
|
||||||
|
# Current: ~22.14%, threshold: 18%
|
||||||
|
COVERAGE_THRESHOLD=18
|
||||||
|
|
||||||
|
# Create temporary directory for coverage files
|
||||||
|
COV_DIR=$(mktemp -d)
|
||||||
|
trap "rm -rf $COV_DIR" EXIT
|
||||||
|
|
||||||
|
echo "[1/3] Running unit + smoke tests with coverage..."
|
||||||
|
uv run pytest tests/unit_tests/ tests/smoke/ \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=json:$COV_DIR/unit.json \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
-q --tb=short
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[2/3] Running fast integration tests with coverage..."
|
||||||
|
uv run pytest tests/integration/ -m "not slow" \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=json:$COV_DIR/integration.json \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
-q --tb=short
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[3/3] Combining coverage reports..."
|
||||||
|
# Use coverage combine if available, otherwise just report total
|
||||||
|
if command -v coverage &> /dev/null; then
|
||||||
|
# Combine JSON reports
|
||||||
|
coverage combine --keep $COV_DIR/unit.json $COV_DIR/integration.json \
|
||||||
|
--data-file=$COV_DIR/combined.data 2>/dev/null || true
|
||||||
|
|
||||||
|
coverage report --data-file=$COV_DIR/combined.data || true
|
||||||
|
else
|
||||||
|
echo "Note: coverage combine not available, showing individual reports above"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate final XML report for CI (from last run)
|
||||||
|
uv run pytest tests/unit_tests/ tests/smoke/ \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=xml:coverage.xml \
|
||||||
|
--cov-report=term \
|
||||||
|
--cov-fail-under=$COVERAGE_THRESHOLD \
|
||||||
|
-q 2>/dev/null || {
|
||||||
|
# If threshold check fails on combined, check unit+smoke baseline
|
||||||
|
echo ""
|
||||||
|
echo "Coverage threshold: $COVERAGE_THRESHOLD%"
|
||||||
|
echo "Note: Full coverage requires running all test types separately"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Coverage Gate Complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Coverage baseline: $COVERAGE_THRESHOLD%"
|
||||||
|
echo "Coverage report saved to coverage.xml"
|
||||||
16
scripts/test-integration-fast.sh
Executable file
16
scripts/test-integration-fast.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Fast integration tests
|
||||||
|
# Runs integration tests excluding slow ones (PostgreSQL, external services)
|
||||||
|
# Uses fake runner/provider, no real credentials needed
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== LangBot Fast Integration Tests ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Running integration tests (excluding slow)..."
|
||||||
|
uv run pytest tests/integration/ -m "not slow" -q --tb=short
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Fast Integration Tests Complete ==="
|
||||||
36
scripts/test-quick.sh
Executable file
36
scripts/test-quick.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Quick developer self-test command
|
||||||
|
# Runs linting, unit tests, and smoke tests without requiring real provider keys
|
||||||
|
# Suitable for local branch validation
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== LangBot Quick Self-Test ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Ruff check
|
||||||
|
echo "[1/3] Running ruff check..."
|
||||||
|
uv run ruff check src/langbot/ tests/ --output-format=concise || {
|
||||||
|
echo ""
|
||||||
|
echo "⚠ Ruff check found issues. Run 'uv run ruff check --fix' to auto-fix."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "✓ Ruff check passed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. Unit tests
|
||||||
|
echo "[2/3] Running unit tests..."
|
||||||
|
uv run pytest tests/unit_tests/ -q --tb=short
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3. Smoke tests (if exists)
|
||||||
|
echo "[3/3] Running smoke tests..."
|
||||||
|
if [ -d "tests/smoke" ]; then
|
||||||
|
uv run pytest tests/smoke/ -q --tb=short
|
||||||
|
else
|
||||||
|
echo "No smoke tests found, skipping"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Quick Self-Test Complete ==="
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.9.4'
|
__version__ = '4.10.0'
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import argparse
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from langbot.pkg.utils import paths
|
||||||
|
|
||||||
# ASCII art banner
|
# ASCII art banner
|
||||||
asciiart = r"""
|
asciiart = r"""
|
||||||
_ ___ _
|
_ ___ _
|
||||||
@@ -27,6 +29,12 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
|
|||||||
help='Use standalone plugin runtime / 使用独立插件运行时',
|
help='Use standalone plugin runtime / 使用独立插件运行时',
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--standalone-box',
|
||||||
|
action='store_true',
|
||||||
|
help='Use standalone box runtime / 使用独立 Box 运行时',
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False)
|
parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -35,6 +43,11 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
|
|||||||
|
|
||||||
platform.standalone_runtime = True
|
platform.standalone_runtime = True
|
||||||
|
|
||||||
|
if args.standalone_box:
|
||||||
|
from langbot.pkg.utils import platform
|
||||||
|
|
||||||
|
platform.standalone_box = True
|
||||||
|
|
||||||
if args.debug:
|
if args.debug:
|
||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
|
|
||||||
@@ -87,7 +100,7 @@ def main():
|
|||||||
# Set up the working directory
|
# Set up the working directory
|
||||||
# When installed as a package, we need to handle the working directory differently
|
# When installed as a package, we need to handle the working directory differently
|
||||||
# We'll create data directory in current working directory if not exists
|
# We'll create data directory in current working directory if not exists
|
||||||
os.makedirs('data', exist_ok=True)
|
os.makedirs(paths.get_data_root(), exist_ok=True)
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
|
|
||||||
|
|||||||
5
src/langbot/libs/deerflow_api/__init__.py
Normal file
5
src/langbot/libs/deerflow_api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .client import AsyncDeerFlowClient
|
||||||
|
from .errors import DeerFlowAPIError
|
||||||
|
from . import stream_utils
|
||||||
|
|
||||||
|
__all__ = ['AsyncDeerFlowClient', 'DeerFlowAPIError', 'stream_utils']
|
||||||
204
src/langbot/libs/deerflow_api/client.py
Normal file
204
src/langbot/libs/deerflow_api/client.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""DeerFlow LangGraph HTTP API 客户端
|
||||||
|
|
||||||
|
参考 astrbot 的 deerflow_api_client 实现,使用 httpx 适配 LangBot 风格。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import codecs
|
||||||
|
import json
|
||||||
|
import typing
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .errors import DeerFlowAPIError
|
||||||
|
|
||||||
|
|
||||||
|
SSE_MAX_BUFFER_CHARS = 1_048_576
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sse_newlines(text: str) -> str:
|
||||||
|
"""规范化 CRLF/CR 为 LF,确保 SSE 块分割稳定"""
|
||||||
|
return text.replace('\r\n', '\n').replace('\r', '\n')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_data_lines(data_lines: list[str]) -> typing.Any:
|
||||||
|
raw_data = '\n'.join(data_lines)
|
||||||
|
try:
|
||||||
|
return json.loads(raw_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# 某些 LangGraph 兼容服务端会在单个 SSE 事件中用多个 data 行
|
||||||
|
# 发送多段 JSON 片段(例如 tuple payload)
|
||||||
|
parsed_lines: list[typing.Any] = []
|
||||||
|
can_parse_all = True
|
||||||
|
for line in data_lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed_lines.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
can_parse_all = False
|
||||||
|
break
|
||||||
|
if can_parse_all and parsed_lines:
|
||||||
|
return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines
|
||||||
|
return raw_data
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_block(block: str) -> dict[str, typing.Any] | None:
|
||||||
|
if not block.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_name = 'message'
|
||||||
|
data_lines: list[str] = []
|
||||||
|
for line in block.splitlines():
|
||||||
|
if line.startswith('event:'):
|
||||||
|
event_name = line[6:].strip()
|
||||||
|
elif line.startswith('data:'):
|
||||||
|
data_lines.append(line[5:].lstrip())
|
||||||
|
|
||||||
|
if not data_lines:
|
||||||
|
return None
|
||||||
|
return {'event': event_name, 'data': _parse_sse_data_lines(data_lines)}
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncDeerFlowClient:
|
||||||
|
"""DeerFlow LangGraph HTTP API 客户端"""
|
||||||
|
|
||||||
|
api_base: str
|
||||||
|
headers: dict[str, str]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_base: str = 'http://127.0.0.1:2026',
|
||||||
|
api_key: str = '',
|
||||||
|
auth_header: str = '',
|
||||||
|
) -> None:
|
||||||
|
self.api_base = api_base.rstrip('/')
|
||||||
|
self.headers: dict[str, str] = {}
|
||||||
|
if auth_header:
|
||||||
|
self.headers['Authorization'] = auth_header
|
||||||
|
elif api_key:
|
||||||
|
self.headers['Authorization'] = f'Bearer {api_key}'
|
||||||
|
|
||||||
|
async def create_thread(self, timeout: float = 20) -> dict[str, typing.Any]:
|
||||||
|
"""创建一个新的 LangGraph thread
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含 thread_id 等信息的字典
|
||||||
|
"""
|
||||||
|
url = f'{self.api_base}/api/langgraph/threads'
|
||||||
|
payload = {'metadata': {}}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers=self.headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
raise DeerFlowAPIError(
|
||||||
|
operation='create thread',
|
||||||
|
status=response.status_code,
|
||||||
|
body=response.text,
|
||||||
|
url=url,
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
|
||||||
|
"""删除指定 thread"""
|
||||||
|
url = f'{self.api_base}/api/threads/{thread_id}'
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
response = await client.delete(url, headers=self.headers)
|
||||||
|
if response.status_code not in (200, 202, 204, 404):
|
||||||
|
raise DeerFlowAPIError(
|
||||||
|
operation='delete thread',
|
||||||
|
status=response.status_code,
|
||||||
|
body=response.text,
|
||||||
|
url=url,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stream_run(
|
||||||
|
self,
|
||||||
|
thread_id: str,
|
||||||
|
payload: dict[str, typing.Any],
|
||||||
|
timeout: float = 120,
|
||||||
|
) -> AsyncGenerator[dict[str, typing.Any], None]:
|
||||||
|
"""运行一次 LangGraph stream 请求,逐事件 yield
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
事件字典 {'event': event_name, 'data': parsed_data}
|
||||||
|
"""
|
||||||
|
url = f'{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream'
|
||||||
|
|
||||||
|
# 流式请求使用单独的 read timeout 控制
|
||||||
|
stream_timeout = httpx.Timeout(
|
||||||
|
connect=min(timeout, 30),
|
||||||
|
read=timeout,
|
||||||
|
write=timeout,
|
||||||
|
pool=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
trust_env=True,
|
||||||
|
timeout=stream_timeout,
|
||||||
|
) as client:
|
||||||
|
async with client.stream(
|
||||||
|
'POST',
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
**self.headers,
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
) as resp:
|
||||||
|
if resp.status_code != 200:
|
||||||
|
body = await resp.aread()
|
||||||
|
raise DeerFlowAPIError(
|
||||||
|
operation='runs/stream request',
|
||||||
|
status=resp.status_code,
|
||||||
|
body=body.decode('utf-8', errors='replace'),
|
||||||
|
url=url,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
||||||
|
buffer = ''
|
||||||
|
|
||||||
|
async for chunk in resp.aiter_bytes(8192):
|
||||||
|
buffer += _normalize_sse_newlines(decoder.decode(chunk))
|
||||||
|
|
||||||
|
while '\n\n' in buffer:
|
||||||
|
block, buffer = buffer.split('\n\n', 1)
|
||||||
|
parsed = _parse_sse_block(block)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
|
|
||||||
|
if len(buffer) > SSE_MAX_BUFFER_CHARS:
|
||||||
|
# 缓冲区过大,强制 flush
|
||||||
|
parsed = _parse_sse_block(buffer)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
|
buffer = ''
|
||||||
|
|
||||||
|
# flush 剩余内容
|
||||||
|
buffer += _normalize_sse_newlines(decoder.decode(b'', final=True))
|
||||||
|
while '\n\n' in buffer:
|
||||||
|
block, buffer = buffer.split('\n\n', 1)
|
||||||
|
parsed = _parse_sse_block(block)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
|
if buffer.strip():
|
||||||
|
parsed = _parse_sse_block(buffer)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
30
src/langbot/libs/deerflow_api/errors.py
Normal file
30
src/langbot/libs/deerflow_api/errors.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class DeerFlowAPIError(Exception):
|
||||||
|
"""DeerFlow API 请求失败"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
operation: str = '',
|
||||||
|
status: int = 0,
|
||||||
|
body: str = '',
|
||||||
|
url: str = '',
|
||||||
|
thread_id: str | None = None,
|
||||||
|
message: str = '',
|
||||||
|
) -> None:
|
||||||
|
self.operation = operation
|
||||||
|
self.status = status
|
||||||
|
self.body = body
|
||||||
|
self.url = url
|
||||||
|
self.thread_id = thread_id
|
||||||
|
|
||||||
|
if message:
|
||||||
|
super().__init__(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = f'DeerFlow {operation} failed: status={status}, url={url}, body={body}'
|
||||||
|
if thread_id is not None:
|
||||||
|
msg = f'DeerFlow {operation} failed: thread_id={thread_id}, status={status}, url={url}, body={body}'
|
||||||
|
super().__init__(msg)
|
||||||
212
src/langbot/libs/deerflow_api/stream_utils.py
Normal file
212
src/langbot/libs/deerflow_api/stream_utils.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""DeerFlow LangGraph 流式响应解析工具
|
||||||
|
|
||||||
|
参考 astrbot 实现的 deerflow_stream_utils。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text(content: typing.Any) -> str:
|
||||||
|
"""从消息 content 中提取纯文本"""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, dict):
|
||||||
|
if isinstance(content.get('text'), str):
|
||||||
|
return content['text']
|
||||||
|
if 'content' in content:
|
||||||
|
return extract_text(content.get('content'))
|
||||||
|
if 'kwargs' in content and isinstance(content['kwargs'], dict):
|
||||||
|
return extract_text(content['kwargs'].get('content'))
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in content:
|
||||||
|
if isinstance(item, str):
|
||||||
|
parts.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
item_type = item.get('type')
|
||||||
|
if item_type == 'text' and isinstance(item.get('text'), str):
|
||||||
|
parts.append(item['text'])
|
||||||
|
elif 'content' in item:
|
||||||
|
parts.append(extract_text(item['content']))
|
||||||
|
return '\n'.join([p for p in parts if p]).strip()
|
||||||
|
return str(content) if content is not None else ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract_messages_from_values_data(data: typing.Any) -> list[typing.Any]:
|
||||||
|
"""从 values 事件中提取 messages 列表"""
|
||||||
|
candidates: list[typing.Any] = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
candidates.append(data)
|
||||||
|
if isinstance(data.get('values'), dict):
|
||||||
|
candidates.append(data['values'])
|
||||||
|
elif isinstance(data, list):
|
||||||
|
candidates.extend([x for x in data if isinstance(x, dict)])
|
||||||
|
|
||||||
|
for item in candidates:
|
||||||
|
messages = item.get('messages')
|
||||||
|
if isinstance(messages, list):
|
||||||
|
return messages
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def is_ai_message(message: dict[str, typing.Any]) -> bool:
|
||||||
|
"""判断是否为 AI/assistant 消息"""
|
||||||
|
role = str(message.get('role', '')).lower()
|
||||||
|
if role in {'assistant', 'ai'}:
|
||||||
|
return True
|
||||||
|
|
||||||
|
msg_type = str(message.get('type', '')).lower()
|
||||||
|
if msg_type in {'ai', 'assistant', 'aimessage', 'aimessagechunk'}:
|
||||||
|
return True
|
||||||
|
if 'ai' in msg_type and all(token not in msg_type for token in ('human', 'tool', 'system')):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_latest_ai_text(messages: Iterable[typing.Any]) -> str:
|
||||||
|
"""获取最近一条 AI 消息的文本内容"""
|
||||||
|
if isinstance(messages, (list, tuple)):
|
||||||
|
iterable = reversed(messages)
|
||||||
|
else:
|
||||||
|
iterable = reversed(list(messages))
|
||||||
|
|
||||||
|
for msg in iterable:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if is_ai_message(msg):
|
||||||
|
text = extract_text(msg.get('content'))
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract_latest_ai_message(messages: Iterable[typing.Any]) -> dict[str, typing.Any] | None:
|
||||||
|
"""获取最近一条 AI 消息对象"""
|
||||||
|
if isinstance(messages, (list, tuple)):
|
||||||
|
iterable = reversed(messages)
|
||||||
|
else:
|
||||||
|
iterable = reversed(list(messages))
|
||||||
|
|
||||||
|
for msg in iterable:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if is_ai_message(msg):
|
||||||
|
return msg
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_clarification_tool_message(message: dict[str, typing.Any]) -> bool:
|
||||||
|
"""判断是否为澄清问题工具消息"""
|
||||||
|
msg_type = str(message.get('type', '')).lower()
|
||||||
|
tool_name = str(message.get('name', '')).lower()
|
||||||
|
return msg_type == 'tool' and tool_name == 'ask_clarification'
|
||||||
|
|
||||||
|
|
||||||
|
def extract_latest_clarification_text(messages: Iterable[typing.Any]) -> str:
|
||||||
|
"""提取最近的澄清问题文本"""
|
||||||
|
if isinstance(messages, (list, tuple)):
|
||||||
|
iterable = reversed(messages)
|
||||||
|
else:
|
||||||
|
iterable = reversed(list(messages))
|
||||||
|
|
||||||
|
for msg in iterable:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if is_clarification_tool_message(msg):
|
||||||
|
text = extract_text(msg.get('content'))
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def get_message_id(message: typing.Any) -> str:
|
||||||
|
"""提取消息 ID"""
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
return ''
|
||||||
|
msg_id = message.get('id')
|
||||||
|
return msg_id if isinstance(msg_id, str) else ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract_event_message_obj(data: typing.Any) -> dict[str, typing.Any] | None:
|
||||||
|
"""从事件 data 中提取消息对象"""
|
||||||
|
msg_obj = data
|
||||||
|
if isinstance(data, (list, tuple)) and data:
|
||||||
|
msg_obj = data[0]
|
||||||
|
if isinstance(msg_obj, dict) and isinstance(msg_obj.get('data'), dict):
|
||||||
|
msg_obj = msg_obj['data']
|
||||||
|
return msg_obj if isinstance(msg_obj, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ai_delta_from_event_data(data: typing.Any) -> str:
|
||||||
|
"""从 messages-tuple 事件中提取 AI delta 文本"""
|
||||||
|
msg_obj = extract_event_message_obj(data)
|
||||||
|
if not msg_obj:
|
||||||
|
return ''
|
||||||
|
if is_ai_message(msg_obj):
|
||||||
|
return extract_text(msg_obj.get('content'))
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract_clarification_from_event_data(data: typing.Any) -> str:
|
||||||
|
"""从事件中提取澄清问题"""
|
||||||
|
msg_obj = extract_event_message_obj(data)
|
||||||
|
if not msg_obj:
|
||||||
|
return ''
|
||||||
|
if is_clarification_tool_message(msg_obj):
|
||||||
|
return extract_text(msg_obj.get('content'))
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_custom_event_items(data: typing.Any) -> list[dict[str, typing.Any]]:
|
||||||
|
items: list[dict[str, typing.Any]] = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return [data]
|
||||||
|
if isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
items.append(item)
|
||||||
|
elif isinstance(item, (list, tuple)):
|
||||||
|
for nested in item:
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
items.append(nested)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def extract_task_failures_from_custom_event(data: typing.Any) -> list[str]:
|
||||||
|
"""从 custom 事件中提取子任务失败信息"""
|
||||||
|
failures: list[str] = []
|
||||||
|
for item in _iter_custom_event_items(data):
|
||||||
|
event_type = str(item.get('type', '')).lower()
|
||||||
|
if event_type not in {'task_failed', 'task_timed_out'}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
task_id = str(item.get('task_id', '')).strip()
|
||||||
|
error_text = extract_text(item.get('error')).strip()
|
||||||
|
if task_id and error_text:
|
||||||
|
failures.append(f'{task_id}: {error_text}')
|
||||||
|
elif error_text:
|
||||||
|
failures.append(error_text)
|
||||||
|
elif task_id:
|
||||||
|
failures.append(f'{task_id}: unknown error')
|
||||||
|
else:
|
||||||
|
failures.append('unknown task failure')
|
||||||
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
def build_task_failure_summary(failures: list[str]) -> str:
|
||||||
|
"""构建任务失败摘要"""
|
||||||
|
if not failures:
|
||||||
|
return ''
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for failure in failures:
|
||||||
|
if failure not in seen:
|
||||||
|
seen.add(failure)
|
||||||
|
deduped.append(failure)
|
||||||
|
if len(deduped) == 1:
|
||||||
|
return f'DeerFlow subtask failed: {deduped[0]}'
|
||||||
|
joined = '\n'.join([f'- {item}' for item in deduped[:5]])
|
||||||
|
return f'DeerFlow subtasks failed:\n{joined}'
|
||||||
@@ -182,6 +182,88 @@ class DingTalkClient:
|
|||||||
for handler in self._message_handlers[msg_type]:
|
for handler in self._message_handlers[msg_type]:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
||||||
|
async def _parse_quoted_message(self, replied_msg: dict) -> dict:
|
||||||
|
"""Parse the quoted/replied message and extract its content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
replied_msg: The repliedMsg object from DingTalk message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing the quoted message info with keys:
|
||||||
|
- message_id: The original message ID
|
||||||
|
- msg_type: The message type (text, file, picture, audio, etc.)
|
||||||
|
- content: The text content (if any)
|
||||||
|
- file_url: The file download URL (if file type)
|
||||||
|
- file_name: The file name (if file type)
|
||||||
|
- picture: The picture base64 (if picture type)
|
||||||
|
- audio: The audio base64 (if audio type)
|
||||||
|
"""
|
||||||
|
quote_info = {
|
||||||
|
'message_id': replied_msg.get('msgId', ''),
|
||||||
|
'msg_type': replied_msg.get('msgType', ''),
|
||||||
|
'sender_id': replied_msg.get('senderId', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg_type = replied_msg.get('msgType', '')
|
||||||
|
content = replied_msg.get('content', {})
|
||||||
|
|
||||||
|
# Handle content as string (JSON) or dict
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
content = json.loads(content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
content = {}
|
||||||
|
|
||||||
|
if msg_type == 'text':
|
||||||
|
# Text message
|
||||||
|
if isinstance(content, dict):
|
||||||
|
quote_info['content'] = content.get('content', '')
|
||||||
|
else:
|
||||||
|
quote_info['content'] = str(content)
|
||||||
|
|
||||||
|
elif msg_type == 'file':
|
||||||
|
# File message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
file_name = content.get('fileName')
|
||||||
|
if download_code and file_name:
|
||||||
|
try:
|
||||||
|
quote_info['file_url'] = await self.get_file_url(download_code)
|
||||||
|
quote_info['file_name'] = file_name
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to get quoted file URL: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'picture':
|
||||||
|
# Picture message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
try:
|
||||||
|
quote_info['picture'] = await self.download_image(download_code)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to download quoted image: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'audio':
|
||||||
|
# Audio message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
try:
|
||||||
|
quote_info['audio'] = await self.get_audio_url(download_code)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to get quoted audio: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'richText':
|
||||||
|
# Rich text message - extract text content
|
||||||
|
rich_text = content.get('richText', [])
|
||||||
|
texts = []
|
||||||
|
for item in rich_text:
|
||||||
|
if 'text' in item and item['text'] != '\n':
|
||||||
|
texts.append(item['text'])
|
||||||
|
quote_info['content'] = '\n'.join(texts)
|
||||||
|
|
||||||
|
return quote_info
|
||||||
|
|
||||||
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
||||||
try:
|
try:
|
||||||
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
||||||
@@ -193,6 +275,15 @@ class DingTalkClient:
|
|||||||
elif str(incoming_message.conversation_type) == '2':
|
elif str(incoming_message.conversation_type) == '2':
|
||||||
message_data['conversation_type'] = 'GroupMessage'
|
message_data['conversation_type'] = 'GroupMessage'
|
||||||
|
|
||||||
|
# Check for quoted/replied message
|
||||||
|
raw_data = incoming_message.to_dict()
|
||||||
|
text_data = raw_data.get('text', {})
|
||||||
|
if isinstance(text_data, dict) and text_data.get('isReplyMsg'):
|
||||||
|
replied_msg = text_data.get('repliedMsg', {})
|
||||||
|
if replied_msg:
|
||||||
|
quote_info = await self._parse_quoted_message(replied_msg)
|
||||||
|
message_data['QuotedMessage'] = quote_info
|
||||||
|
|
||||||
if incoming_message.message_type == 'richText':
|
if incoming_message.message_type == 'richText':
|
||||||
data = incoming_message.rich_text_content.to_dict()
|
data = incoming_message.rich_text_content.to_dict()
|
||||||
|
|
||||||
@@ -268,19 +359,52 @@ class DingTalkClient:
|
|||||||
|
|
||||||
message_data['Type'] = 'image'
|
message_data['Type'] = 'image'
|
||||||
elif incoming_message.message_type == 'audio':
|
elif incoming_message.message_type == 'audio':
|
||||||
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
|
raw_content = incoming_message.to_dict().get('content', {})
|
||||||
|
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||||||
|
if isinstance(raw_content, str):
|
||||||
|
try:
|
||||||
|
raw_content = json.loads(raw_content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raw_content = {}
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
# 提取钉钉自带的语音转写文字(Powered by Qwen)
|
||||||
|
recognition = raw_content.get('recognition', '')
|
||||||
|
if recognition:
|
||||||
|
message_data['Content'] = recognition
|
||||||
|
|
||||||
|
download_code = raw_content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
message_data['Audio'] = await self.get_audio_url(download_code)
|
||||||
|
|
||||||
message_data['Type'] = 'audio'
|
message_data['Type'] = 'audio'
|
||||||
elif incoming_message.message_type == 'file':
|
elif incoming_message.message_type == 'file':
|
||||||
down_list = incoming_message.get_down_list()
|
# 获取原始数据字典并提取嵌套的文件信息
|
||||||
if len(down_list) >= 2:
|
raw_data = incoming_message.to_dict()
|
||||||
message_data['File'] = await self.get_file_url(down_list[0])
|
file_info = raw_data.get('content', {})
|
||||||
message_data['Name'] = down_list[1]
|
|
||||||
|
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||||||
|
if isinstance(file_info, str):
|
||||||
|
try:
|
||||||
|
file_info = json.loads(file_info)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
file_info = {}
|
||||||
|
|
||||||
|
download_code = file_info.get('downloadCode')
|
||||||
|
file_name = file_info.get('fileName')
|
||||||
|
|
||||||
|
if download_code and file_name:
|
||||||
|
# 转换 downloadCode 为可下载的真实 URL
|
||||||
|
message_data['File'] = await self.get_file_url(download_code)
|
||||||
|
message_data['Name'] = file_name
|
||||||
else:
|
else:
|
||||||
if self.logger:
|
if self.logger:
|
||||||
await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}')
|
await self.logger.error(f'Failed to extract file info from message content: {file_info}')
|
||||||
message_data['File'] = None
|
message_data['File'] = None
|
||||||
message_data['Name'] = None
|
message_data['Name'] = None
|
||||||
|
|
||||||
message_data['Type'] = 'file'
|
message_data['Type'] = 'file'
|
||||||
|
|
||||||
copy_message_data = message_data.copy()
|
copy_message_data = message_data.copy()
|
||||||
@@ -357,6 +481,12 @@ class DingTalkClient:
|
|||||||
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
||||||
card_data['content'] = ''
|
card_data['content'] = ''
|
||||||
|
|
||||||
|
# 将用户的消息内容作为卡片的查询参数,方便后续处理
|
||||||
|
if incoming_message.message_type == 'text':
|
||||||
|
card_data['query'] = incoming_message.get_text_list()[0]
|
||||||
|
else:
|
||||||
|
card_data['query'] = '...'
|
||||||
|
|
||||||
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
||||||
# print(card_instance)
|
# print(card_instance)
|
||||||
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class DingTalkEvent(dict):
|
|||||||
def conversation(self):
|
def conversation(self):
|
||||||
return self.get('conversation_type', '')
|
return self.get('conversation_type', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quoted_message(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get the quoted/replied message info if this is a reply message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing:
|
||||||
|
- message_id: The original message ID
|
||||||
|
- msg_type: The message type (text, file, picture, audio, etc.)
|
||||||
|
- content: The text content (if any)
|
||||||
|
- file_url: The file download URL (if file type)
|
||||||
|
- file_name: The file name (if file type)
|
||||||
|
- picture: The picture base64 (if picture type)
|
||||||
|
- audio: The audio base64 (if audio type)
|
||||||
|
"""
|
||||||
|
return self.get('QuotedMessage')
|
||||||
|
|
||||||
def __getattr__(self, key: str) -> Optional[Any]:
|
def __getattr__(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
允许通过属性访问数据中的任意字段。
|
允许通过属性访问数据中的任意字段。
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
from quart import request
|
from quart import request
|
||||||
import httpx
|
import httpx
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any, Optional
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
from .qqofficialevent import QQOfficialEvent
|
from .qqofficialevent import QQOfficialEvent
|
||||||
import json
|
import json
|
||||||
@@ -32,6 +34,8 @@ class QQOfficialClient:
|
|||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.access_token_expiry_time = None
|
self.access_token_expiry_time = None
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self._msg_seq_counter = 0
|
||||||
|
self._token_refresh_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
async def check_access_token(self):
|
async def check_access_token(self):
|
||||||
"""检查access_token是否存在"""
|
"""检查access_token是否存在"""
|
||||||
@@ -50,18 +54,18 @@ class QQOfficialClient:
|
|||||||
headers = {
|
headers = {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
}
|
}
|
||||||
try:
|
|
||||||
response = await client.post(url, json=params, headers=headers)
|
response = await client.post(url, json=params, headers=headers)
|
||||||
if response.status_code == 200:
|
if response.status_code != 200:
|
||||||
|
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
access_token = response_data.get('access_token')
|
access_token = response_data.get('access_token')
|
||||||
expires_in = int(response_data.get('expires_in', 7200))
|
expires_in = int(response_data.get('expires_in', 7200))
|
||||||
self.access_token_expiry_time = time.time() + expires_in - 60
|
self.access_token_expiry_time = time.time() + expires_in - 60
|
||||||
if access_token:
|
if access_token:
|
||||||
self.access_token = access_token
|
self.access_token = access_token
|
||||||
except Exception as e:
|
await self.logger.info(f'access_token obtained, expires_in={expires_in}s')
|
||||||
await self.logger.error(f'获取access_token失败: {response_data}')
|
else:
|
||||||
raise Exception(f'获取access_token失败: {e}')
|
raise Exception('Failed to get access_token: no access_token in response')
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""处理回调请求(独立端口模式,使用全局 request)"""
|
"""处理回调请求(独立端口模式,使用全局 request)"""
|
||||||
@@ -87,10 +91,10 @@ class QQOfficialClient:
|
|||||||
try:
|
try:
|
||||||
body = await req.get_data()
|
body = await req.get_data()
|
||||||
|
|
||||||
print(f'[QQ Official] Received request, body length: {len(body)}')
|
await self.logger.info(f'Received request, body length: {len(body)}')
|
||||||
|
|
||||||
if not body or len(body) == 0:
|
if not body or len(body) == 0:
|
||||||
print('[QQ Official] Received empty body, might be health check or GET request')
|
await self.logger.info('Received empty body, might be health check or GET request')
|
||||||
return {'code': 0, 'message': 'ok'}, 200
|
return {'code': 0, 'message': 'ok'}, 200
|
||||||
|
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
@@ -111,7 +115,6 @@ class QQOfficialClient:
|
|||||||
return {'code': 0, 'message': 'success'}
|
return {'code': 0, 'message': 'success'}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[QQ Official] ERROR: {traceback.format_exc()}')
|
|
||||||
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
|
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
|
||||||
return {'error': str(e)}, 400
|
return {'error': str(e)}, 400
|
||||||
|
|
||||||
@@ -139,21 +142,24 @@ class QQOfficialClient:
|
|||||||
|
|
||||||
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
||||||
"""获取消息"""
|
"""获取消息"""
|
||||||
|
d = msg.get('d', {})
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return {}
|
||||||
message_data = {
|
message_data = {
|
||||||
't': msg.get('t', {}),
|
't': msg.get('t', {}),
|
||||||
'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),
|
'user_openid': d.get('author', {}).get('user_openid', {}),
|
||||||
'timestamp': msg.get('d', {}).get('timestamp', {}),
|
'timestamp': d.get('timestamp', {}),
|
||||||
'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),
|
'd_author_id': d.get('author', {}).get('id', {}),
|
||||||
'content': msg.get('d', {}).get('content', {}),
|
'content': d.get('content', {}),
|
||||||
'd_id': msg.get('d', {}).get('id', {}),
|
'd_id': d.get('id', {}),
|
||||||
'id': msg.get('id', {}),
|
'id': msg.get('id', {}),
|
||||||
'channel_id': msg.get('d', {}).get('channel_id', {}),
|
'channel_id': d.get('channel_id', {}),
|
||||||
'username': msg.get('d', {}).get('author', {}).get('username', {}),
|
'username': d.get('author', {}).get('username', {}),
|
||||||
'guild_id': msg.get('d', {}).get('guild_id', {}),
|
'guild_id': d.get('guild_id', {}),
|
||||||
'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),
|
'member_openid': d.get('author', {}).get('openid', {}),
|
||||||
'group_openid': msg.get('d', {}).get('group_openid', {}),
|
'group_openid': d.get('group_openid', {}),
|
||||||
}
|
}
|
||||||
attachments = msg.get('d', {}).get('attachments', [])
|
attachments = d.get('attachments', [])
|
||||||
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
||||||
image_attachments_type = [
|
image_attachments_type = [
|
||||||
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
||||||
@@ -192,7 +198,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送私聊消息失败: {response_data}')
|
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||||
raise ValueError(response)
|
raise ValueError(response)
|
||||||
|
|
||||||
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
||||||
@@ -215,7 +221,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送群聊消息失败:{response.json()}')
|
await self.logger.error(f'Failed to send group message: {response.json()}')
|
||||||
raise Exception(response.read().decode())
|
raise Exception(response.read().decode())
|
||||||
|
|
||||||
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
||||||
@@ -238,7 +244,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
|
await self.logger.error(f'Failed to send channel group message: {response.json()}')
|
||||||
raise Exception(response)
|
raise Exception(response)
|
||||||
|
|
||||||
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
||||||
@@ -261,9 +267,224 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
|
await self.logger.error(f'Failed to send channel private message: {response.json()}')
|
||||||
raise Exception(response)
|
raise Exception(response)
|
||||||
|
|
||||||
|
# ---- 富媒体消息 ----
|
||||||
|
|
||||||
|
# 媒体文件类型
|
||||||
|
MEDIA_TYPE_IMAGE = 1
|
||||||
|
MEDIA_TYPE_VIDEO = 2
|
||||||
|
MEDIA_TYPE_VOICE = 3
|
||||||
|
MEDIA_TYPE_FILE = 4
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_type: int,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
file_name: str = None,
|
||||||
|
) -> str:
|
||||||
|
"""上传媒体文件,返回 file_info。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_type: 'c2c' | 'group'
|
||||||
|
target_id: 用户 openid 或群 openid
|
||||||
|
file_type: 1=图片, 2=视频, 3=语音, 4=文件
|
||||||
|
file_url: 在线 URL(与 file_data 二选一)
|
||||||
|
file_data: base64 编码的文件数据或 data URL(与 file_url 二选一)
|
||||||
|
file_name: 文件名(file_type=4 时必填)
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/files'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/files'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'file_type': file_type,
|
||||||
|
'srv_send_msg': False,
|
||||||
|
}
|
||||||
|
if file_url:
|
||||||
|
body['url'] = file_url
|
||||||
|
elif file_data:
|
||||||
|
# 处理 data URL 格式: data:image/png;base64,xxxxx
|
||||||
|
if file_data.startswith('data:'):
|
||||||
|
match = re.match(r'^data:[^;]+;base64,(.+)$', file_data, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
body['file_data'] = match.group(1)
|
||||||
|
else:
|
||||||
|
body['file_data'] = file_data
|
||||||
|
else:
|
||||||
|
body['file_data'] = file_data
|
||||||
|
else:
|
||||||
|
raise ValueError('file_url or file_data is required')
|
||||||
|
|
||||||
|
if file_type == self.MEDIA_TYPE_FILE and file_name:
|
||||||
|
body['file_name'] = file_name
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
file_info = data.get('file_info', '')
|
||||||
|
preview = file_info[:80] + '...' if len(file_info) > 80 else file_info
|
||||||
|
await self.logger.info(f'Upload media success, file_info={preview}')
|
||||||
|
return file_info
|
||||||
|
else:
|
||||||
|
raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def _send_media_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_info: str,
|
||||||
|
msg_id: str = None,
|
||||||
|
content: str = None,
|
||||||
|
):
|
||||||
|
"""发送富媒体消息(msg_type=7)"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||||
|
|
||||||
|
self._msg_seq_counter += 1
|
||||||
|
msg_seq = self._msg_seq_counter
|
||||||
|
body = {
|
||||||
|
'msg_type': 7,
|
||||||
|
'media': {'file_info': file_info},
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
}
|
||||||
|
if content:
|
||||||
|
body['content'] = content
|
||||||
|
if msg_id:
|
||||||
|
body['msg_id'] = msg_id
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}')
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def send_image_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
content: str = None,
|
||||||
|
):
|
||||||
|
"""发送图片消息"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_IMAGE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id, content)
|
||||||
|
|
||||||
|
async def send_voice_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
):
|
||||||
|
"""发送语音消息"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_VOICE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id)
|
||||||
|
|
||||||
|
async def send_file_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
file_name: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
):
|
||||||
|
"""发送文件消息(含视频)"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_FILE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
file_name=file_name,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id)
|
||||||
|
|
||||||
|
async def send_stream_msg(
|
||||||
|
self,
|
||||||
|
user_openid: str,
|
||||||
|
content: str,
|
||||||
|
event_id: str,
|
||||||
|
msg_id: str,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
index: int = 0,
|
||||||
|
stream_msg_id: str = None,
|
||||||
|
input_state: int = 1,
|
||||||
|
):
|
||||||
|
"""发送流式消息(C2C 私聊)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_state: 1=生成中, 10=生成结束
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
url = f'{self.base_url}/v2/users/{user_openid}/stream_messages'
|
||||||
|
body = {
|
||||||
|
'input_mode': 'replace',
|
||||||
|
'input_state': input_state,
|
||||||
|
'content_type': 'markdown',
|
||||||
|
'content_raw': content,
|
||||||
|
'event_id': event_id,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
'index': index,
|
||||||
|
}
|
||||||
|
if stream_msg_id:
|
||||||
|
body['stream_msg_id'] = stream_msg_id
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
|
||||||
|
return response.json()
|
||||||
|
|
||||||
async def is_token_expired(self):
|
async def is_token_expired(self):
|
||||||
"""检查token是否过期"""
|
"""检查token是否过期"""
|
||||||
if self.access_token_expiry_time is None:
|
if self.access_token_expiry_time is None:
|
||||||
@@ -292,3 +513,325 @@ class QQOfficialClient:
|
|||||||
'signature': signature,
|
'signature': signature,
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# ---- WebSocket Gateway ----
|
||||||
|
# Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
|
||||||
|
|
||||||
|
INTENT_GUILDS = 1 << 0
|
||||||
|
INTENT_GUILD_MEMBERS = 1 << 1
|
||||||
|
INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30
|
||||||
|
INTENT_DIRECT_MESSAGE = 1 << 12
|
||||||
|
INTENT_GROUP_AND_C2C = 1 << 25
|
||||||
|
INTENT_INTERACTION = 1 << 26
|
||||||
|
|
||||||
|
FULL_INTENTS = (
|
||||||
|
INTENT_GUILDS
|
||||||
|
| INTENT_GUILD_MEMBERS
|
||||||
|
| INTENT_PUBLIC_GUILD_MESSAGES
|
||||||
|
| INTENT_DIRECT_MESSAGE
|
||||||
|
| INTENT_GROUP_AND_C2C
|
||||||
|
| INTENT_INTERACTION
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_gateway_url(self) -> str:
|
||||||
|
"""获取 WebSocket 网关地址"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
url = f'{self.base_url}/gateway'
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
}
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
ws_url = data.get('url', '')
|
||||||
|
if not ws_url:
|
||||||
|
raise Exception('Gateway URL is empty')
|
||||||
|
return ws_url
|
||||||
|
else:
|
||||||
|
raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def _background_token_refresh(self):
|
||||||
|
"""在 token 到期前主动刷新"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if self.access_token_expiry_time:
|
||||||
|
remain = self.access_token_expiry_time - time.time()
|
||||||
|
if remain > 120:
|
||||||
|
await asyncio.sleep(remain - 60)
|
||||||
|
continue
|
||||||
|
self.access_token = ''
|
||||||
|
self.access_token_expiry_time = None
|
||||||
|
if await self.check_access_token():
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
else:
|
||||||
|
await self.get_access_token()
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def connect_gateway(
|
||||||
|
self,
|
||||||
|
on_event: Callable[[str, dict], Any],
|
||||||
|
on_ready: Optional[Callable[[], Any]] = None,
|
||||||
|
on_error: Optional[Callable[[Exception], Any]] = None,
|
||||||
|
):
|
||||||
|
"""WebSocket 网关连接,含重连逻辑。持续重连直到达到最大次数或被取消。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on_event: 收到 op=0 Dispatch 事件时的回调,参数为 (event_type, event_data)
|
||||||
|
on_ready: 连接就绪 (收到 READY) 时的回调
|
||||||
|
on_error: 发生错误时的回调
|
||||||
|
"""
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
reconnect_attempts = 0
|
||||||
|
max_reconnect_attempts = 100
|
||||||
|
backoff_delays = [1, 2, 5, 10, 30, 60]
|
||||||
|
rate_limit_delay = 60
|
||||||
|
|
||||||
|
# Cancel previous token refresh task if any
|
||||||
|
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||||
|
self._token_refresh_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._token_refresh_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._token_refresh_task = None
|
||||||
|
|
||||||
|
while reconnect_attempts <= max_reconnect_attempts:
|
||||||
|
heartbeat_interval = 45000
|
||||||
|
should_refresh_token = False
|
||||||
|
ws = None
|
||||||
|
heartbeat_task = None
|
||||||
|
|
||||||
|
# Refresh token if needed
|
||||||
|
if should_refresh_token:
|
||||||
|
self.access_token = ''
|
||||||
|
self.access_token_expiry_time = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws_url = await self.get_gateway_url()
|
||||||
|
await self.logger.info(f'Gateway URL obtained: {ws_url[:60]}...')
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
await self.logger.error(f'Failed to get gateway URL: {e}')
|
||||||
|
reconnect_attempts += 1
|
||||||
|
if '100017' in error_msg or '频率' in error_msg or 'Too many' in error_msg:
|
||||||
|
delay = rate_limit_delay
|
||||||
|
else:
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.logger.info('Connecting to WebSocket gateway...')
|
||||||
|
ws = await websockets.connect(ws_url)
|
||||||
|
await self.logger.info('WebSocket connected')
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'WebSocket connection failed: {e}')
|
||||||
|
reconnect_attempts += 1
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for raw_msg in ws:
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_msg)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.logger.error(f'Failed to parse message: {raw_msg}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
op = payload.get('op')
|
||||||
|
d = payload.get('d', {})
|
||||||
|
s = payload.get('s')
|
||||||
|
t = payload.get('t')
|
||||||
|
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
d = {}
|
||||||
|
|
||||||
|
if op == 10: # Hello
|
||||||
|
heartbeat_interval = d.get('heartbeat_interval', 45000)
|
||||||
|
await self.logger.info(f'Received Hello, heartbeat_interval={heartbeat_interval}ms')
|
||||||
|
|
||||||
|
# Send Identify or Resume
|
||||||
|
if session_id and last_seq > 0:
|
||||||
|
resume_payload = {
|
||||||
|
'op': 6,
|
||||||
|
'd': {
|
||||||
|
'token': f'QQBot {self.access_token}',
|
||||||
|
'session_id': session_id,
|
||||||
|
'seq': last_seq,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(resume_payload))
|
||||||
|
await self.logger.info(f'Sent Resume, session_id={session_id}, seq={last_seq}')
|
||||||
|
else:
|
||||||
|
identify_payload = {
|
||||||
|
'op': 2,
|
||||||
|
'd': {
|
||||||
|
'token': f'QQBot {self.access_token}',
|
||||||
|
'intents': self.FULL_INTENTS,
|
||||||
|
'shard': [0, 1],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(identify_payload))
|
||||||
|
await self.logger.info(f'Sent Identify, intents={self.FULL_INTENTS}')
|
||||||
|
|
||||||
|
# Start heartbeat
|
||||||
|
async def _heartbeat_loop(conn, interval_ms):
|
||||||
|
interval_sec = interval_ms / 1000.0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval_sec)
|
||||||
|
try:
|
||||||
|
hb_payload = {'op': 1, 'd': last_seq}
|
||||||
|
await conn.send(json.dumps(hb_payload))
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(ws, heartbeat_interval))
|
||||||
|
|
||||||
|
elif op == 0: # Dispatch
|
||||||
|
if s is not None:
|
||||||
|
last_seq = s
|
||||||
|
|
||||||
|
if t == 'READY':
|
||||||
|
session_id = d.get('session_id', '')
|
||||||
|
reconnect_attempts = 0
|
||||||
|
await self.logger.info(f'READY, session_id={session_id}')
|
||||||
|
if on_ready:
|
||||||
|
try:
|
||||||
|
result = on_ready()
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Track token refresh task to avoid leaks
|
||||||
|
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||||
|
self._token_refresh_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._token_refresh_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._token_refresh_task = asyncio.create_task(self._background_token_refresh())
|
||||||
|
|
||||||
|
elif t == 'RESUMED':
|
||||||
|
reconnect_attempts = 0
|
||||||
|
await self.logger.info('RESUMED')
|
||||||
|
|
||||||
|
else:
|
||||||
|
await self.logger.debug(f'Received event: {t}, seq={s}')
|
||||||
|
if on_event:
|
||||||
|
try:
|
||||||
|
result = on_event(t, d)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling event {t}: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
elif op == 11: # Heartbeat ACK
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif op == 7: # Reconnect
|
||||||
|
await self.logger.info('Received Reconnect directive')
|
||||||
|
break
|
||||||
|
|
||||||
|
elif op == 9: # Invalid Session
|
||||||
|
can_resume = d.get('can_resume', False)
|
||||||
|
await self.logger.warning(f'Invalid Session, can_resume={can_resume}')
|
||||||
|
if not can_resume:
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
should_refresh_token = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Connection closed normally (end of async for)
|
||||||
|
try:
|
||||||
|
close_code = ws.close_code
|
||||||
|
close_reason = ws.close_reason or ''
|
||||||
|
except Exception:
|
||||||
|
close_code = None
|
||||||
|
close_reason = ''
|
||||||
|
await self.logger.info(f'Connection closed, code={close_code}, reason={close_reason}')
|
||||||
|
|
||||||
|
if close_code == 4004:
|
||||||
|
should_refresh_token = True
|
||||||
|
elif close_code in (4006, 4007, 4009):
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
should_refresh_token = True
|
||||||
|
elif close_code == 4008:
|
||||||
|
reconnect_attempts += 1
|
||||||
|
delay = rate_limit_delay
|
||||||
|
await self.logger.info(
|
||||||
|
f'Rate limited, waiting {delay}s before reconnect (attempt {reconnect_attempts})'
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
elif close_code in (4914, 4915):
|
||||||
|
err = Exception(f'Bot disconnected/banned (close_code={close_code})')
|
||||||
|
if on_error:
|
||||||
|
await self._safe_callback(on_error, err)
|
||||||
|
return
|
||||||
|
elif close_code in (4900, 4901, 4902, 4903, 4904, 4905, 4906, 4907, 4908, 4909, 4910, 4911, 4912, 4913):
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
|
||||||
|
if close_code == 1000:
|
||||||
|
return
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Unexpected error in WebSocket loop: {traceback.format_exc()}')
|
||||||
|
finally:
|
||||||
|
if heartbeat_task:
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
try:
|
||||||
|
await heartbeat_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if ws:
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If we reach here, we need to reconnect
|
||||||
|
reconnect_attempts += 1
|
||||||
|
if reconnect_attempts > max_reconnect_attempts:
|
||||||
|
await self.logger.error(f'Max reconnect attempts ({max_reconnect_attempts}) reached, stopping')
|
||||||
|
if on_error:
|
||||||
|
await self._safe_callback(on_error, Exception('Max reconnect attempts reached'))
|
||||||
|
return
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
async def _safe_callback(self, callback, *args):
|
||||||
|
"""Safely invoke a callback, handling both sync and async functions."""
|
||||||
|
try:
|
||||||
|
result = callback(*args)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def connect_gateway_loop(
|
||||||
|
self,
|
||||||
|
on_event: Callable[[str, dict], Any],
|
||||||
|
on_ready: Optional[Callable[[], Any]] = None,
|
||||||
|
on_error: Optional[Callable[[Exception], Any]] = None,
|
||||||
|
):
|
||||||
|
"""持续重连的网关循环。"""
|
||||||
|
await self.connect_gateway(on_event, on_ready, on_error)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import traceback
|
|||||||
import uuid
|
import uuid
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Optional
|
import re
|
||||||
|
from typing import Any, Callable, Optional, Tuple
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -63,16 +64,25 @@ class StreamSession:
|
|||||||
# 缓存最近一次片段,处理重试或超时兜底
|
# 缓存最近一次片段,处理重试或超时兜底
|
||||||
last_chunk: Optional[StreamChunk] = None
|
last_chunk: Optional[StreamChunk] = None
|
||||||
|
|
||||||
|
# 反馈 ID,用于接收用户点赞/点踩反馈
|
||||||
|
feedback_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class StreamSessionManager:
|
class StreamSessionManager:
|
||||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||||
|
|
||||||
|
# Sessions with registered feedback_ids use a longer TTL to survive the
|
||||||
|
# full like → cancel → dislike feedback flow. Must align with the adapter's
|
||||||
|
# _stream_to_monitoring_msg TTL (wecombot.py).
|
||||||
|
_FEEDBACK_SESSION_TTL = 600 # 10 minutes
|
||||||
|
|
||||||
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
||||||
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
||||||
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
||||||
|
self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射
|
||||||
|
|
||||||
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
||||||
if not msg_id:
|
if not msg_id:
|
||||||
@@ -82,6 +92,32 @@ class StreamSessionManager:
|
|||||||
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
||||||
return self._sessions.get(stream_id)
|
return self._sessions.get(stream_id)
|
||||||
|
|
||||||
|
def get_session_by_feedback_id(self, feedback_id: str) -> Optional[StreamSession]:
|
||||||
|
"""根据 feedback_id 查找会话。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feedback_id: 企业微信反馈事件中的反馈 ID。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[StreamSession]: 找到的会话实例,未找到返回 None。
|
||||||
|
"""
|
||||||
|
if not feedback_id:
|
||||||
|
return None
|
||||||
|
stream_id = self._feedback_index.get(feedback_id)
|
||||||
|
if stream_id:
|
||||||
|
return self._sessions.get(stream_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def register_feedback_id(self, stream_id: str, feedback_id: str) -> None:
|
||||||
|
"""注册 feedback_id 与 stream_id 的映射。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream_id: 企业微信流式会话 ID。
|
||||||
|
feedback_id: 反馈 ID。
|
||||||
|
"""
|
||||||
|
if feedback_id and stream_id:
|
||||||
|
self._feedback_index[feedback_id] = stream_id
|
||||||
|
|
||||||
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
||||||
"""根据企业微信回调创建或获取会话。
|
"""根据企业微信回调创建或获取会话。
|
||||||
|
|
||||||
@@ -183,11 +219,17 @@ class StreamSessionManager:
|
|||||||
session.last_access = time.time()
|
session.last_access = time.time()
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""定期清理过期会话,防止队列与映射无上限累积。"""
|
"""定期清理过期会话,防止队列与映射无上限累积。
|
||||||
|
|
||||||
|
已注册 feedback_id 的会话使用更长的 TTL,确保用户在点赞/取消/点踩流程中
|
||||||
|
不会因为 session 被提前清除而丢失上下文信息。
|
||||||
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
expired: list[str] = []
|
expired: list[str] = []
|
||||||
for stream_id, session in self._sessions.items():
|
for stream_id, session in self._sessions.items():
|
||||||
if now - session.last_access > self.ttl:
|
# Sessions with registered feedback_ids use a longer TTL
|
||||||
|
effective_ttl = self._FEEDBACK_SESSION_TTL if session.feedback_id else self.ttl
|
||||||
|
if now - session.last_access > effective_ttl:
|
||||||
expired.append(stream_id)
|
expired.append(stream_id)
|
||||||
|
|
||||||
for stream_id in expired:
|
for stream_id in expired:
|
||||||
@@ -197,54 +239,144 @@ class StreamSessionManager:
|
|||||||
msg_id = session.msg_id
|
msg_id = session.msg_id
|
||||||
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
||||||
self._msg_index.pop(msg_id, None)
|
self._msg_index.pop(msg_id, None)
|
||||||
|
# Clean up feedback index for expired sessions
|
||||||
|
if session.feedback_id:
|
||||||
|
self._feedback_index.pop(session.feedback_id, None)
|
||||||
|
|
||||||
|
|
||||||
async def download_encrypted_file(download_url: str, encoding_aes_key: str, logger: EventLogger) -> Optional[str]:
|
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||||
"""Download an AES-encrypted file from WeChat Work and return as data URI.
|
"""Decrypt AES-256-CBC encrypted file data.
|
||||||
|
|
||||||
|
Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
download_url: The encrypted file download URL.
|
encrypted_data: The raw encrypted bytes.
|
||||||
encoding_aes_key: The AES key used for decryption (base64-encoded, without trailing '=').
|
aes_key_str: Base64-encoded AES key (may lack padding).
|
||||||
logger: Logger instance.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A data URI string (e.g. 'data:image/jpeg;base64,...') or None on failure.
|
Decrypted bytes with PKCS#7 padding removed.
|
||||||
"""
|
"""
|
||||||
if not download_url:
|
if not encrypted_data:
|
||||||
return None
|
raise ValueError('encrypted_data is empty')
|
||||||
async with httpx.AsyncClient() as client:
|
if not aes_key_str:
|
||||||
response = await client.get(download_url)
|
raise ValueError('aes_key is empty')
|
||||||
if response.status_code != 200:
|
|
||||||
await logger.error(f'failed to get file: {response.text}')
|
|
||||||
return None
|
|
||||||
encrypted_bytes = response.content
|
|
||||||
|
|
||||||
aes_key = base64.b64decode(encoding_aes_key + '=')
|
# Python's base64.b64decode requires proper padding (length % 4 == 0).
|
||||||
iv = aes_key[:16]
|
# Node.js Buffer.from tolerates missing '=', so we must pad manually.
|
||||||
|
remainder = len(aes_key_str) % 4
|
||||||
|
if remainder != 0:
|
||||||
|
aes_key_str = aes_key_str + '=' * (4 - remainder)
|
||||||
|
key = base64.b64decode(aes_key_str)
|
||||||
|
|
||||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
iv = key[:16]
|
||||||
decrypted = cipher.decrypt(encrypted_bytes)
|
|
||||||
|
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||||
|
|
||||||
|
# Ensure encrypted data is aligned to AES block size (16 bytes).
|
||||||
|
# Node.js setAutoPadding(false) silently handles unaligned data,
|
||||||
|
# but PyCryptodome will raise an error.
|
||||||
|
block_size = 16
|
||||||
|
data_remainder = len(encrypted_data) % block_size
|
||||||
|
if data_remainder != 0:
|
||||||
|
encrypted_data = encrypted_data + b'\x00' * (block_size - data_remainder)
|
||||||
|
|
||||||
|
decrypted = cipher.decrypt(encrypted_data)
|
||||||
|
|
||||||
|
# Remove PKCS#7 padding with validation
|
||||||
|
if len(decrypted) == 0:
|
||||||
|
raise ValueError('Decrypted data is empty')
|
||||||
|
|
||||||
pad_len = decrypted[-1]
|
pad_len = decrypted[-1]
|
||||||
decrypted = decrypted[:-pad_len]
|
if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted):
|
||||||
|
raise ValueError(f'Invalid PKCS#7 padding value: {pad_len}')
|
||||||
|
|
||||||
if decrypted.startswith(b'\xff\xd8'):
|
# Verify all padding bytes are consistent
|
||||||
|
for i in range(len(decrypted) - pad_len, len(decrypted)):
|
||||||
|
if decrypted[i] != pad_len:
|
||||||
|
raise ValueError('Invalid PKCS#7 padding: padding bytes mismatch')
|
||||||
|
|
||||||
|
return decrypted[: len(decrypted) - pad_len]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_filename(content_disposition: str) -> Optional[str]:
|
||||||
|
"""Extract filename from a Content-Disposition header value."""
|
||||||
|
if not content_disposition:
|
||||||
|
return None
|
||||||
|
# RFC 5987: filename*=UTF-8''xxx
|
||||||
|
utf8_match = re.search(r"filename\*=UTF-8''([^;\s]+)", content_disposition, re.IGNORECASE)
|
||||||
|
if utf8_match:
|
||||||
|
return unquote(utf8_match.group(1))
|
||||||
|
# Standard: filename="xxx" or filename=xxx
|
||||||
|
match = re.search(r'filename="?([^";\s]+)"?', content_disposition, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return unquote(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes_to_data_uri(data: bytes) -> str:
|
||||||
|
"""Convert raw bytes to a data URI with auto-detected MIME type."""
|
||||||
|
if data.startswith(b'\xff\xd8'):
|
||||||
mime_type = 'image/jpeg'
|
mime_type = 'image/jpeg'
|
||||||
elif decrypted.startswith(b'\x89PNG'):
|
elif data.startswith(b'\x89PNG'):
|
||||||
mime_type = 'image/png'
|
mime_type = 'image/png'
|
||||||
elif decrypted.startswith((b'GIF87a', b'GIF89a')):
|
elif data.startswith((b'GIF87a', b'GIF89a')):
|
||||||
mime_type = 'image/gif'
|
mime_type = 'image/gif'
|
||||||
elif decrypted.startswith(b'BM'):
|
elif data.startswith(b'BM'):
|
||||||
mime_type = 'image/bmp'
|
mime_type = 'image/bmp'
|
||||||
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'):
|
elif data.startswith(b'II*\x00') or data.startswith(b'MM\x00*'):
|
||||||
mime_type = 'image/tiff'
|
mime_type = 'image/tiff'
|
||||||
|
elif data[:4] == b'%PDF':
|
||||||
|
mime_type = 'application/pdf'
|
||||||
|
elif data[:4] == b'PK\x03\x04':
|
||||||
|
mime_type = 'application/zip'
|
||||||
else:
|
else:
|
||||||
mime_type = 'application/octet-stream'
|
mime_type = 'application/octet-stream'
|
||||||
|
|
||||||
base64_str = base64.b64encode(decrypted).decode('utf-8')
|
base64_str = base64.b64encode(data).decode('utf-8')
|
||||||
return f'data:{mime_type};base64,{base64_str}'
|
return f'data:{mime_type};base64,{base64_str}'
|
||||||
|
|
||||||
|
|
||||||
|
async def download_encrypted_file(
|
||||||
|
download_url: str, aes_key: str, logger: EventLogger
|
||||||
|
) -> Tuple[Optional[bytes], Optional[str]]:
|
||||||
|
"""Download an AES-encrypted file from WeChat Work and decrypt it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_url: The encrypted file download URL.
|
||||||
|
aes_key: The AES key for decryption (base64-encoded, per-message aeskey
|
||||||
|
or platform EncodingAESKey).
|
||||||
|
logger: Logger instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (decrypted_bytes, filename) or (None, None) on failure.
|
||||||
|
"""
|
||||||
|
if not download_url:
|
||||||
|
return None, None
|
||||||
|
if not aes_key:
|
||||||
|
await logger.error('download_encrypted_file: aes_key is empty, cannot decrypt')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
filename: Optional[str] = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(download_url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}')
|
||||||
|
return None, None
|
||||||
|
encrypted_bytes = response.content
|
||||||
|
filename = _extract_filename(response.headers.get('content-disposition', ''))
|
||||||
|
except Exception:
|
||||||
|
await logger.error(f'Failed to download file: {traceback.format_exc()}')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
decrypted = _decrypt_file(encrypted_bytes, aes_key)
|
||||||
|
return decrypted, filename
|
||||||
|
except Exception:
|
||||||
|
await logger.error(f'Failed to decrypt file: {traceback.format_exc()}')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def parse_wecom_bot_message(
|
async def parse_wecom_bot_message(
|
||||||
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
|
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -273,10 +405,22 @@ async def parse_wecom_bot_message(
|
|||||||
|
|
||||||
max_inline_file_size = 5 * 1024 * 1024
|
max_inline_file_size = 5 * 1024 * 1024
|
||||||
|
|
||||||
async def _safe_download(url: str):
|
async def _safe_download(url: str, per_msg_aeskey: str = '') -> Tuple[Optional[bytes], Optional[str]]:
|
||||||
|
"""Download and decrypt a file, preferring per-message aeskey over platform key."""
|
||||||
if not url:
|
if not url:
|
||||||
|
return None, None
|
||||||
|
key = per_msg_aeskey or encoding_aes_key
|
||||||
|
if not key:
|
||||||
|
await logger.warning('No AES key available for file decryption, skipping download')
|
||||||
|
return None, None
|
||||||
|
return await download_encrypted_file(url, key, logger)
|
||||||
|
|
||||||
|
async def _safe_download_as_data_uri(url: str, per_msg_aeskey: str = '') -> Optional[str]:
|
||||||
|
"""Download, decrypt, and convert to data URI for backward compatibility."""
|
||||||
|
data, _filename = await _safe_download(url, per_msg_aeskey)
|
||||||
|
if data:
|
||||||
|
return _bytes_to_data_uri(data)
|
||||||
return None
|
return None
|
||||||
return await download_encrypted_file(url, encoding_aes_key, logger)
|
|
||||||
|
|
||||||
if msg_type == 'text':
|
if msg_type == 'text':
|
||||||
message_data['content'] = msg_json.get('text', {}).get('content')
|
message_data['content'] = msg_json.get('text', {}).get('content')
|
||||||
@@ -285,14 +429,17 @@ async def parse_wecom_bot_message(
|
|||||||
'content', ''
|
'content', ''
|
||||||
)
|
)
|
||||||
elif msg_type == 'image':
|
elif msg_type == 'image':
|
||||||
picurl = msg_json.get('image', {}).get('url', '')
|
image_info = msg_json.get('image', {})
|
||||||
base64_data = await _safe_download(picurl)
|
picurl = image_info.get('url', '')
|
||||||
|
per_msg_aeskey = image_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(picurl, per_msg_aeskey)
|
||||||
if base64_data:
|
if base64_data:
|
||||||
message_data['picurl'] = base64_data
|
message_data['picurl'] = base64_data
|
||||||
message_data['images'] = [base64_data]
|
message_data['images'] = [base64_data]
|
||||||
elif msg_type == 'voice':
|
elif msg_type == 'voice':
|
||||||
voice_info = msg_json.get('voice', {}) or {}
|
voice_info = msg_json.get('voice', {}) or {}
|
||||||
download_url = voice_info.get('url')
|
download_url = voice_info.get('url')
|
||||||
|
per_msg_aeskey = voice_info.get('aeskey', '')
|
||||||
message_data['voice'] = {
|
message_data['voice'] = {
|
||||||
'url': download_url,
|
'url': download_url,
|
||||||
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||||
@@ -301,13 +448,14 @@ async def parse_wecom_bot_message(
|
|||||||
}
|
}
|
||||||
if voice_info.get('content'):
|
if voice_info.get('content'):
|
||||||
message_data['content'] = voice_info.get('content')
|
message_data['content'] = voice_info.get('content')
|
||||||
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
# if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
||||||
voice_base64 = await _safe_download(download_url)
|
# voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
if voice_base64:
|
# if voice_base64:
|
||||||
message_data['voice']['base64'] = voice_base64
|
# message_data['voice']['base64'] = voice_base64
|
||||||
elif msg_type == 'video':
|
elif msg_type == 'video':
|
||||||
video_info = msg_json.get('video', {}) or {}
|
video_info = msg_json.get('video', {}) or {}
|
||||||
download_url = video_info.get('url')
|
download_url = video_info.get('url')
|
||||||
|
per_msg_aeskey = video_info.get('aeskey', '')
|
||||||
video_data = {
|
video_data = {
|
||||||
'url': download_url,
|
'url': download_url,
|
||||||
'filesize': video_info.get('filesize') or video_info.get('size'),
|
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||||
@@ -315,14 +463,17 @@ async def parse_wecom_bot_message(
|
|||||||
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
'filename': video_info.get('filename') or video_info.get('name'),
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
}
|
}
|
||||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
# if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
video_base64 = await _safe_download(download_url)
|
# video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
if video_base64:
|
# if video_base64:
|
||||||
video_data['base64'] = video_base64
|
# video_data['base64'] = video_base64
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
message_data['video'] = video_data
|
message_data['video'] = video_data
|
||||||
elif msg_type == 'file':
|
elif msg_type == 'file':
|
||||||
file_info = msg_json.get('file', {}) or {}
|
file_info = msg_json.get('file', {}) or {}
|
||||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
per_msg_aeskey = file_info.get('aeskey', '')
|
||||||
file_data = {
|
file_data = {
|
||||||
'filename': file_info.get('filename') or file_info.get('name'),
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
'filesize': file_info.get('filesize') or file_info.get('size'),
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
@@ -331,10 +482,15 @@ async def parse_wecom_bot_message(
|
|||||||
'download_url': download_url,
|
'download_url': download_url,
|
||||||
'extra': file_info,
|
'extra': file_info,
|
||||||
}
|
}
|
||||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
# if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
file_base64 = await _safe_download(download_url)
|
# file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
||||||
if file_base64:
|
# if file_bytes:
|
||||||
file_data['base64'] = file_base64
|
# file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||||
|
# if dl_filename and not file_data.get('filename'):
|
||||||
|
# file_data['filename'] = dl_filename
|
||||||
|
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
message_data['file'] = file_data
|
message_data['file'] = file_data
|
||||||
elif msg_type == 'link':
|
elif msg_type == 'link':
|
||||||
message_data['link'] = msg_json.get('link', {})
|
message_data['link'] = msg_json.get('link', {})
|
||||||
@@ -355,13 +511,16 @@ async def parse_wecom_bot_message(
|
|||||||
if item_type == 'text':
|
if item_type == 'text':
|
||||||
texts.append(item.get('text', {}).get('content', ''))
|
texts.append(item.get('text', {}).get('content', ''))
|
||||||
elif item_type == 'image':
|
elif item_type == 'image':
|
||||||
img_url = item.get('image', {}).get('url')
|
img_info = item.get('image', {})
|
||||||
base64_data = await _safe_download(img_url)
|
img_url = img_info.get('url')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
if base64_data:
|
if base64_data:
|
||||||
images.append(base64_data)
|
images.append(base64_data)
|
||||||
elif item_type == 'file':
|
elif item_type == 'file':
|
||||||
file_info = item.get('file', {}) or {}
|
file_info = item.get('file', {}) or {}
|
||||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
file_data = {
|
file_data = {
|
||||||
'filename': file_info.get('filename') or file_info.get('name'),
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
'filesize': file_info.get('filesize') or file_info.get('size'),
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
@@ -371,13 +530,16 @@ async def parse_wecom_bot_message(
|
|||||||
'extra': file_info,
|
'extra': file_info,
|
||||||
}
|
}
|
||||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
file_base64 = await _safe_download(download_url)
|
file_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
|
||||||
if file_base64:
|
if file_bytes:
|
||||||
file_data['base64'] = file_base64
|
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||||
|
if dl_filename and not file_data.get('filename'):
|
||||||
|
file_data['filename'] = dl_filename
|
||||||
files.append(file_data)
|
files.append(file_data)
|
||||||
elif item_type == 'voice':
|
elif item_type == 'voice':
|
||||||
voice_info = item.get('voice', {}) or {}
|
voice_info = item.get('voice', {}) or {}
|
||||||
download_url = voice_info.get('url')
|
download_url = voice_info.get('url')
|
||||||
|
item_aeskey = voice_info.get('aeskey', '')
|
||||||
voice_data = {
|
voice_data = {
|
||||||
'url': download_url,
|
'url': download_url,
|
||||||
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||||
@@ -387,13 +549,14 @@ async def parse_wecom_bot_message(
|
|||||||
if voice_info.get('content'):
|
if voice_info.get('content'):
|
||||||
texts.append(voice_info.get('content'))
|
texts.append(voice_info.get('content'))
|
||||||
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
|
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
voice_base64 = await _safe_download(download_url)
|
voice_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
|
||||||
if voice_base64:
|
if voice_base64:
|
||||||
voice_data['base64'] = voice_base64
|
voice_data['base64'] = voice_base64
|
||||||
voices.append(voice_data)
|
voices.append(voice_data)
|
||||||
elif item_type == 'video':
|
elif item_type == 'video':
|
||||||
video_info = item.get('video', {}) or {}
|
video_info = item.get('video', {}) or {}
|
||||||
download_url = video_info.get('url')
|
download_url = video_info.get('url')
|
||||||
|
item_aeskey = video_info.get('aeskey', '')
|
||||||
video_data = {
|
video_data = {
|
||||||
'url': download_url,
|
'url': download_url,
|
||||||
'filesize': video_info.get('filesize') or video_info.get('size'),
|
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||||
@@ -402,7 +565,7 @@ async def parse_wecom_bot_message(
|
|||||||
'filename': video_info.get('filename') or video_info.get('name'),
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
}
|
}
|
||||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
video_base64 = await _safe_download(download_url)
|
video_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
|
||||||
if video_base64:
|
if video_base64:
|
||||||
video_data['base64'] = video_base64
|
video_data['base64'] = video_base64
|
||||||
videos.append(video_data)
|
videos.append(video_data)
|
||||||
@@ -443,6 +606,120 @@ async def parse_wecom_bot_message(
|
|||||||
if msg_json.get('aibotid'):
|
if msg_json.get('aibotid'):
|
||||||
message_data['aibotid'] = msg_json.get('aibotid', '')
|
message_data['aibotid'] = msg_json.get('aibotid', '')
|
||||||
|
|
||||||
|
# Handle quote (referenced message) - important for group chat file references
|
||||||
|
quote_info = msg_json.get('quote')
|
||||||
|
if quote_info:
|
||||||
|
quote_data: dict[str, Any] = {}
|
||||||
|
quote_type = quote_info.get('msgtype', '')
|
||||||
|
quote_data['msgtype'] = quote_type
|
||||||
|
|
||||||
|
if quote_type == 'text':
|
||||||
|
quote_data['content'] = quote_info.get('text', {}).get('content', '')
|
||||||
|
elif quote_type == 'image':
|
||||||
|
img_info = quote_info.get('image', {})
|
||||||
|
img_url = img_info.get('url', '')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
|
if base64_data:
|
||||||
|
quote_data['picurl'] = base64_data
|
||||||
|
quote_data['images'] = [base64_data]
|
||||||
|
elif quote_type == 'file':
|
||||||
|
file_info = quote_info.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['file'] = file_data
|
||||||
|
elif quote_type == 'voice':
|
||||||
|
voice_info = quote_info.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
item_aeskey = voice_info.get('aeskey', '')
|
||||||
|
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'):
|
||||||
|
quote_data['content'] = voice_info.get('content')
|
||||||
|
# Same as private chat: append aeskey to url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
voice_data['url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['voice'] = voice_data
|
||||||
|
elif quote_type == 'video':
|
||||||
|
video_info = quote_info.get('video', {}) or {}
|
||||||
|
download_url = video_info.get('url')
|
||||||
|
item_aeskey = video_info.get('aeskey', '')
|
||||||
|
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'),
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
video_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['video'] = video_data
|
||||||
|
elif quote_type == 'link':
|
||||||
|
quote_data['link'] = quote_info.get('link', {})
|
||||||
|
link = quote_data['link']
|
||||||
|
title = link.get('title', '')
|
||||||
|
desc = link.get('description') or link.get('digest', '')
|
||||||
|
quote_data['content'] = '\n'.join(filter(None, [title, desc]))
|
||||||
|
elif quote_type == 'mixed':
|
||||||
|
# Handle mixed type in quote (text + images + files etc.)
|
||||||
|
items = quote_info.get('mixed', {}).get('msg_item', [])
|
||||||
|
texts = []
|
||||||
|
images = []
|
||||||
|
files = []
|
||||||
|
for item in items:
|
||||||
|
item_type = item.get('msgtype')
|
||||||
|
if item_type == 'text':
|
||||||
|
texts.append(item.get('text', {}).get('content', ''))
|
||||||
|
elif item_type == 'image':
|
||||||
|
img_info = item.get('image', {})
|
||||||
|
img_url = img_info.get('url')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
|
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')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
files.append(file_data)
|
||||||
|
if texts:
|
||||||
|
quote_data['content'] = ' '.join(texts)
|
||||||
|
if images:
|
||||||
|
quote_data['images'] = images
|
||||||
|
quote_data['picurl'] = images[0]
|
||||||
|
if files:
|
||||||
|
quote_data['files'] = files
|
||||||
|
quote_data['file'] = files[0]
|
||||||
|
|
||||||
|
message_data['quote'] = quote_data
|
||||||
|
|
||||||
return message_data
|
return message_data
|
||||||
|
|
||||||
|
|
||||||
@@ -483,14 +760,27 @@ class WecomBotClient:
|
|||||||
self.stream_sessions = StreamSessionManager(logger=logger)
|
self.stream_sessions = StreamSessionManager(logger=logger)
|
||||||
self.stream_poll_timeout = 0.5
|
self.stream_poll_timeout = 0.5
|
||||||
|
|
||||||
|
self._feedback_callback: Optional[Callable] = None
|
||||||
|
|
||||||
|
def set_feedback_callback(self, callback: Callable) -> None:
|
||||||
|
"""设置反馈回调函数。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: 反馈回调函数,签名: async def callback(feedback_id, feedback_type, feedback_content, inaccurate_reasons, session)
|
||||||
|
"""
|
||||||
|
self._feedback_callback = callback
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
|
def _build_stream_payload(
|
||||||
|
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""按照企业微信协议拼装返回报文。
|
"""按照企业微信协议拼装返回报文。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stream_id: 企业微信会话 ID。
|
stream_id: 企业微信会话 ID。
|
||||||
content: 推送的文本内容。
|
content: 推送的文本内容。
|
||||||
finish: 是否为最终片段。
|
finish: 是否为最终片段。
|
||||||
|
feedback_id: 反馈 ID,用于接收用户点赞/点踩反馈。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: 可直接加密返回的 payload。
|
dict[str, Any]: 可直接加密返回的 payload。
|
||||||
@@ -498,13 +788,16 @@ class WecomBotClient:
|
|||||||
Example:
|
Example:
|
||||||
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
||||||
"""
|
"""
|
||||||
return {
|
stream_payload = {
|
||||||
'msgtype': 'stream',
|
|
||||||
'stream': {
|
|
||||||
'id': stream_id,
|
'id': stream_id,
|
||||||
'finish': finish,
|
'finish': finish,
|
||||||
'content': content,
|
'content': content,
|
||||||
},
|
}
|
||||||
|
if feedback_id:
|
||||||
|
stream_payload['feedback'] = {'id': feedback_id}
|
||||||
|
return {
|
||||||
|
'msgtype': 'stream',
|
||||||
|
'stream': stream_payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
@@ -560,9 +853,14 @@ class WecomBotClient:
|
|||||||
"""
|
"""
|
||||||
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
||||||
|
|
||||||
|
feedback_id = str(uuid.uuid4())
|
||||||
|
session.feedback_id = feedback_id
|
||||||
|
self.stream_sessions.register_feedback_id(session.stream_id, feedback_id)
|
||||||
|
|
||||||
message_data = await self.get_message(msg_json)
|
message_data = await self.get_message(msg_json)
|
||||||
if message_data:
|
if message_data:
|
||||||
message_data['stream_id'] = session.stream_id
|
message_data['stream_id'] = session.stream_id
|
||||||
|
message_data['feedback_id'] = feedback_id
|
||||||
try:
|
try:
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -571,7 +869,7 @@ class WecomBotClient:
|
|||||||
if is_new:
|
if is_new:
|
||||||
asyncio.create_task(self._dispatch_event(event))
|
asyncio.create_task(self._dispatch_event(event))
|
||||||
|
|
||||||
payload = self._build_stream_payload(session.stream_id, '', False)
|
payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
|
||||||
return await self._encrypt_and_reply(payload, nonce)
|
return await self._encrypt_and_reply(payload, nonce)
|
||||||
|
|
||||||
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
@@ -696,11 +994,81 @@ class WecomBotClient:
|
|||||||
|
|
||||||
msg_json = json.loads(decrypted_xml)
|
msg_json = json.loads(decrypted_xml)
|
||||||
|
|
||||||
|
event = msg_json.get('event', {})
|
||||||
|
event_type = event.get('eventtype', '')
|
||||||
|
|
||||||
|
if event_type == 'feedback_event':
|
||||||
|
return await self._handle_feedback_event(msg_json, nonce)
|
||||||
|
|
||||||
if msg_json.get('msgtype') == 'stream':
|
if msg_json.get('msgtype') == 'stream':
|
||||||
return await self._handle_post_followup_response(msg_json, nonce)
|
return await self._handle_post_followup_response(msg_json, nonce)
|
||||||
|
|
||||||
return await self._handle_post_initial_response(msg_json, nonce)
|
return await self._handle_post_initial_response(msg_json, nonce)
|
||||||
|
|
||||||
|
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
|
"""处理企业微信用户反馈事件(点赞/点踩)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_json: 解密后的企业微信反馈事件 JSON。
|
||||||
|
nonce: 企业微信回调参数 nonce。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[Response, int]: Quart Response 及状态码。
|
||||||
|
|
||||||
|
Note:
|
||||||
|
企业微信协议要求:反馈事件目前仅支持回复空包。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
feedback_event = msg_json.get('event', {}).get('feedback_event', {})
|
||||||
|
feedback_id = feedback_event.get('id', '')
|
||||||
|
feedback_type = feedback_event.get('type', 0)
|
||||||
|
feedback_content = feedback_event.get('content', '')
|
||||||
|
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
||||||
|
|
||||||
|
await self.logger.info(
|
||||||
|
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
|
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||||
|
)
|
||||||
|
|
||||||
|
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||||
|
|
||||||
|
if session:
|
||||||
|
await self.logger.info(
|
||||||
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈')
|
||||||
|
|
||||||
|
# Dispatch feedback event regardless of session availability
|
||||||
|
for handler in self._message_handlers.get('feedback', []):
|
||||||
|
try:
|
||||||
|
await handler(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
if self._feedback_callback:
|
||||||
|
try:
|
||||||
|
await self._feedback_callback(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
return await self._encrypt_and_reply({}, nonce)
|
||||||
|
|
||||||
async def get_message(self, msg_json):
|
async def get_message(self, msg_json):
|
||||||
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
||||||
|
|
||||||
@@ -769,8 +1137,20 @@ class WecomBotClient:
|
|||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def on_feedback(self):
|
||||||
|
def decorator(func: Callable):
|
||||||
|
if 'feedback' not in self._message_handlers:
|
||||||
|
self._message_handlers['feedback'] = []
|
||||||
|
self._message_handlers['feedback'].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||||
return await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||||
|
if data:
|
||||||
|
return _bytes_to_data_uri(data)
|
||||||
|
return None
|
||||||
|
|
||||||
async def run_task(self, host: str, port: int, *args, **kwargs):
|
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -133,3 +133,24 @@ class WecomBotEvent(dict):
|
|||||||
AI Bot ID
|
AI Bot ID
|
||||||
"""
|
"""
|
||||||
return self.get('aibotid', '')
|
return self.get('aibotid', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def feedback_id(self) -> str:
|
||||||
|
"""
|
||||||
|
反馈 ID,用于关联用户点赞/点踩反馈
|
||||||
|
"""
|
||||||
|
return self.get('feedback_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_id(self) -> str:
|
||||||
|
"""
|
||||||
|
流式消息 ID
|
||||||
|
"""
|
||||||
|
return self.get('stream_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quote(self):
|
||||||
|
"""
|
||||||
|
引用消息信息(群聊中用户引用其他消息时返回)
|
||||||
|
"""
|
||||||
|
return self.get('quote', {})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any, Callable, Optional
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message
|
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||||
@@ -96,6 +96,12 @@ class WecomBotWsClient:
|
|||||||
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
||||||
# Dedup: skip sending when content hasn't changed
|
# Dedup: skip sending when content hasn't changed
|
||||||
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
||||||
|
# Stream session info for feedback tracking
|
||||||
|
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
|
||||||
|
# Feedback tracking: feedback_id -> session info
|
||||||
|
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
|
||||||
|
# msg_id -> feedback_id (for associating feedback with message)
|
||||||
|
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
|
||||||
|
|
||||||
# ── Public API ──────────────────────────────────────────────────
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -164,12 +170,27 @@ class WecomBotWsClient:
|
|||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def on_feedback(self) -> Callable:
|
||||||
|
"""Decorator to register a feedback event handler.
|
||||||
|
|
||||||
|
Same interface as WecomBotClient.on_feedback for compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable):
|
||||||
|
if 'feedback' not in self._message_handlers:
|
||||||
|
self._message_handlers['feedback'] = []
|
||||||
|
self._message_handlers['feedback'].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
async def reply_stream(
|
async def reply_stream(
|
||||||
self,
|
self,
|
||||||
req_id: str,
|
req_id: str,
|
||||||
stream_id: str,
|
stream_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
finish: bool = False,
|
finish: bool = False,
|
||||||
|
feedback_id: str = '',
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""Send a streaming reply frame.
|
"""Send a streaming reply frame.
|
||||||
|
|
||||||
@@ -178,17 +199,22 @@ class WecomBotWsClient:
|
|||||||
stream_id: The stream ID for this streaming session.
|
stream_id: The stream ID for this streaming session.
|
||||||
content: The content to send (supports Markdown).
|
content: The content to send (supports Markdown).
|
||||||
finish: Whether this is the final chunk.
|
finish: Whether this is the final chunk.
|
||||||
|
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The ACK frame dict, or None on failure.
|
The ACK frame dict, or None on failure.
|
||||||
"""
|
"""
|
||||||
body = {
|
stream_payload = {
|
||||||
'msgtype': 'stream',
|
|
||||||
'stream': {
|
|
||||||
'id': stream_id,
|
'id': stream_id,
|
||||||
'finish': finish,
|
'finish': finish,
|
||||||
'content': content,
|
'content': content,
|
||||||
},
|
}
|
||||||
|
if feedback_id:
|
||||||
|
stream_payload['feedback'] = {'id': feedback_id}
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'msgtype': 'stream',
|
||||||
|
'stream': stream_payload,
|
||||||
}
|
}
|
||||||
return await self._send_reply(req_id, body)
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
@@ -253,11 +279,23 @@ class WecomBotWsClient:
|
|||||||
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
||||||
if not is_final and content == self._stream_last_content.get(msg_id):
|
if not is_final and content == self._stream_last_content.get(msg_id):
|
||||||
return True
|
return True
|
||||||
await self.reply_stream(req_id, stream_id, content, finish=is_final)
|
|
||||||
|
# Generate feedback_id for final chunk
|
||||||
|
feedback_id = ''
|
||||||
|
if is_final:
|
||||||
|
feedback_id = _generate_req_id('feedback')
|
||||||
|
self._msg_feedback_ids[msg_id] = feedback_id
|
||||||
|
# Store session info for feedback tracking
|
||||||
|
session_info = self._stream_sessions.get(msg_id)
|
||||||
|
if session_info:
|
||||||
|
self._feedback_sessions[feedback_id] = session_info
|
||||||
|
|
||||||
|
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
|
||||||
self._stream_last_content[msg_id] = content
|
self._stream_last_content[msg_id] = content
|
||||||
if is_final:
|
if is_final:
|
||||||
self._stream_ids.pop(msg_id, None)
|
self._stream_ids.pop(msg_id, None)
|
||||||
self._stream_last_content.pop(msg_id, None)
|
self._stream_last_content.pop(msg_id, None)
|
||||||
|
self._stream_sessions.pop(msg_id, None)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
||||||
@@ -445,6 +483,15 @@ class WecomBotWsClient:
|
|||||||
msg_id = message_data.get('msgid', '')
|
msg_id = message_data.get('msgid', '')
|
||||||
if msg_id:
|
if msg_id:
|
||||||
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
||||||
|
# Store session info for feedback tracking
|
||||||
|
self._stream_sessions[msg_id] = {
|
||||||
|
'req_id': req_id,
|
||||||
|
'stream_id': stream_id,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'user_id': message_data.get('userid', ''),
|
||||||
|
'chat_id': message_data.get('chatid', ''),
|
||||||
|
'chat_type': message_data.get('type', 'single'),
|
||||||
|
}
|
||||||
message_data['stream_id'] = stream_id
|
message_data['stream_id'] = stream_id
|
||||||
message_data['req_id'] = req_id
|
message_data['req_id'] = req_id
|
||||||
|
|
||||||
@@ -454,7 +501,7 @@ class WecomBotWsClient:
|
|||||||
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _handle_event_callback(self, frame: dict):
|
async def _handle_event_callback(self, frame: dict):
|
||||||
"""Handle an incoming event callback frame (enter_chat, template_card_event, etc.)."""
|
"""Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event)."""
|
||||||
try:
|
try:
|
||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
@@ -479,14 +526,54 @@ class WecomBotWsClient:
|
|||||||
if body.get('chatid'):
|
if body.get('chatid'):
|
||||||
message_data['chatid'] = body.get('chatid', '')
|
message_data['chatid'] = body.get('chatid', '')
|
||||||
|
|
||||||
|
if event_type == 'feedback_event':
|
||||||
|
feedback_event = event_info.get('feedback_event', {})
|
||||||
|
feedback_id = feedback_event.get('id', '')
|
||||||
|
feedback_type = feedback_event.get('type', 0)
|
||||||
|
feedback_content = feedback_event.get('content', '')
|
||||||
|
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
||||||
|
|
||||||
|
await self.logger.info(
|
||||||
|
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
|
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up session by feedback_id
|
||||||
|
session_info = self._feedback_sessions.get(feedback_id)
|
||||||
|
session = None
|
||||||
|
if session_info:
|
||||||
|
session = StreamSession(
|
||||||
|
stream_id=session_info.get('stream_id', ''),
|
||||||
|
msg_id=session_info.get('msg_id', ''),
|
||||||
|
chat_id=session_info.get('chat_id') or None,
|
||||||
|
user_id=session_info.get('user_id') or None,
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
)
|
||||||
|
await self.logger.info(
|
||||||
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
|
||||||
|
|
||||||
|
for handler in self._message_handlers.get('feedback', []):
|
||||||
|
try:
|
||||||
|
await handler(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
|
||||||
# Dispatch to event-specific handlers
|
|
||||||
if event_type in self._message_handlers:
|
if event_type in self._message_handlers:
|
||||||
for handler in self._message_handlers[event_type]:
|
for handler in self._message_handlers[event_type]:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
||||||
# Also dispatch to generic 'event' handlers
|
|
||||||
if 'event' in self._message_handlers:
|
if 'event' in self._message_handlers:
|
||||||
for handler in self._message_handlers['event']:
|
for handler in self._message_handlers['event']:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|||||||
4
src/langbot/libs/weknora_api/__init__.py
Normal file
4
src/langbot/libs/weknora_api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .client import AsyncWeKnoraClient
|
||||||
|
from .errors import WeKnoraAPIError
|
||||||
|
|
||||||
|
__all__ = ['AsyncWeKnoraClient', 'WeKnoraAPIError']
|
||||||
180
src/langbot/libs/weknora_api/client.py
Normal file
180
src/langbot/libs/weknora_api/client.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import typing
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .errors import WeKnoraAPIError
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncWeKnoraClient:
|
||||||
|
"""WeKnora API 客户端"""
|
||||||
|
|
||||||
|
api_key: str
|
||||||
|
base_url: str
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str = 'http://localhost:80/api/v1',
|
||||||
|
) -> None:
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base_url = base_url
|
||||||
|
|
||||||
|
async def create_session(
|
||||||
|
self,
|
||||||
|
title: str = '',
|
||||||
|
description: str = '',
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> str:
|
||||||
|
"""创建会话,返回 session_id"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
payload: dict[str, typing.Any] = {}
|
||||||
|
if title:
|
||||||
|
payload['title'] = title
|
||||||
|
if description:
|
||||||
|
payload['description'] = description
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/sessions',
|
||||||
|
headers={
|
||||||
|
'X-API-Key': self.api_key,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
raise WeKnoraAPIError(f'{response.status_code} {response.text}')
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return data['data']['id']
|
||||||
|
|
||||||
|
async def agent_chat(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
query: str,
|
||||||
|
user: str,
|
||||||
|
agent_id: str = '',
|
||||||
|
knowledge_base_ids: list[str] | None = None,
|
||||||
|
web_search_enabled: bool = False,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||||
|
"""
|
||||||
|
Agent 智能对话(SSE 流式)
|
||||||
|
|
||||||
|
响应事件类型:
|
||||||
|
- agent_query: Agent 开始处理
|
||||||
|
- thinking: 思考过程
|
||||||
|
- tool_call: 工具调用
|
||||||
|
- tool_result: 工具结果
|
||||||
|
- references: 知识库引用
|
||||||
|
- answer: 回答内容
|
||||||
|
- reflection: 反思
|
||||||
|
- session_title: 会话标题
|
||||||
|
- error: 错误
|
||||||
|
"""
|
||||||
|
if knowledge_base_ids is None:
|
||||||
|
knowledge_base_ids = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
payload: dict[str, typing.Any] = {
|
||||||
|
'query': query,
|
||||||
|
'agent_enabled': True,
|
||||||
|
'channel': 'im',
|
||||||
|
}
|
||||||
|
if agent_id:
|
||||||
|
payload['agent_id'] = agent_id
|
||||||
|
if knowledge_base_ids:
|
||||||
|
payload['knowledge_base_ids'] = knowledge_base_ids
|
||||||
|
if web_search_enabled:
|
||||||
|
payload['web_search_enabled'] = True
|
||||||
|
|
||||||
|
async with client.stream(
|
||||||
|
'POST',
|
||||||
|
f'/agent-chat/{session_id}',
|
||||||
|
headers={
|
||||||
|
'X-API-Key': self.api_key,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
) as r:
|
||||||
|
async for chunk in r.aiter_lines():
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||||
|
if chunk.strip() == '':
|
||||||
|
continue
|
||||||
|
if chunk.startswith('data:'):
|
||||||
|
try:
|
||||||
|
data = json.loads(chunk[5:].strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
yield data
|
||||||
|
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||||
|
if data.get('response_type') == 'error':
|
||||||
|
return
|
||||||
|
|
||||||
|
async def knowledge_chat(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
query: str,
|
||||||
|
user: str,
|
||||||
|
agent_id: str = 'builtin-quick-answer',
|
||||||
|
knowledge_base_ids: list[str] | None = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||||
|
"""
|
||||||
|
知识库 RAG 问答(SSE 流式)
|
||||||
|
|
||||||
|
响应事件类型:
|
||||||
|
- references: 知识库引用
|
||||||
|
- answer: 回答内容
|
||||||
|
"""
|
||||||
|
if knowledge_base_ids is None:
|
||||||
|
knowledge_base_ids = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
payload: dict[str, typing.Any] = {
|
||||||
|
'query': query,
|
||||||
|
'channel': 'im',
|
||||||
|
}
|
||||||
|
if agent_id:
|
||||||
|
payload['agent_id'] = agent_id
|
||||||
|
if knowledge_base_ids:
|
||||||
|
payload['knowledge_base_ids'] = knowledge_base_ids
|
||||||
|
|
||||||
|
async with client.stream(
|
||||||
|
'POST',
|
||||||
|
f'/knowledge-chat/{session_id}',
|
||||||
|
headers={
|
||||||
|
'X-API-Key': self.api_key,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
) as r:
|
||||||
|
async for chunk in r.aiter_lines():
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||||
|
if chunk.strip() == '':
|
||||||
|
continue
|
||||||
|
if chunk.startswith('data:'):
|
||||||
|
try:
|
||||||
|
data = json.loads(chunk[5:].strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
yield data
|
||||||
|
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||||
|
if data.get('response_type') == 'error':
|
||||||
|
return
|
||||||
6
src/langbot/libs/weknora_api/errors.py
Normal file
6
src/langbot/libs/weknora_api/errors.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class WeKnoraAPIError(Exception):
|
||||||
|
"""WeKnora API 请求失败"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = ''):
|
||||||
|
self.message = message
|
||||||
|
super().__init__(self.message)
|
||||||
22
src/langbot/pkg/api/http/controller/groups/box.py
Normal file
22
src/langbot/pkg/api/http/controller/groups/box.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('box', '/api/v1/box')
|
||||||
|
class BoxRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
status = await self.ap.box_service.get_status()
|
||||||
|
return self.success(data=status)
|
||||||
|
|
||||||
|
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
sessions = await self.ap.box_service.get_sessions()
|
||||||
|
return self.success(data=sessions)
|
||||||
|
|
||||||
|
@self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
errors = self.ap.box_service.get_recent_errors()
|
||||||
|
return self.success(data=errors)
|
||||||
52
src/langbot/pkg/api/http/controller/groups/extensions.py
Normal file
52
src/langbot/pkg/api/http/controller/groups/extensions.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from .. import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('extensions', '/api/v1/extensions')
|
||||||
|
class ExtensionsRouterGroup(group.RouterGroup):
|
||||||
|
"""Unified API for installed extensions (plugins, MCP servers, skills)."""
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> quart.Response:
|
||||||
|
plugins, mcp_servers, skills = await asyncio.gather(
|
||||||
|
self.ap.plugin_connector.list_plugins(),
|
||||||
|
self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True),
|
||||||
|
self.ap.skill_service.list_skills(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sort_key(item: dict) -> str:
|
||||||
|
if item['type'] == 'plugin':
|
||||||
|
return (
|
||||||
|
item['plugin']
|
||||||
|
.get('manifest', {})
|
||||||
|
.get('manifest', {})
|
||||||
|
.get('metadata', {})
|
||||||
|
.get('name', '')
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
if item['type'] == 'mcp':
|
||||||
|
return (item['server'].get('name') or '').lower()
|
||||||
|
if item['type'] == 'skill':
|
||||||
|
return (item['skill'].get('display_name') or item['skill'].get('name') or '').lower()
|
||||||
|
return ''
|
||||||
|
|
||||||
|
extensions: list[dict] = []
|
||||||
|
if isinstance(plugins, list):
|
||||||
|
for plugin in plugins:
|
||||||
|
extensions.append({'type': 'plugin', 'plugin': plugin})
|
||||||
|
if isinstance(mcp_servers, list):
|
||||||
|
for server in mcp_servers:
|
||||||
|
extensions.append({'type': 'mcp', 'server': server})
|
||||||
|
if isinstance(skills, list):
|
||||||
|
for skill in skills:
|
||||||
|
extensions.append({'type': 'skill', 'skill': skill})
|
||||||
|
|
||||||
|
extensions.sort(key=_sort_key)
|
||||||
|
|
||||||
|
return self.success(data={'extensions': extensions})
|
||||||
@@ -456,6 +456,31 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
'platform',
|
'platform',
|
||||||
'user_id',
|
'user_id',
|
||||||
]
|
]
|
||||||
|
elif export_type == 'feedback':
|
||||||
|
data = await self.ap.monitoring_service.export_feedback(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
headers = [
|
||||||
|
'id',
|
||||||
|
'timestamp',
|
||||||
|
'feedback_id',
|
||||||
|
'feedback_type',
|
||||||
|
'feedback_content',
|
||||||
|
'inaccurate_reasons',
|
||||||
|
'bot_id',
|
||||||
|
'bot_name',
|
||||||
|
'pipeline_id',
|
||||||
|
'pipeline_name',
|
||||||
|
'session_id',
|
||||||
|
'message_id',
|
||||||
|
'stream_id',
|
||||||
|
'user_id',
|
||||||
|
'platform',
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
||||||
|
|
||||||
@@ -486,3 +511,63 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return response, 200
|
return response, 200
|
||||||
|
|
||||||
|
@self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def get_feedback_stats() -> str:
|
||||||
|
"""Get feedback statistics"""
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
stats = await self.ap.monitoring_service.get_feedback_stats(
|
||||||
|
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=stats)
|
||||||
|
|
||||||
|
@self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def get_feedback() -> str:
|
||||||
|
"""Get feedback list"""
|
||||||
|
# Parse query parameters
|
||||||
|
bot_ids = quart.request.args.getlist('botId')
|
||||||
|
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||||
|
feedback_type_str = quart.request.args.get('feedbackType')
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Parse feedback type
|
||||||
|
feedback_type = int(feedback_type_str) if feedback_type_str else None
|
||||||
|
|
||||||
|
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
|
||||||
|
bot_ids=bot_ids if bot_ids else None,
|
||||||
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'feedback': feedback_list,
|
||||||
|
'total': total,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
384
src/langbot/pkg/api/http/controller/groups/pipelines/embed.py
Normal file
384
src/langbot/pkg/api/http/controller/groups/pipelines/embed.py
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"""Embed widget routes - serve embeddable chat widget for external websites.
|
||||||
|
|
||||||
|
All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that
|
||||||
|
internal pipeline identifiers are never exposed to end-users. Each handler
|
||||||
|
resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts
|
||||||
|
the bound pipeline_uuid for internal routing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
from ......utils import paths
|
||||||
|
from ......platform.sources.websocket_manager import ws_connection_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cache the widget template content
|
||||||
|
_widget_template_cache: str | None = None
|
||||||
|
_logo_bytes_cache: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_uuid(s: str) -> bool:
|
||||||
|
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_widget_template() -> str:
|
||||||
|
"""Load and cache the widget JS template."""
|
||||||
|
global _widget_template_cache
|
||||||
|
if _widget_template_cache is None:
|
||||||
|
template_path = paths.get_resource_path('templates/embed/widget.js')
|
||||||
|
with open(template_path, 'r', encoding='utf-8') as f:
|
||||||
|
_widget_template_cache = f.read()
|
||||||
|
return _widget_template_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _get_logo_bytes() -> bytes:
|
||||||
|
"""Load and cache the logo image."""
|
||||||
|
global _logo_bytes_cache
|
||||||
|
if _logo_bytes_cache is None:
|
||||||
|
logo_path = paths.get_resource_path('templates/embed/logo.webp')
|
||||||
|
with open(logo_path, 'rb') as f:
|
||||||
|
_logo_bytes_cache = f.read()
|
||||||
|
return _logo_bytes_cache
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('embed', '/api/v1/embed')
|
||||||
|
class EmbedRouterGroup(group.RouterGroup):
|
||||||
|
# -- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolve_bot(self, bot_uuid: str):
|
||||||
|
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
||||||
|
|
||||||
|
Returns ``(None, None)`` when the bot does not exist, is not a
|
||||||
|
``web_page_bot``, is disabled, or has no pipeline bound.
|
||||||
|
"""
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if (
|
||||||
|
bot.bot_entity.uuid == bot_uuid
|
||||||
|
and bot.bot_entity.adapter == 'web_page_bot'
|
||||||
|
and bot.bot_entity.enable
|
||||||
|
and bot.bot_entity.use_pipeline_uuid
|
||||||
|
):
|
||||||
|
return bot, bot.bot_entity.use_pipeline_uuid
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _get_bot_config(self, bot_uuid: str) -> dict:
|
||||||
|
for bot in self.ap.platform_mgr.bots:
|
||||||
|
if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
|
||||||
|
return bot.bot_entity.adapter_config
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def _verify_session_token(self, request, bot_uuid: str) -> bool:
|
||||||
|
config = self._get_bot_config(bot_uuid)
|
||||||
|
secret = config.get('turnstile_secret_key', '')
|
||||||
|
if not secret:
|
||||||
|
return True
|
||||||
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
if not auth_header.startswith('Bearer '):
|
||||||
|
return False
|
||||||
|
token = auth_header[7:]
|
||||||
|
try:
|
||||||
|
ts_str, mac = token.split('.', 1)
|
||||||
|
ts = float(ts_str)
|
||||||
|
if time.time() - ts > 86400:
|
||||||
|
return False
|
||||||
|
expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(mac, expected_mac)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- routes --------------------------------------------------------------
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/<bot_uuid>/turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
|
async def verify_turnstile(bot_uuid: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
try:
|
||||||
|
data = await quart.request.get_json()
|
||||||
|
token = data.get('token')
|
||||||
|
if not token:
|
||||||
|
return self.http_status(400, -1, 'Token is required')
|
||||||
|
|
||||||
|
config = self._get_bot_config(bot_uuid)
|
||||||
|
secret = config.get('turnstile_secret_key', '')
|
||||||
|
if not secret:
|
||||||
|
ts = time.time()
|
||||||
|
return self.success(data={'token': f'{ts}.dummy'})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||||
|
data={'secret': secret, 'response': token},
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if not result.get('success'):
|
||||||
|
return self.http_status(403, -1, 'Turnstile verification failed')
|
||||||
|
|
||||||
|
ts = time.time()
|
||||||
|
mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest()
|
||||||
|
session_token = f'{ts}.{mac}'
|
||||||
|
|
||||||
|
return self.success(data={'token': session_token})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Turnstile verify failed: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/widget.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def serve_widget(bot_uuid: str) -> quart.Response:
|
||||||
|
"""Serve the embed widget JavaScript with injected configuration."""
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return quart.Response(
|
||||||
|
'// Bot not found or not available', status=404, content_type='application/javascript'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
template = _get_widget_template()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return quart.Response('// Widget template not found', status=404, content_type='application/javascript')
|
||||||
|
|
||||||
|
base_url = quart.request.host_url.rstrip('/')
|
||||||
|
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
|
||||||
|
if webhook_prefix:
|
||||||
|
base_url = webhook_prefix.rstrip('/')
|
||||||
|
|
||||||
|
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
|
||||||
|
base_url = quart.request.host_url.rstrip('/')
|
||||||
|
|
||||||
|
config = self._get_bot_config(bot_uuid)
|
||||||
|
site_key = config.get('turnstile_site_key', '')
|
||||||
|
locale = config.get('language', 'en_US') or 'en_US'
|
||||||
|
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
|
||||||
|
widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale)
|
||||||
|
widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon)
|
||||||
|
|
||||||
|
response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8')
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=300'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def serve_logo() -> quart.Response:
|
||||||
|
"""Serve the LangBot logo for the embed widget."""
|
||||||
|
try:
|
||||||
|
logo_data = _get_logo_bytes()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return quart.Response('', status=404)
|
||||||
|
|
||||||
|
response = quart.Response(logo_data, content_type='image/webp')
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=86400'
|
||||||
|
return response
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/messages/<session_type>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||||
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||||
|
try:
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
if not websocket_adapter:
|
||||||
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
|
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
||||||
|
return self.success(data={'messages': messages})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to get embed messages: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/reset/<session_type>', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
|
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||||
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||||
|
try:
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
if not websocket_adapter:
|
||||||
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
|
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
||||||
|
return self.success(data={'message': 'Session reset successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to reset embed session: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/feedback', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
|
async def submit_feedback(bot_uuid: str) -> str:
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
return self.http_status(404, -1, 'Bot not found or not available')
|
||||||
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||||
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||||
|
try:
|
||||||
|
data = await quart.request.get_json()
|
||||||
|
message_id = data.get('message_id', '')
|
||||||
|
feedback_type = data.get('feedback_type')
|
||||||
|
|
||||||
|
if feedback_type not in (1, 2, 3):
|
||||||
|
return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)')
|
||||||
|
|
||||||
|
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_feedback(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
bot_id=runtime_bot.bot_entity.uuid,
|
||||||
|
bot_name=runtime_bot.bot_entity.name or bot_uuid,
|
||||||
|
pipeline_id=pipeline_uuid,
|
||||||
|
message_id=str(message_id),
|
||||||
|
platform='web_page_bot',
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data={'feedback_id': feedback_id})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to record feedback: {e}', exc_info=True)
|
||||||
|
return self.http_status(500, -1, 'Internal server error')
|
||||||
|
|
||||||
|
# -- Embed WebSocket endpoint ----------------------------------------
|
||||||
|
|
||||||
|
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
|
||||||
|
async def embed_websocket_connect(bot_uuid: str):
|
||||||
|
"""WebSocket connection for embed widget, keyed by bot_uuid."""
|
||||||
|
if not _is_valid_uuid(bot_uuid):
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||||
|
if runtime_bot is None:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
session_type = quart.websocket.args.get('session_type', 'person')
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
await quart.websocket.send(
|
||||||
|
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
if not websocket_adapter:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection = await ws_connection_manager.add_connection(
|
||||||
|
websocket=quart.websocket._get_current_object(),
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
session_type=session_type,
|
||||||
|
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||||
|
)
|
||||||
|
|
||||||
|
await quart.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
'type': 'connected',
|
||||||
|
'connection_id': connection.connection_id,
|
||||||
|
'bot_uuid': bot_uuid,
|
||||||
|
'session_type': session_type,
|
||||||
|
'timestamp': connection.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f'Embed WebSocket connected: {connection.connection_id} '
|
||||||
|
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
|
||||||
|
)
|
||||||
|
|
||||||
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
|
||||||
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.gather(receive_task, send_task)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed WebSocket task error: {e}')
|
||||||
|
finally:
|
||||||
|
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
|
||||||
|
try:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- WebSocket receive/send helpers --------------------------------------
|
||||||
|
|
||||||
|
async def _handle_receive(self, connection, websocket_adapter, owner_bot):
|
||||||
|
try:
|
||||||
|
while connection.is_active:
|
||||||
|
message = await quart.websocket.receive()
|
||||||
|
await ws_connection_manager.update_activity(connection.connection_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
message_type = data.get('type', 'message')
|
||||||
|
|
||||||
|
if message_type == 'ping':
|
||||||
|
await connection.send_queue.put(
|
||||||
|
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||||
|
)
|
||||||
|
elif message_type == 'message':
|
||||||
|
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||||
|
elif message_type == 'disconnect':
|
||||||
|
break
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed receive error: {e}', exc_info=True)
|
||||||
|
finally:
|
||||||
|
connection.is_active = False
|
||||||
|
|
||||||
|
async def _handle_send(self, connection):
|
||||||
|
try:
|
||||||
|
while connection.is_active:
|
||||||
|
try:
|
||||||
|
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||||
|
await quart.websocket.send(json.dumps(message))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Embed send error: {e}', exc_info=True)
|
||||||
|
finally:
|
||||||
|
connection.is_active = False
|
||||||
@@ -73,15 +73,21 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
|
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
|
||||||
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||||
|
|
||||||
|
# Get available skills
|
||||||
|
available_skills = await self.ap.skill_service.list_skills()
|
||||||
|
|
||||||
extensions_prefs = pipeline.get('extensions_preferences', {})
|
extensions_prefs = pipeline.get('extensions_preferences', {})
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
||||||
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
||||||
|
'enable_all_skills': extensions_prefs.get('enable_all_skills', True),
|
||||||
'bound_plugins': extensions_prefs.get('plugins', []),
|
'bound_plugins': extensions_prefs.get('plugins', []),
|
||||||
'available_plugins': plugins,
|
'available_plugins': plugins,
|
||||||
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
||||||
'available_mcp_servers': mcp_servers,
|
'available_mcp_servers': mcp_servers,
|
||||||
|
'bound_skills': extensions_prefs.get('skills', []),
|
||||||
|
'available_skills': available_skills,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif quart.request.method == 'PUT':
|
elif quart.request.method == 'PUT':
|
||||||
@@ -89,11 +95,19 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
enable_all_plugins = json_data.get('enable_all_plugins', True)
|
enable_all_plugins = json_data.get('enable_all_plugins', True)
|
||||||
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
|
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
|
||||||
|
enable_all_skills = json_data.get('enable_all_skills', True)
|
||||||
bound_plugins = json_data.get('bound_plugins', [])
|
bound_plugins = json_data.get('bound_plugins', [])
|
||||||
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
||||||
|
bound_skills = json_data.get('bound_skills', [])
|
||||||
|
|
||||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||||
pipeline_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers
|
pipeline_uuid,
|
||||||
|
bound_plugins,
|
||||||
|
bound_mcp_servers,
|
||||||
|
enable_all_plugins,
|
||||||
|
enable_all_mcp_servers,
|
||||||
|
bound_skills=bound_skills,
|
||||||
|
enable_all_skills=enable_all_skills,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.success()
|
return self.success()
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Dashboard pipeline-debug sessions must always run under the
|
||||||
|
# built-in websocket_proxy_bot identity. We deliberately do NOT
|
||||||
|
# resolve a web_page_bot owner here — even if one is bound to
|
||||||
|
# the same pipeline, debug requests must not be attributed to
|
||||||
|
# it. The embed widget path (`/api/v1/embed/<bot>/ws/connect`)
|
||||||
|
# is the one that carries the page-bot identity.
|
||||||
|
|
||||||
# 注册连接
|
# 注册连接
|
||||||
connection = await ws_connection_manager.add_connection(
|
connection = await ws_connection_manager.add_connection(
|
||||||
websocket=quart.websocket._get_current_object(),
|
websocket=quart.websocket._get_current_object(),
|
||||||
@@ -203,6 +210,9 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||||
|
|
||||||
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||||
|
# owner_bot is intentionally NOT passed: the dashboard
|
||||||
|
# debug WebSocket must always run under the proxy bot,
|
||||||
|
# never under a coincidentally-bound web_page_bot.
|
||||||
await websocket_adapter.handle_websocket_message(connection, data)
|
await websocket_adapter.handle_websocket_message(connection, data)
|
||||||
|
|
||||||
elif message_type == 'disconnect':
|
elif message_type == 'disconnect':
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import quart
|
import quart
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import asyncio
|
||||||
from ... import group
|
from ... import group
|
||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
@@ -35,3 +36,617 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
return quart.Response(
|
return quart.Response(
|
||||||
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# In-memory session store for active registrations
|
||||||
|
_create_app_sessions: dict = {}
|
||||||
|
_SESSION_TTL = 900 # 15 minutes
|
||||||
|
|
||||||
|
def _cleanup_expired_sessions():
|
||||||
|
"""Remove sessions that have exceeded their TTL."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [sid for sid, s in _create_app_sessions.items() if now - s.get('created_at', 0) > _SESSION_TTL]
|
||||||
|
for sid in expired:
|
||||||
|
session = _create_app_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/lark/create-app', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import lark_oapi as lark
|
||||||
|
from lark_oapi.scene.registration.errors import AppAccessDeniedError, AppExpiredError
|
||||||
|
|
||||||
|
_cleanup_expired_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'app_id': None,
|
||||||
|
'app_secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
_create_app_sessions[session_id] = session
|
||||||
|
|
||||||
|
def on_qr_code(info):
|
||||||
|
# May be called from a background thread by the SDK;
|
||||||
|
# use call_soon_threadsafe to safely update session state.
|
||||||
|
def _update():
|
||||||
|
session['qr_url'] = info['url']
|
||||||
|
session['expire_at'] = time.time() + 600 # 10 minutes
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
loop.call_soon_threadsafe(_update)
|
||||||
|
|
||||||
|
async def run_registration():
|
||||||
|
try:
|
||||||
|
result = await lark.aregister_app(
|
||||||
|
on_qr_code=on_qr_code,
|
||||||
|
source='langbot',
|
||||||
|
)
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['app_id'] = result['client_id']
|
||||||
|
session['app_secret'] = result['client_secret']
|
||||||
|
except AppAccessDeniedError:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'User denied authorization'
|
||||||
|
except AppExpiredError:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_registration())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/lark/create-app/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll registration status."""
|
||||||
|
session = _create_app_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['app_id'] = session['app_id']
|
||||||
|
data['app_secret'] = session['app_secret']
|
||||||
|
_create_app_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_create_app_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/lark/create-app/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a registration session."""
|
||||||
|
session = _create_app_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# WeChat QR Code Login
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_weixin_login_sessions: dict = {}
|
||||||
|
_WEIXIN_SESSION_TTL = 600 # 10 minutes (3 retries × 3 min QR validity)
|
||||||
|
|
||||||
|
def _cleanup_expired_weixin_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _weixin_login_sessions.items() if now - s.get('created_at', 0) > _WEIXIN_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _weixin_login_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/weixin/login', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
|
||||||
|
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
||||||
|
|
||||||
|
_cleanup_expired_weixin_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_data_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'token': None,
|
||||||
|
'base_url': None,
|
||||||
|
'account_id': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
}
|
||||||
|
_weixin_login_sessions[session_id] = session
|
||||||
|
|
||||||
|
client = OpenClawWeixinClient(
|
||||||
|
base_url=DEFAULT_BASE_URL,
|
||||||
|
token='',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_login():
|
||||||
|
try:
|
||||||
|
|
||||||
|
def on_qrcode(qr_data_url: str, _qr_url: str):
|
||||||
|
def _update():
|
||||||
|
session['qr_data_url'] = qr_data_url
|
||||||
|
session['expire_at'] = time.time() + 180
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
loop.call_soon_threadsafe(_update)
|
||||||
|
|
||||||
|
result = await client.login(
|
||||||
|
max_retries=1,
|
||||||
|
poll_timeout_ms=180_000,
|
||||||
|
on_qrcode=on_qrcode,
|
||||||
|
)
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['token'] = result.token
|
||||||
|
session['base_url'] = result.base_url
|
||||||
|
session['account_id'] = result.account_id
|
||||||
|
except Exception as e:
|
||||||
|
error_message = str(e)
|
||||||
|
if 'expired' in error_message.lower() or 'max retries exceeded' in error_message.lower():
|
||||||
|
session['status'] = 'expired'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
else:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = error_message
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_login())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_data_url']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if not session['qr_data_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_data_url': session['qr_data_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/weixin/login/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll WeChat login status."""
|
||||||
|
session = _weixin_login_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'status': session['status'],
|
||||||
|
'qr_data_url': session['qr_data_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['token'] = session['token']
|
||||||
|
data['base_url'] = session['base_url']
|
||||||
|
data['account_id'] = session['account_id']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'expired':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/weixin/login/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a WeChat login session."""
|
||||||
|
session = _weixin_login_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# DingTalk Device Flow QR Code Login
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_dingtalk_sessions: dict = {}
|
||||||
|
_DINGTALK_SESSION_TTL = 600 # 10 minutes (QR code validity window)
|
||||||
|
|
||||||
|
def _cleanup_expired_dingtalk_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _dingtalk_sessions.items() if now - s.get('created_at', 0) > _DINGTALK_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _dingtalk_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
DINGTALK_BASE_URL = 'https://oapi.dingtalk.com'
|
||||||
|
|
||||||
|
_cleanup_expired_dingtalk_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'client_id': None,
|
||||||
|
'client_secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
'device_code': None,
|
||||||
|
'interval': 5,
|
||||||
|
}
|
||||||
|
_dingtalk_sessions[session_id] = session
|
||||||
|
|
||||||
|
async def run_device_flow():
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||||
|
# Step 1: Init — get nonce
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/init',
|
||||||
|
json={'source': 'langbot'},
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from DingTalk service'
|
||||||
|
return
|
||||||
|
if data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to init')
|
||||||
|
return
|
||||||
|
nonce = data['nonce']
|
||||||
|
|
||||||
|
# Step 2: Begin — get device_code + QR URL
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/begin',
|
||||||
|
json={'nonce': nonce},
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from DingTalk service'
|
||||||
|
return
|
||||||
|
if data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to begin authorization')
|
||||||
|
return
|
||||||
|
|
||||||
|
device_code = data['device_code']
|
||||||
|
verification_uri_complete = data.get('verification_uri_complete', '')
|
||||||
|
expires_in = data.get('expires_in', 7200)
|
||||||
|
interval = data.get('interval', 5)
|
||||||
|
|
||||||
|
session['device_code'] = device_code
|
||||||
|
session['interval'] = interval
|
||||||
|
session['qr_url'] = verification_uri_complete
|
||||||
|
session['expire_at'] = time.time() + 600 # QR code valid for ~10 min
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
# Step 3: Poll for authorization result
|
||||||
|
deadline = time.time() + expires_in
|
||||||
|
while time.time() < deadline:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
async with http.post(
|
||||||
|
f'{DINGTALK_BASE_URL}/app/registration/poll',
|
||||||
|
json={'device_code': device_code},
|
||||||
|
) as poll_resp:
|
||||||
|
try:
|
||||||
|
poll_data = await poll_resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if poll_data.get('errcode', -1) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = poll_data.get('errmsg', 'Poll failed')
|
||||||
|
return
|
||||||
|
|
||||||
|
status = poll_data.get('status', '')
|
||||||
|
|
||||||
|
if status == 'SUCCESS':
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['client_id'] = poll_data.get('client_id', '')
|
||||||
|
session['client_secret'] = poll_data.get('client_secret', '')
|
||||||
|
return
|
||||||
|
elif status == 'FAIL':
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = poll_data.get('fail_reason', 'Authorization failed')
|
||||||
|
return
|
||||||
|
elif status == 'EXPIRED':
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
return
|
||||||
|
# status == 'WAITING': continue polling
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_device_flow())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url'] or session['error']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if session['error']:
|
||||||
|
task.cancel()
|
||||||
|
return self.http_status(502, -1, session['error'])
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll DingTalk Device Flow status."""
|
||||||
|
_cleanup_expired_dingtalk_sessions()
|
||||||
|
session = _dingtalk_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['client_id'] = session['client_id']
|
||||||
|
data['client_secret'] = session['client_secret']
|
||||||
|
_dingtalk_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_dingtalk_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/dingtalk/create-app/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a DingTalk Device Flow session."""
|
||||||
|
session = _dingtalk_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# WeComBot QR Code One-Click Create
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_wecombot_sessions: dict = {}
|
||||||
|
_WECOMBOT_SESSION_TTL = 300 # 5 minutes (WeCom QR validity window)
|
||||||
|
|
||||||
|
def _cleanup_expired_wecombot_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _wecombot_sessions.items() if now - s.get('created_at', 0) > _WECOMBOT_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _wecombot_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
WECOM_QC_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate'
|
||||||
|
WECOM_QC_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result'
|
||||||
|
|
||||||
|
_cleanup_expired_wecombot_sessions()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'botid': None,
|
||||||
|
'secret': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
'scode': None,
|
||||||
|
'task': None,
|
||||||
|
}
|
||||||
|
_wecombot_sessions[session_id] = session
|
||||||
|
|
||||||
|
async def run_qr_flow():
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||||
|
# Step 1: Generate QR code
|
||||||
|
async with http.get(
|
||||||
|
f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0',
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from WeCom service'
|
||||||
|
return
|
||||||
|
if not data.get('data', {}).get('scode') or not data.get('data', {}).get('auth_url'):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = data.get('errmsg', 'Failed to generate QR code')
|
||||||
|
return
|
||||||
|
|
||||||
|
scode = data['data']['scode']
|
||||||
|
auth_url = data['data']['auth_url']
|
||||||
|
|
||||||
|
session['scode'] = scode
|
||||||
|
session['qr_url'] = auth_url
|
||||||
|
session['expire_at'] = time.time() + _WECOMBOT_SESSION_TTL
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
# Step 2: Poll for scan result
|
||||||
|
deadline = time.time() + _WECOMBOT_SESSION_TTL
|
||||||
|
while time.time() < deadline:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
async with http.get(
|
||||||
|
f'{WECOM_QC_QUERY_URL}?scode={scode}',
|
||||||
|
) as poll_resp:
|
||||||
|
try:
|
||||||
|
poll_data = await poll_resp.json()
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = poll_data.get('data', {}).get('status', '')
|
||||||
|
if status == 'success':
|
||||||
|
bot_info = poll_data.get('data', {}).get('bot_info', {})
|
||||||
|
if bot_info.get('botid') and bot_info.get('secret'):
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['botid'] = bot_info['botid']
|
||||||
|
session['secret'] = bot_info['secret']
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Scan succeeded but bot info is incomplete'
|
||||||
|
return
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(run_qr_flow())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait for QR code to be ready (max 10 seconds)
|
||||||
|
for _ in range(20):
|
||||||
|
if session['qr_url'] or session['error']:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
if session['error']:
|
||||||
|
task.cancel()
|
||||||
|
return self.http_status(502, -1, session['error'])
|
||||||
|
|
||||||
|
if not session['qr_url']:
|
||||||
|
task.cancel()
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Timeout waiting for QR code'
|
||||||
|
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'session_id': session_id,
|
||||||
|
'qr_url': session['qr_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll WeComBot creation status."""
|
||||||
|
_cleanup_expired_wecombot_sessions()
|
||||||
|
session = _wecombot_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
|
data = {'status': session['status']}
|
||||||
|
|
||||||
|
if session['status'] == 'success':
|
||||||
|
data['botid'] = session['botid']
|
||||||
|
data['secret'] = session['secret']
|
||||||
|
_wecombot_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'error':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_wecombot_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/wecombot/create-bot/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a WeComBot creation session."""
|
||||||
|
session = _wecombot_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|||||||
@@ -1,19 +1,153 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import io
|
||||||
import quart
|
import quart
|
||||||
import re
|
import re
|
||||||
import httpx
|
import httpx
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
|
import zipfile
|
||||||
|
import yaml
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import posixpath
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
from .....core import taskmgr
|
from .....core import taskmgr
|
||||||
|
from .....entity.persistence import plugin as persistence_plugin
|
||||||
from .. import group
|
from .. import group
|
||||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||||
|
|
||||||
|
# Resolve the built-in page SDK JS from the langbot_plugin package
|
||||||
|
_PAGE_SDK_PATH = None
|
||||||
|
try:
|
||||||
|
import langbot_plugin.assets as _assets_pkg
|
||||||
|
|
||||||
|
_candidate = os.path.join(os.path.dirname(_assets_pkg.__file__), 'langbot-page-sdk.js')
|
||||||
|
if os.path.exists(_candidate):
|
||||||
|
_PAGE_SDK_PATH = _candidate
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_plugin_asset_path(filepath: str) -> str | None:
|
||||||
|
filepath = filepath.replace('\\', '/')
|
||||||
|
if filepath.startswith('/'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = posixpath.normpath(filepath)
|
||||||
|
if normalized == '.' or normalized.startswith('../') or normalized == '..':
|
||||||
|
return None
|
||||||
|
|
||||||
|
if normalized.startswith('components/pages/'):
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
return f'assets/{normalized}'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_origin() -> str:
|
||||||
|
"""Return the public request origin, respecting reverse-proxy headers."""
|
||||||
|
forwarded_proto = quart.request.headers.get('X-Forwarded-Proto', '').split(',')[0].strip()
|
||||||
|
forwarded_host = quart.request.headers.get('X-Forwarded-Host', '').split(',')[0].strip()
|
||||||
|
|
||||||
|
scheme = forwarded_proto or quart.request.scheme
|
||||||
|
host = forwarded_host or quart.request.host
|
||||||
|
return f'{scheme}://{host}'
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('plugins', '/api/v1/plugins')
|
@group.group_class('plugins', '/api/v1/plugins')
|
||||||
class PluginsRouterGroup(group.RouterGroup):
|
class PluginsRouterGroup(group.RouterGroup):
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_archive_path(path: str) -> str:
|
||||||
|
normalized = str(path or '').replace('\\', '/').strip('/')
|
||||||
|
return posixpath.normpath(normalized) if normalized else ''
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _component_source_path(cls, entry) -> str:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
return cls._normalize_archive_path(entry.get('path') or '')
|
||||||
|
return cls._normalize_archive_path(str(entry or ''))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _count_component_configs(cls, component_config, archive_names: list[str]) -> int:
|
||||||
|
normalized_names = [cls._normalize_archive_path(name) for name in archive_names]
|
||||||
|
component_files: set[str] = set()
|
||||||
|
|
||||||
|
if isinstance(component_config, list):
|
||||||
|
return len(component_config)
|
||||||
|
if not isinstance(component_config, dict):
|
||||||
|
return 1 if component_config else 0
|
||||||
|
|
||||||
|
for entry in component_config.get('fromFiles') or []:
|
||||||
|
source_path = cls._component_source_path(entry)
|
||||||
|
if source_path and source_path in normalized_names:
|
||||||
|
component_files.add(source_path)
|
||||||
|
|
||||||
|
for entry in component_config.get('fromDirs') or []:
|
||||||
|
source_dir = cls._component_source_path(entry).rstrip('/')
|
||||||
|
if not source_dir:
|
||||||
|
continue
|
||||||
|
prefix = f'{source_dir}/'
|
||||||
|
for archive_name in normalized_names:
|
||||||
|
if not archive_name.startswith(prefix):
|
||||||
|
continue
|
||||||
|
if archive_name.lower().endswith(('.yaml', '.yml')):
|
||||||
|
component_files.add(archive_name)
|
||||||
|
|
||||||
|
if component_files:
|
||||||
|
return len(component_files)
|
||||||
|
|
||||||
|
return 1 if any(key in component_config for key in ('path', 'name', 'kind')) else 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _count_plugin_components(cls, components, archive_names: list[str]) -> dict[str, int]:
|
||||||
|
if not isinstance(components, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
component_counts: dict[str, int] = {}
|
||||||
|
for kind, component_config in components.items():
|
||||||
|
count = cls._count_component_configs(component_config, archive_names)
|
||||||
|
if count > 0:
|
||||||
|
component_counts[str(kind)] = count
|
||||||
|
return component_counts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_github_repo_url(repo_url: str) -> dict | None:
|
||||||
|
raw_url = str(repo_url or '').strip()
|
||||||
|
if not raw_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', raw_url):
|
||||||
|
raw_url = f'https://{raw_url}'
|
||||||
|
|
||||||
|
parsed = urlparse(raw_url)
|
||||||
|
if parsed.netloc.lower() not in ('github.com', 'www.github.com'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts = [part for part in parsed.path.strip('/').split('/') if part]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
owner = parts[0]
|
||||||
|
repo = parts[1]
|
||||||
|
if repo.endswith('.git'):
|
||||||
|
repo = repo[:-4]
|
||||||
|
if not owner or not repo:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ref = ''
|
||||||
|
subdir = ''
|
||||||
|
if len(parts) >= 4 and parts[2] in ('tree', 'blob'):
|
||||||
|
ref = parts[3]
|
||||||
|
subdir = '/'.join(parts[4:]).strip('/')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'owner': owner,
|
||||||
|
'repo': repo,
|
||||||
|
'ref': ref,
|
||||||
|
'subdir': subdir,
|
||||||
|
}
|
||||||
|
|
||||||
async def _check_extensions_limit(self) -> str | None:
|
async def _check_extensions_limit(self) -> str | None:
|
||||||
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
|
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
|
||||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||||
@@ -27,6 +161,15 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
|
async def _() -> quart.Response:
|
||||||
|
"""Serve the built-in LangBot page SDK JavaScript."""
|
||||||
|
if _PAGE_SDK_PATH and os.path.exists(_PAGE_SDK_PATH):
|
||||||
|
with open(_PAGE_SDK_PATH, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
return quart.Response(content, mimetype='application/javascript')
|
||||||
|
return quart.Response('// SDK not found', status=404, mimetype='application/javascript')
|
||||||
|
|
||||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
plugins = await self.ap.plugin_connector.list_plugins()
|
plugins = await self.ap.plugin_connector.list_plugins()
|
||||||
@@ -102,7 +245,15 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(404, -1, 'plugin not found')
|
return self.http_status(404, -1, 'plugin not found')
|
||||||
|
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
return self.success(data={'config': plugin['plugin_config']})
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_plugin.PluginSetting.config)
|
||||||
|
.where(persistence_plugin.PluginSetting.plugin_author == author)
|
||||||
|
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
|
||||||
|
)
|
||||||
|
persisted_config = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
config = persisted_config if persisted_config is not None else plugin['plugin_config']
|
||||||
|
return self.success(data={'config': config})
|
||||||
elif quart.request.method == 'PUT':
|
elif quart.request.method == 'PUT':
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
|
|
||||||
@@ -135,15 +286,62 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return quart.Response(icon_data, mimetype=mime_type)
|
return quart.Response(icon_data, mimetype=mime_type)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<author>/<plugin_name>/assets/<filepath>',
|
'/<author>/<plugin_name>/assets/<path:filepath>',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
auth_type=group.AuthType.NONE,
|
auth_type=group.AuthType.NONE,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
||||||
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath)
|
asset_path = _normalize_plugin_asset_path(filepath)
|
||||||
|
if asset_path is None:
|
||||||
|
return quart.Response('Asset not found', status=404)
|
||||||
|
|
||||||
|
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
|
||||||
|
if not asset_data.get('asset_base64'):
|
||||||
|
return quart.Response('Asset not found', status=404)
|
||||||
asset_bytes = base64.b64decode(asset_data['asset_base64'])
|
asset_bytes = base64.b64decode(asset_data['asset_base64'])
|
||||||
mime_type = asset_data['mime_type']
|
mime_type = asset_data['mime_type']
|
||||||
return quart.Response(asset_bytes, mimetype=mime_type)
|
resp = quart.Response(asset_bytes, mimetype=mime_type)
|
||||||
|
# CSP for HTML pages served to sandboxed iframes (opaque origin).
|
||||||
|
# 'self' doesn't work in sandboxed iframes — use actual server origin.
|
||||||
|
if mime_type and mime_type.startswith('text/html'):
|
||||||
|
origin = _get_request_origin()
|
||||||
|
resp.headers['Content-Security-Policy'] = (
|
||||||
|
f'default-src {origin}; '
|
||||||
|
f"script-src {origin} 'unsafe-inline'; "
|
||||||
|
f"style-src {origin} 'unsafe-inline'; "
|
||||||
|
f'img-src {origin} data:; '
|
||||||
|
f'connect-src {origin}; '
|
||||||
|
"frame-src 'none'; "
|
||||||
|
"object-src 'none'"
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<author>/<plugin_name>/page-api',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(author: str, plugin_name: str) -> str:
|
||||||
|
"""Forward a page API request to the plugin."""
|
||||||
|
data = await quart.request.json
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return self.http_status(400, -1, 'invalid request body')
|
||||||
|
|
||||||
|
page_id = data.get('page_id', '')
|
||||||
|
endpoint = data.get('endpoint', '')
|
||||||
|
method = data.get('method', 'POST')
|
||||||
|
body = data.get('body')
|
||||||
|
if not isinstance(page_id, str) or not isinstance(endpoint, str) or not isinstance(method, str):
|
||||||
|
return self.http_status(400, -1, 'invalid page api request')
|
||||||
|
if not endpoint.startswith('/') or '..' in endpoint:
|
||||||
|
return self.http_status(400, -1, 'invalid endpoint')
|
||||||
|
|
||||||
|
result = await self.ap.plugin_connector.handle_page_api(
|
||||||
|
author, plugin_name, page_id, endpoint, method.upper(), body
|
||||||
|
)
|
||||||
|
if result.get('error'):
|
||||||
|
return self.http_status(400, -1, result['error'])
|
||||||
|
return self.success(data=result.get('data'))
|
||||||
|
|
||||||
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
@@ -151,17 +349,37 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
repo_url = data.get('repo_url', '')
|
repo_url = data.get('repo_url', '')
|
||||||
|
|
||||||
# Parse GitHub repository URL to extract owner and repo
|
parsed_repo = self._parse_github_repo_url(repo_url)
|
||||||
# Supports: https://github.com/owner/repo or github.com/owner/repo
|
if not parsed_repo:
|
||||||
pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$'
|
|
||||||
match = re.search(pattern, repo_url)
|
|
||||||
|
|
||||||
if not match:
|
|
||||||
return self.http_status(400, -1, 'Invalid GitHub repository URL')
|
return self.http_status(400, -1, 'Invalid GitHub repository URL')
|
||||||
|
|
||||||
owner, repo = match.groups()
|
owner = parsed_repo['owner']
|
||||||
|
repo = parsed_repo['repo']
|
||||||
|
requested_ref = parsed_repo['ref']
|
||||||
|
requested_subdir = parsed_repo['subdir']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if requested_ref:
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'releases': [
|
||||||
|
{
|
||||||
|
'id': 0,
|
||||||
|
'tag_name': requested_ref,
|
||||||
|
'name': requested_ref,
|
||||||
|
'published_at': '',
|
||||||
|
'prerelease': False,
|
||||||
|
'draft': False,
|
||||||
|
'source_type': 'branch',
|
||||||
|
'archive_url': f'https://api.github.com/repos/{owner}/{repo}/zipball/{requested_ref}',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'owner': owner,
|
||||||
|
'repo': repo,
|
||||||
|
'source_subdir': requested_subdir,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Fetch releases from GitHub API
|
# Fetch releases from GitHub API
|
||||||
url = f'https://api.github.com/repos/{owner}/{repo}/releases'
|
url = f'https://api.github.com/repos/{owner}/{repo}/releases'
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -187,7 +405,14 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo})
|
return self.success(
|
||||||
|
data={
|
||||||
|
'releases': formatted_releases,
|
||||||
|
'owner': owner,
|
||||||
|
'repo': repo,
|
||||||
|
'source_subdir': requested_subdir,
|
||||||
|
}
|
||||||
|
)
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
|
return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
|
||||||
|
|
||||||
@@ -265,6 +490,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(400, -1, 'Missing asset_url parameter')
|
return self.http_status(400, -1, 'Missing asset_url parameter')
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
|
||||||
|
ctx.metadata['install_source'] = 'github'
|
||||||
install_info = {
|
install_info = {
|
||||||
'asset_url': asset_url,
|
'asset_url': asset_url,
|
||||||
'owner': owner,
|
'owner': owner,
|
||||||
@@ -295,12 +522,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
|
|
||||||
|
plugin_author = data.get('plugin_author', '')
|
||||||
|
plugin_name = data.get('plugin_name', '')
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
|
ctx.metadata['install_source'] = 'marketplace'
|
||||||
wrapper = self.ap.task_mgr.create_user_task(
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
||||||
kind='plugin-operation',
|
kind='plugin-operation',
|
||||||
name='plugin-install-marketplace',
|
name='plugin-install-marketplace',
|
||||||
label=f'Installing plugin from marketplace ...{data}',
|
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,16 +555,74 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
|
||||||
|
ctx.metadata['install_source'] = 'local'
|
||||||
wrapper = self.ap.task_mgr.create_user_task(
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
||||||
kind='plugin-operation',
|
kind='plugin-operation',
|
||||||
name='plugin-install-local',
|
name='plugin-install-local',
|
||||||
label=f'Installing plugin from local ...{file.filename}',
|
label=f'Installing plugin from local {file.filename}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.success(data={'task_id': wrapper.id})
|
return self.success(data={'task_id': wrapper.id})
|
||||||
|
|
||||||
|
@self.route('/install/local/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> str:
|
||||||
|
file = (await quart.request.files).get('file')
|
||||||
|
if file is None:
|
||||||
|
return self.http_status(400, -1, 'file is required')
|
||||||
|
|
||||||
|
file_bytes = file.read()
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||||
|
names = [name for name in zf.namelist() if not name.endswith('/')]
|
||||||
|
manifest_name = next(
|
||||||
|
(
|
||||||
|
name
|
||||||
|
for name in names
|
||||||
|
if name.replace('\\', '/').strip('/').lower() in ('manifest.yaml', 'manifest.yml')
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if manifest_name is None:
|
||||||
|
return self.http_status(400, -1, 'manifest.yaml is required')
|
||||||
|
|
||||||
|
manifest = yaml.safe_load(zf.read(manifest_name).decode('utf-8')) or {}
|
||||||
|
requirements: list[str] = []
|
||||||
|
requirements_name = next(
|
||||||
|
(name for name in names if name.replace('\\', '/').strip('/').lower() == 'requirements.txt'),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if requirements_name is not None:
|
||||||
|
requirements = [
|
||||||
|
line.strip()
|
||||||
|
for line in zf.read(requirements_name).decode('utf-8', errors='ignore').splitlines()
|
||||||
|
if line.strip() and not line.strip().startswith('#')
|
||||||
|
]
|
||||||
|
|
||||||
|
spec = manifest.get('spec') or {}
|
||||||
|
components = spec.get('components') or {}
|
||||||
|
component_counts = self._count_plugin_components(components, names)
|
||||||
|
component_types = list(component_counts.keys())
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'filename': file.filename or 'local plugin',
|
||||||
|
'size': len(file_bytes),
|
||||||
|
'manifest': manifest,
|
||||||
|
'metadata': manifest.get('metadata') or {},
|
||||||
|
'component_types': component_types,
|
||||||
|
'component_counts': component_counts,
|
||||||
|
'requirements': requirements,
|
||||||
|
'file_count': len(names),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
return self.http_status(400, -1, 'invalid .lbpkg file')
|
||||||
|
except Exception as exc:
|
||||||
|
return self.http_status(500, -1, f'Failed to preview plugin package: {exc}')
|
||||||
|
|
||||||
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
"""Upload a file for plugin configuration"""
|
"""Upload a file for plugin configuration"""
|
||||||
|
|||||||
@@ -97,3 +97,51 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
|||||||
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
|
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
|
||||||
|
|
||||||
return self.success()
|
return self.success()
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('models/rerank', '/api/v1/provider/models/rerank')
|
||||||
|
class RerankModelsRouterGroup(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':
|
||||||
|
provider_uuid = quart.request.args.get('provider_uuid')
|
||||||
|
if provider_uuid:
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()})
|
||||||
|
elif quart.request.method == 'POST':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
model_uuid = await self.ap.rerank_models_service.create_rerank_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)
|
||||||
|
async def _(model_uuid: str) -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
model = await self.ap.rerank_models_service.get_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
return self.http_status(404, -1, 'model not found')
|
||||||
|
|
||||||
|
return self.success(data={'model': model})
|
||||||
|
elif quart.request.method == 'PUT':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
|
||||||
|
await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data)
|
||||||
|
|
||||||
|
return self.success()
|
||||||
|
elif quart.request.method == 'DELETE':
|
||||||
|
await self.ap.rerank_models_service.delete_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
return self.success()
|
||||||
|
|
||||||
|
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(model_uuid: str) -> str:
|
||||||
|
json_data = await quart.request.json
|
||||||
|
|
||||||
|
await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data)
|
||||||
|
|
||||||
|
return self.success()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
||||||
provider['llm_count'] = counts['llm_count']
|
provider['llm_count'] = counts['llm_count']
|
||||||
provider['embedding_count'] = counts['embedding_count']
|
provider['embedding_count'] = counts['embedding_count']
|
||||||
|
provider['rerank_count'] = counts['rerank_count']
|
||||||
return self.success(data={'providers': providers})
|
return self.success(data={'providers': providers})
|
||||||
elif quart.request.method == 'POST':
|
elif quart.request.method == 'POST':
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
@@ -32,6 +33,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
||||||
provider['llm_count'] = counts['llm_count']
|
provider['llm_count'] = counts['llm_count']
|
||||||
provider['embedding_count'] = counts['embedding_count']
|
provider['embedding_count'] = counts['embedding_count']
|
||||||
|
provider['rerank_count'] = counts['rerank_count']
|
||||||
return self.success(data={'provider': provider})
|
return self.success(data={'provider': provider})
|
||||||
elif quart.request.method == 'PUT':
|
elif quart.request.method == 'PUT':
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
@@ -43,3 +45,12 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
return self.success()
|
return self.success()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return self.http_status(400, -1, str(e))
|
return self.http_status(400, -1, str(e))
|
||||||
|
|
||||||
|
@self.route('/<provider_uuid>/scan-models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(provider_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
model_type = quart.request.args.get('type')
|
||||||
|
result = await self.ap.provider_service.scan_provider_models(provider_uuid, model_type)
|
||||||
|
return self.success(data=result)
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(400, -1, str(e))
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(server_name: str) -> str:
|
async def _(server_name: str) -> str:
|
||||||
"""获取、更新或删除MCP服务器配置"""
|
"""获取、更新或删除MCP服务器配置"""
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
server_name = unquote(server_name)
|
||||||
|
|
||||||
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
|
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
|
||||||
if server_data is None:
|
if server_data is None:
|
||||||
@@ -57,6 +60,9 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(server_name: str) -> str:
|
async def _(server_name: str) -> str:
|
||||||
"""测试MCP服务器连接"""
|
"""测试MCP服务器连接"""
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
server_name = unquote(server_name)
|
||||||
server_data = await quart.request.json
|
server_data = await quart.request.json
|
||||||
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
|
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
|
||||||
return self.success(data={'task_id': task_id})
|
return self.success(data={'task_id': task_id})
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('tools', '/api/v1/tools')
|
||||||
|
class ToolsRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""获取所有可用工具列表"""
|
||||||
|
tools = await self.ap.tool_mgr.get_all_tools()
|
||||||
|
|
||||||
|
tool_list = []
|
||||||
|
for tool in tools:
|
||||||
|
tool_list.append(
|
||||||
|
{
|
||||||
|
'name': tool.name,
|
||||||
|
'description': tool.description,
|
||||||
|
'human_desc': tool.human_desc,
|
||||||
|
'parameters': tool.parameters,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data={'tools': tool_list})
|
||||||
|
|
||||||
|
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(tool_name: str) -> str:
|
||||||
|
"""获取特定工具详情"""
|
||||||
|
tools = await self.ap.tool_mgr.get_all_tools()
|
||||||
|
|
||||||
|
for tool in tools:
|
||||||
|
if tool.name == tool_name:
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'tool': {
|
||||||
|
'name': tool.name,
|
||||||
|
'description': tool.description,
|
||||||
|
'human_desc': tool.human_desc,
|
||||||
|
'parameters': tool.parameters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.http_status(404, -1, f'Tool not found: {tool_name}')
|
||||||
190
src/langbot/pkg/api/http/controller/groups/skills.py
Normal file
190
src/langbot/pkg/api/http/controller/groups/skills.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from langbot_plugin.box.errors import BoxError
|
||||||
|
|
||||||
|
from .. import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('skills', '/api/v1/skills')
|
||||||
|
class SkillsRouterGroup(group.RouterGroup):
|
||||||
|
"""Skills management API endpoints."""
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def list_or_create_skills() -> quart.Response:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
try:
|
||||||
|
skills = await self.ap.skill_service.list_skills()
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
return self.success(data={'skills': skills})
|
||||||
|
|
||||||
|
data = await quart.request.json
|
||||||
|
if 'name' not in data or not data['name']:
|
||||||
|
return self.http_status(400, -1, 'Missing required field: name')
|
||||||
|
|
||||||
|
try:
|
||||||
|
skill = await self.ap.skill_service.create_skill(data)
|
||||||
|
return self.success(data={'skill': skill})
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
|
@self.route('/<skill_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def get_update_delete_skill(skill_name: str) -> quart.Response:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
try:
|
||||||
|
skill = await self.ap.skill_service.get_skill(skill_name)
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
if not skill:
|
||||||
|
return self.http_status(404, -1, 'Skill not found')
|
||||||
|
return self.success(data={'skill': skill})
|
||||||
|
|
||||||
|
if quart.request.method == 'PUT':
|
||||||
|
data = await quart.request.json
|
||||||
|
try:
|
||||||
|
skill = await self.ap.skill_service.update_skill(skill_name, data)
|
||||||
|
return self.success(data={'skill': skill})
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.skill_service.delete_skill(skill_name)
|
||||||
|
return self.success()
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
|
@self.route('/<skill_name>/files', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def list_skill_files(skill_name: str) -> quart.Response:
|
||||||
|
"""List files in skill package directory."""
|
||||||
|
path = quart.request.args.get('path', '.').strip()
|
||||||
|
include_hidden = quart.request.args.get('include_hidden', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.skill_service.list_skill_files(
|
||||||
|
skill_name,
|
||||||
|
path=path,
|
||||||
|
include_hidden=include_hidden,
|
||||||
|
)
|
||||||
|
return self.success(data=result)
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<skill_name>/files/<path:path>', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||||
|
)
|
||||||
|
async def read_or_write_skill_file(skill_name: str, path: str) -> quart.Response:
|
||||||
|
"""Read or write a file in skill package."""
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
try:
|
||||||
|
result = await self.ap.skill_service.read_skill_file(skill_name, path)
|
||||||
|
return self.success(data=result)
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
|
# PUT - write file
|
||||||
|
data = await quart.request.json
|
||||||
|
content = data.get('content', '')
|
||||||
|
if content is None:
|
||||||
|
return self.http_status(400, -1, 'Missing required field: content')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.skill_service.write_skill_file(skill_name, path, content)
|
||||||
|
return self.success(data=result)
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
|
@self.route('/<skill_name>/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def preview_skill(skill_name: str) -> quart.Response:
|
||||||
|
skill = self.ap.skill_mgr.get_skill_by_name(skill_name)
|
||||||
|
if not skill:
|
||||||
|
return self.http_status(404, -1, 'Skill not found')
|
||||||
|
return self.success(data={'instructions': skill.get('instructions', '')})
|
||||||
|
|
||||||
|
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def install_skill_from_github() -> quart.Response:
|
||||||
|
data = await quart.request.json
|
||||||
|
required_fields = ['asset_url', 'owner', 'repo']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in data or not data[field]:
|
||||||
|
return self.http_status(400, -1, f'Missing required field: {field}')
|
||||||
|
asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0]
|
||||||
|
if not asset_url.endswith('skill.md') and not data.get('release_tag'):
|
||||||
|
return self.http_status(400, -1, 'Missing required field: release_tag')
|
||||||
|
|
||||||
|
try:
|
||||||
|
skill = await self.ap.skill_service.install_from_github(data)
|
||||||
|
return self.success(data={'skills': skill})
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
return self.http_status(500, -1, f'Failed to install skill: {exc}')
|
||||||
|
|
||||||
|
@self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def preview_skill_from_github() -> quart.Response:
|
||||||
|
data = await quart.request.json
|
||||||
|
required_fields = ['asset_url', 'owner', 'repo']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in data or not data[field]:
|
||||||
|
return self.http_status(400, -1, f'Missing required field: {field}')
|
||||||
|
asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0]
|
||||||
|
if not asset_url.endswith('skill.md') and not data.get('release_tag'):
|
||||||
|
return self.http_status(400, -1, 'Missing required field: release_tag')
|
||||||
|
|
||||||
|
try:
|
||||||
|
preview = await self.ap.skill_service.preview_install_from_github(data)
|
||||||
|
return self.success(data={'skills': preview})
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
|
||||||
|
|
||||||
|
@self.route('/install/upload', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def install_skill_from_upload() -> quart.Response:
|
||||||
|
file = (await quart.request.files).get('file')
|
||||||
|
if file is None:
|
||||||
|
return self.http_status(400, -1, 'file is required')
|
||||||
|
form = await quart.request.form
|
||||||
|
|
||||||
|
try:
|
||||||
|
skill = await self.ap.skill_service.install_from_zip_upload(
|
||||||
|
file_bytes=file.read(),
|
||||||
|
filename=file.filename or '',
|
||||||
|
source_paths=form.getlist('source_paths'),
|
||||||
|
)
|
||||||
|
return self.success(data={'skills': skill})
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
return self.http_status(500, -1, f'Failed to install skill: {exc}')
|
||||||
|
|
||||||
|
@self.route('/install/upload/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def preview_skill_from_upload() -> quart.Response:
|
||||||
|
file = (await quart.request.files).get('file')
|
||||||
|
if file is None:
|
||||||
|
return self.http_status(400, -1, 'file is required')
|
||||||
|
|
||||||
|
try:
|
||||||
|
preview = await self.ap.skill_service.preview_install_from_zip_upload(
|
||||||
|
file_bytes=file.read(),
|
||||||
|
filename=file.filename or '',
|
||||||
|
)
|
||||||
|
return self.success(data={'skills': preview})
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
|
||||||
|
|
||||||
|
@self.route('/scan', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def scan_skill_directory() -> quart.Response:
|
||||||
|
path = quart.request.args.get('path', '').strip()
|
||||||
|
if not path:
|
||||||
|
return self.http_status(400, -1, 'Missing required parameter: path')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.skill_service.scan_directory_async(path)
|
||||||
|
return self.success(data=result)
|
||||||
|
except (ValueError, BoxError) as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import quart
|
import quart
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
from .. import group
|
from .. import group
|
||||||
from .....utils import constants
|
from .....utils import constants
|
||||||
|
from .....entity.persistence.metadata import Metadata
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('system', '/api/v1/system')
|
@group.group_class('system', '/api/v1/system')
|
||||||
@@ -9,6 +13,24 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
|
# Read wizard_status and wizard_progress from metadata table
|
||||||
|
wizard_status = 'none'
|
||||||
|
wizard_progress = None
|
||||||
|
try:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress']))
|
||||||
|
)
|
||||||
|
for row in result:
|
||||||
|
if row.key == 'wizard_status':
|
||||||
|
wizard_status = row.value
|
||||||
|
elif row.key == 'wizard_progress':
|
||||||
|
try:
|
||||||
|
wizard_progress = json.loads(row.value)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
wizard_progress = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
@@ -27,17 +49,83 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
'disable_models_service', False
|
'disable_models_service', False
|
||||||
),
|
),
|
||||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||||
|
'wizard_status': wizard_status,
|
||||||
|
'wizard_progress': wizard_progress,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""Mark wizard status in metadata table and clear progress.
|
||||||
|
|
||||||
|
Accepts JSON body: { "status": "skipped" | "completed" }
|
||||||
|
"""
|
||||||
|
data = await quart.request.get_json(silent=True) or {}
|
||||||
|
status = data.get('status', 'completed')
|
||||||
|
if status not in ('skipped', 'completed'):
|
||||||
|
return self.http_status(400, 400, f'Invalid wizard status: {status}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status')
|
||||||
|
)
|
||||||
|
if result.first():
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(Metadata).values(key='wizard_status', value=status)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear wizard progress when wizard is completed/skipped
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress')
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, 500, f'Failed to update wizard status: {e}')
|
||||||
|
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
@self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""Save wizard progress to metadata table.
|
||||||
|
|
||||||
|
Accepts JSON body with wizard state fields:
|
||||||
|
{ "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null,
|
||||||
|
"bot_saved": bool, "selected_runner": str|null }
|
||||||
|
"""
|
||||||
|
data = await quart.request.get_json(silent=True) or {}
|
||||||
|
progress_json = json.dumps(data, ensure_ascii=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress')
|
||||||
|
)
|
||||||
|
if result.first():
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, 500, f'Failed to save wizard progress: {e}')
|
||||||
|
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
task_type = quart.request.args.get('type')
|
task_type = quart.request.args.get('type')
|
||||||
|
task_kind = quart.request.args.get('kind')
|
||||||
|
|
||||||
if task_type == '':
|
if task_type == '':
|
||||||
task_type = None
|
task_type = None
|
||||||
|
if task_kind == '':
|
||||||
|
task_kind = None
|
||||||
|
|
||||||
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type))
|
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
|
||||||
|
|
||||||
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(task_id: str) -> str:
|
async def _(task_id: str) -> str:
|
||||||
@@ -48,16 +136,9 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data=task.to_dict())
|
return self.success(data=task.to_dict())
|
||||||
|
|
||||||
@self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
if not constants.debug_mode:
|
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
|
||||||
return self.http_status(403, 403, 'Forbidden')
|
|
||||||
|
|
||||||
py_code = await quart.request.data
|
|
||||||
|
|
||||||
ap = self.ap
|
|
||||||
|
|
||||||
return self.success(data=exec(py_code, {'ap': ap}))
|
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/debug/plugin/action',
|
'/debug/plugin/action',
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.fail(3, str(e))
|
return self.fail(3, str(e))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
self.ap.logger.warning(f'Space OAuth callback failed: {e}')
|
||||||
return self.fail(1, str(e))
|
return self.fail(1, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@@ -105,6 +105,29 @@ class HTTPController:
|
|||||||
):
|
):
|
||||||
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
||||||
path += '.html'
|
path += '.html'
|
||||||
|
elif not path.startswith('api/'):
|
||||||
|
# SPA fallback: serve index.html for all non-API, non-static routes
|
||||||
|
# so that React Router can handle client-side routing (Vite SPA).
|
||||||
|
# For /home/* sub-routes, first try parent .html files (pre-rendered pages).
|
||||||
|
if path.startswith('home/'):
|
||||||
|
segments = path.rstrip('/').split('/')
|
||||||
|
for i in range(len(segments) - 1, 0, -1):
|
||||||
|
parent_path = '/'.join(segments[:i]) + '.html'
|
||||||
|
if os.path.exists(os.path.join(frontend_path, parent_path)):
|
||||||
|
response = await quart.send_from_directory(
|
||||||
|
frontend_path, parent_path, mimetype='text/html'
|
||||||
|
)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||||
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
response.headers['Expires'] = '0'
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Fallback to index.html for SPA client-side routing
|
||||||
|
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
|
||||||
else:
|
else:
|
||||||
return await quart.send_from_directory(frontend_path, '404.html')
|
return await quart.send_from_directory(frontend_path, '404.html')
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class ApiKeyService:
|
|||||||
|
|
||||||
async def verify_api_key(self, key: str) -> bool:
|
async def verify_api_key(self, key: str) -> bool:
|
||||||
"""Verify if an API key is valid"""
|
"""Verify if an API key is valid"""
|
||||||
|
if not isinstance(key, str) or not key.startswith('lbk_'):
|
||||||
|
return False
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ class BotService:
|
|||||||
# TODO: 检查配置信息格式
|
# TODO: 检查配置信息格式
|
||||||
bot_data['uuid'] = str(uuid.uuid4())
|
bot_data['uuid'] = str(uuid.uuid4())
|
||||||
|
|
||||||
# checkout the default pipeline
|
# bind the most recently updated pipeline if any exist
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
|
||||||
persistence_pipeline.LegacyPipeline.is_default == True
|
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
|
||||||
)
|
.limit(1)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
@@ -120,24 +120,26 @@ class BotService:
|
|||||||
|
|
||||||
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
||||||
"""Update bot"""
|
"""Update bot"""
|
||||||
if 'uuid' in bot_data:
|
update_data = bot_data.copy()
|
||||||
del bot_data['uuid']
|
|
||||||
|
if 'uuid' in update_data:
|
||||||
|
del update_data['uuid']
|
||||||
|
|
||||||
# set use_pipeline_name
|
# set use_pipeline_name
|
||||||
if 'use_pipeline_uuid' in bot_data:
|
if 'use_pipeline_uuid' in update_data:
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||||
persistence_pipeline.LegacyPipeline.uuid == bot_data['use_pipeline_uuid']
|
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
bot_data['use_pipeline_name'] = pipeline.name
|
update_data['use_pipeline_name'] = pipeline.name
|
||||||
else:
|
else:
|
||||||
raise Exception('Pipeline not found')
|
raise Exception('Pipeline not found')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_bot.Bot).values(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
||||||
)
|
)
|
||||||
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,126 @@ class KnowledgeService:
|
|||||||
if not knowledge_engine_plugin_id:
|
if not knowledge_engine_plugin_id:
|
||||||
raise ValueError('knowledge_engine_plugin_id is required')
|
raise ValueError('knowledge_engine_plugin_id is required')
|
||||||
|
|
||||||
|
creation_settings = kb_data.get('creation_settings', {})
|
||||||
|
retrieval_settings = kb_data.get('retrieval_settings', {})
|
||||||
|
|
||||||
|
# Validate required fields based on plugin's creation_schema and retrieval_schema
|
||||||
|
await self._validate_schema_required_fields(
|
||||||
|
knowledge_engine_plugin_id,
|
||||||
|
creation_settings,
|
||||||
|
retrieval_settings,
|
||||||
|
)
|
||||||
|
|
||||||
kb = await self.ap.rag_mgr.create_knowledge_base(
|
kb = await self.ap.rag_mgr.create_knowledge_base(
|
||||||
name=kb_data.get('name', 'Untitled'),
|
name=kb_data.get('name', 'Untitled'),
|
||||||
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
||||||
creation_settings=kb_data.get('creation_settings', {}),
|
creation_settings=creation_settings,
|
||||||
retrieval_settings=kb_data.get('retrieval_settings', {}),
|
retrieval_settings=retrieval_settings,
|
||||||
description=kb_data.get('description', ''),
|
description=kb_data.get('description', ''),
|
||||||
)
|
)
|
||||||
return kb.uuid
|
return kb.uuid
|
||||||
|
|
||||||
|
async def _validate_schema_required_fields(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
creation_settings: dict,
|
||||||
|
retrieval_settings: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Validate required fields based on plugin's creation_schema and retrieval_schema.
|
||||||
|
|
||||||
|
This is a business-agnostic validation that checks all fields marked as
|
||||||
|
required in the plugin's schema, regardless of field type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: Knowledge Engine plugin ID.
|
||||||
|
creation_settings: User-provided creation settings.
|
||||||
|
retrieval_settings: User-provided retrieval settings.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any required field is missing or empty.
|
||||||
|
"""
|
||||||
|
# Validate creation_schema
|
||||||
|
try:
|
||||||
|
creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
|
||||||
|
self._check_required_fields(creation_schema, creation_settings, 'creation_settings')
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}')
|
||||||
|
|
||||||
|
# Validate retrieval_schema
|
||||||
|
try:
|
||||||
|
retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
|
||||||
|
self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings')
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to get retrieval_schema for validation: {e}')
|
||||||
|
|
||||||
|
def _check_required_fields(
|
||||||
|
self,
|
||||||
|
schema: dict | list,
|
||||||
|
settings: dict,
|
||||||
|
context: str,
|
||||||
|
) -> None:
|
||||||
|
"""Check required fields in schema against provided settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: Plugin-defined schema (can be list or dict with 'schema' key).
|
||||||
|
settings: User-provided settings values.
|
||||||
|
context: Context name for error messages (e.g., 'creation_settings').
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a required field is missing or empty.
|
||||||
|
"""
|
||||||
|
if not schema:
|
||||||
|
return
|
||||||
|
|
||||||
|
# schema can be a list directly, or a dict with 'schema' key
|
||||||
|
items = schema if isinstance(schema, list) else schema.get('schema', [])
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
field_name = item.get('name')
|
||||||
|
if not field_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_required = item.get('required', False)
|
||||||
|
if not is_required:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check show_if condition - if field is conditionally shown, only validate when condition is met
|
||||||
|
show_if = item.get('show_if')
|
||||||
|
if show_if:
|
||||||
|
depend_field = show_if.get('field')
|
||||||
|
operator = show_if.get('operator')
|
||||||
|
expected_value = show_if.get('value')
|
||||||
|
|
||||||
|
if depend_field and operator:
|
||||||
|
depend_value = settings.get(depend_field)
|
||||||
|
# If show_if condition is not met, skip validation for this field
|
||||||
|
if operator == 'eq' and depend_value != expected_value:
|
||||||
|
continue
|
||||||
|
if operator == 'neq' and depend_value == expected_value:
|
||||||
|
continue
|
||||||
|
if operator == 'in' and isinstance(expected_value, list) and depend_value not in expected_value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = settings.get(field_name)
|
||||||
|
|
||||||
|
# Validate required field has a non-empty value
|
||||||
|
if value is None or (isinstance(value, str) and value.strip() == ''):
|
||||||
|
# Get field label for friendly error message
|
||||||
|
label = item.get('label', {})
|
||||||
|
field_label = (
|
||||||
|
label.get('en_US', field_name)
|
||||||
|
or label.get('zh_Hans', field_name)
|
||||||
|
or label.get('zh_Hant', field_name)
|
||||||
|
or field_name
|
||||||
|
)
|
||||||
|
raise ValueError(f'{field_label} is required ({context}.{field_name})')
|
||||||
|
|
||||||
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
||||||
"""更新知识库"""
|
"""更新知识库"""
|
||||||
# Filter to only mutable fields
|
# Filter to only mutable fields
|
||||||
|
|||||||
309
src/langbot/pkg/api/http/service/maintenance.py
Normal file
309
src/langbot/pkg/api/http/service/maintenance.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from ....core import app
|
||||||
|
from ....entity.persistence import bstorage as persistence_bstorage
|
||||||
|
from ....entity.persistence import monitoring as persistence_monitoring
|
||||||
|
|
||||||
|
|
||||||
|
LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
|
||||||
|
DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7
|
||||||
|
DEFAULT_LOG_RETENTION_DAYS = 3
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceService:
|
||||||
|
"""Storage maintenance and diagnostics."""
|
||||||
|
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application) -> None:
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
async def cleanup_expired_files(self) -> dict[str, int]:
|
||||||
|
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||||
|
upload_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('uploaded_file_retention_days'),
|
||||||
|
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.uploaded_file_retention_days',
|
||||||
|
)
|
||||||
|
log_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('log_retention_days'),
|
||||||
|
DEFAULT_LOG_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.log_retention_days',
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days),
|
||||||
|
'log_files': self._cleanup_expired_log_files(log_retention_days),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_storage_analysis(self) -> dict[str, Any]:
|
||||||
|
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||||
|
upload_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('uploaded_file_retention_days'),
|
||||||
|
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.uploaded_file_retention_days',
|
||||||
|
)
|
||||||
|
log_retention_days = self._positive_int(
|
||||||
|
cleanup_cfg.get('log_retention_days'),
|
||||||
|
DEFAULT_LOG_RETENTION_DAYS,
|
||||||
|
'storage.cleanup.log_retention_days',
|
||||||
|
)
|
||||||
|
|
||||||
|
database_cfg = self.ap.instance_config.data.get('database', {})
|
||||||
|
database_type = database_cfg.get('use', 'sqlite')
|
||||||
|
database_path = (
|
||||||
|
Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None
|
||||||
|
)
|
||||||
|
roots: list[tuple[str, Path | None]] = [
|
||||||
|
('database', database_path),
|
||||||
|
('logs', Path('data/logs')),
|
||||||
|
('storage', Path('data/storage')),
|
||||||
|
('vector_store', Path('data/chroma')),
|
||||||
|
('plugins', Path('data/plugins')),
|
||||||
|
('mcp', Path('data/mcp')),
|
||||||
|
('temp', Path('data/temp')),
|
||||||
|
]
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
for key, path in roots:
|
||||||
|
sections.append(
|
||||||
|
{
|
||||||
|
'key': key,
|
||||||
|
'path': str(path) if path else '',
|
||||||
|
'exists': path.exists() if path else False,
|
||||||
|
'size_bytes': self._path_size(path) if path else 0,
|
||||||
|
'file_count': self._file_count(path) if path else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monitoring_counts = await self._monitoring_counts()
|
||||||
|
binary_storage = await self._binary_storage_stats()
|
||||||
|
upload_candidates = await self._expired_uploaded_candidates(upload_retention_days)
|
||||||
|
log_candidates = self._expired_log_candidates(log_retention_days)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
'cleanup_policy': {
|
||||||
|
'uploaded_file_retention_days': upload_retention_days,
|
||||||
|
'log_retention_days': log_retention_days,
|
||||||
|
},
|
||||||
|
'sections': sections,
|
||||||
|
'database': {
|
||||||
|
'type': database_type,
|
||||||
|
'monitoring_counts': monitoring_counts,
|
||||||
|
'binary_storage': binary_storage,
|
||||||
|
},
|
||||||
|
'cleanup_candidates': {
|
||||||
|
'uploaded_files': upload_candidates,
|
||||||
|
'log_files': log_candidates,
|
||||||
|
},
|
||||||
|
'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int:
|
||||||
|
provider = self.ap.storage_mgr.storage_provider
|
||||||
|
provider_name = provider.__class__.__name__
|
||||||
|
if provider_name == 'LocalStorageProvider':
|
||||||
|
candidates = self._expired_local_upload_candidates(retention_days, include_paths=True)
|
||||||
|
deleted = 0
|
||||||
|
for item in candidates:
|
||||||
|
try:
|
||||||
|
os.remove(item['path'])
|
||||||
|
deleted += 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}')
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
if provider_name == 'S3StorageProvider':
|
||||||
|
return await self._cleanup_expired_s3_uploaded_files(retention_days)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
||||||
|
provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__
|
||||||
|
if provider_name == 'LocalStorageProvider':
|
||||||
|
return self._expired_local_upload_candidates(retention_days)
|
||||||
|
if provider_name == 'S3StorageProvider':
|
||||||
|
return await self._expired_s3_upload_candidates(retention_days)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int:
|
||||||
|
provider = self.ap.storage_mgr.storage_provider
|
||||||
|
candidates = await self._expired_s3_upload_candidates(retention_days)
|
||||||
|
deleted = 0
|
||||||
|
for item in candidates:
|
||||||
|
await provider.delete(item['key'])
|
||||||
|
deleted += 1
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
||||||
|
provider = self.ap.storage_mgr.storage_provider
|
||||||
|
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days)
|
||||||
|
candidates = []
|
||||||
|
paginator = provider.s3_client.get_paginator('list_objects_v2')
|
||||||
|
|
||||||
|
for page in paginator.paginate(Bucket=provider.bucket_name):
|
||||||
|
for obj in page.get('Contents', []):
|
||||||
|
key = obj.get('Key', '')
|
||||||
|
last_modified = obj.get('LastModified')
|
||||||
|
if not self._is_uploaded_file_key(key):
|
||||||
|
continue
|
||||||
|
if last_modified and last_modified < cutoff:
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
'key': key,
|
||||||
|
'size_bytes': obj.get('Size', 0),
|
||||||
|
'modified_at': last_modified.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _cleanup_expired_log_files(self, retention_days: int) -> int:
|
||||||
|
deleted = 0
|
||||||
|
for item in self._expired_log_candidates(retention_days, include_paths=True):
|
||||||
|
try:
|
||||||
|
os.remove(item['path'])
|
||||||
|
deleted += 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to delete expired log file {item["name"]}: {e}')
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def _expired_local_upload_candidates(
|
||||||
|
self, retention_days: int, include_paths: bool = False
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
storage_root = Path('data/storage')
|
||||||
|
if not storage_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
cutoff = datetime.datetime.now().timestamp() - retention_days * 86400
|
||||||
|
candidates = []
|
||||||
|
for entry in storage_root.iterdir():
|
||||||
|
if not entry.is_file() or not self._is_uploaded_file_key(entry.name):
|
||||||
|
continue
|
||||||
|
stat = entry.stat()
|
||||||
|
if stat.st_mtime >= cutoff:
|
||||||
|
continue
|
||||||
|
item = {
|
||||||
|
'key': entry.name,
|
||||||
|
'size_bytes': stat.st_size,
|
||||||
|
'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
if include_paths:
|
||||||
|
item['path'] = str(entry)
|
||||||
|
candidates.append(item)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]:
|
||||||
|
log_root = Path('data/logs')
|
||||||
|
if not log_root.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
cutoff_date = datetime.date.today() - datetime.timedelta(days=retention_days - 1)
|
||||||
|
candidates = []
|
||||||
|
for entry in log_root.iterdir():
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
match = LOG_FILE_PATTERN.match(entry.name)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
file_date = datetime.date.fromisoformat(match.group(1))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if file_date >= cutoff_date:
|
||||||
|
continue
|
||||||
|
stat = entry.stat()
|
||||||
|
item = {
|
||||||
|
'name': entry.name,
|
||||||
|
'date': file_date.isoformat(),
|
||||||
|
'size_bytes': stat.st_size,
|
||||||
|
}
|
||||||
|
if include_paths:
|
||||||
|
item['path'] = str(entry)
|
||||||
|
candidates.append(item)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _is_uploaded_file_key(self, key: str) -> bool:
|
||||||
|
return '/' not in key and not key.startswith('plugin_config_')
|
||||||
|
|
||||||
|
async def _monitoring_counts(self) -> dict[str, int]:
|
||||||
|
tables = {
|
||||||
|
'messages': persistence_monitoring.MonitoringMessage.id,
|
||||||
|
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
|
||||||
|
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||||
|
'errors': persistence_monitoring.MonitoringError.id,
|
||||||
|
'sessions': persistence_monitoring.MonitoringSession.session_id,
|
||||||
|
'feedback': persistence_monitoring.MonitoringFeedback.id,
|
||||||
|
}
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for key, column in tables.items():
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column)))
|
||||||
|
counts[key] = result.scalar() or 0
|
||||||
|
return counts
|
||||||
|
|
||||||
|
async def _binary_storage_stats(self) -> dict[str, Any]:
|
||||||
|
count_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key))
|
||||||
|
)
|
||||||
|
size_bytes = None
|
||||||
|
try:
|
||||||
|
size_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value)))
|
||||||
|
)
|
||||||
|
size_bytes = size_result.scalar() or 0
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to estimate binary storage size: {e}')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'count': count_result.scalar() or 0,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _path_size(self, path: Path) -> int:
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
if path.is_file():
|
||||||
|
return path.stat().st_size
|
||||||
|
total = 0
|
||||||
|
for root, _, files in os.walk(path):
|
||||||
|
for file_name in files:
|
||||||
|
file_path = Path(root) / file_name
|
||||||
|
try:
|
||||||
|
total += file_path.stat().st_size
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return total
|
||||||
|
|
||||||
|
def _file_count(self, path: Path) -> int:
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
if path.is_file():
|
||||||
|
return 1
|
||||||
|
count = 0
|
||||||
|
for _, _, files in os.walk(path):
|
||||||
|
count += len(files)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def _positive_int(self, value: Any, default: int, name: str) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
if parsed < 1:
|
||||||
|
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
return parsed
|
||||||
@@ -23,6 +23,17 @@ def _parse_provider_api_keys(provider_dict: dict) -> dict:
|
|||||||
return provider_dict
|
return provider_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
|
||||||
|
"""Return model data for rebuilding runtime models after an update.
|
||||||
|
|
||||||
|
Update payloads intentionally omit uuid before writing to the database.
|
||||||
|
Runtime model entities still need the stable uuid so pipeline configs can
|
||||||
|
resolve the in-memory model immediately after an edit, without requiring a
|
||||||
|
process restart.
|
||||||
|
"""
|
||||||
|
return {**model_data, 'uuid': model_uuid}
|
||||||
|
|
||||||
|
|
||||||
class LLMModelsService:
|
class LLMModelsService:
|
||||||
ap: app.Application
|
ap: app.Application
|
||||||
|
|
||||||
@@ -173,7 +184,7 @@ class LLMModelsService:
|
|||||||
raise Exception('provider not found')
|
raise Exception('provider not found')
|
||||||
|
|
||||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||||
persistence_model.LLMModel(**model_data),
|
persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
runtime_provider,
|
runtime_provider,
|
||||||
)
|
)
|
||||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||||
@@ -334,7 +345,7 @@ class EmbeddingModelsService:
|
|||||||
raise Exception('provider not found')
|
raise Exception('provider not found')
|
||||||
|
|
||||||
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
||||||
persistence_model.EmbeddingModel(**model_data),
|
persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
runtime_provider,
|
runtime_provider,
|
||||||
)
|
)
|
||||||
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
||||||
@@ -367,3 +378,162 @@ class EmbeddingModelsService:
|
|||||||
input_text=['Hello, world!'],
|
input_text=['Hello, world!'],
|
||||||
extra_args={},
|
extra_args={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RerankModelsService:
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application) -> None:
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
async def get_rerank_models(self) -> list[dict]:
|
||||||
|
"""Get all rerank models with provider info"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
|
||||||
|
models = result.all()
|
||||||
|
|
||||||
|
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.RerankModel, 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_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]:
|
||||||
|
"""Get rerank models by provider UUID"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||||
|
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
models = result.all()
|
||||||
|
return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
||||||
|
|
||||||
|
async def create_rerank_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||||
|
"""Create a new rerank 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.RerankModel).values(**model_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||||
|
if runtime_provider is None:
|
||||||
|
raise Exception('provider not found')
|
||||||
|
|
||||||
|
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||||
|
persistence_model.RerankModel(**model_data),
|
||||||
|
runtime_provider,
|
||||||
|
)
|
||||||
|
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||||
|
|
||||||
|
return model_data['uuid']
|
||||||
|
|
||||||
|
async def get_rerank_model(self, model_uuid: str) -> dict | None:
|
||||||
|
"""Get a single rerank model with provider info"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
|
||||||
|
)
|
||||||
|
model = result.first()
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, 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_rerank_model(self, model_uuid: str, model_data: dict) -> None:
|
||||||
|
"""Update an existing rerank 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.RerankModel)
|
||||||
|
.where(persistence_model.RerankModel.uuid == model_uuid)
|
||||||
|
.values(**model_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.ap.model_mgr.remove_rerank_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')
|
||||||
|
|
||||||
|
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||||
|
persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)),
|
||||||
|
runtime_provider,
|
||||||
|
)
|
||||||
|
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||||
|
|
||||||
|
async def delete_rerank_model(self, model_uuid: str) -> None:
|
||||||
|
"""Delete a rerank model"""
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
|
||||||
|
)
|
||||||
|
await self.ap.model_mgr.remove_rerank_model(model_uuid)
|
||||||
|
|
||||||
|
async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None:
|
||||||
|
"""Test a rerank model"""
|
||||||
|
runtime_rerank_model: model_requester.RuntimeRerankModel | None = None
|
||||||
|
|
||||||
|
if model_uuid != '_':
|
||||||
|
for model in self.ap.model_mgr.rerank_models:
|
||||||
|
if model.model_entity.uuid == model_uuid:
|
||||||
|
runtime_rerank_model = model
|
||||||
|
break
|
||||||
|
if runtime_rerank_model is None:
|
||||||
|
raise Exception('model not found')
|
||||||
|
else:
|
||||||
|
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data)
|
||||||
|
|
||||||
|
await runtime_rerank_model.provider.invoke_rerank(
|
||||||
|
model=runtime_rerank_model,
|
||||||
|
query='What is artificial intelligence?',
|
||||||
|
documents=[
|
||||||
|
'Artificial intelligence is a branch of computer science.',
|
||||||
|
'The weather is nice today.',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,6 +16,121 @@ class MonitoringService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
|
# ========== Cleanup Methods ==========
|
||||||
|
|
||||||
|
async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]:
|
||||||
|
"""Delete monitoring records older than the specified retention period.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
retention_days: Number of days to retain records.
|
||||||
|
batch_size: Maximum rows to delete per table batch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict mapping table name to the number of deleted rows.
|
||||||
|
"""
|
||||||
|
if retention_days < 1:
|
||||||
|
raise ValueError('retention_days must be >= 1')
|
||||||
|
if batch_size < 1:
|
||||||
|
raise ValueError('batch_size must be >= 1')
|
||||||
|
|
||||||
|
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
||||||
|
days=retention_days
|
||||||
|
)
|
||||||
|
|
||||||
|
tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [
|
||||||
|
(
|
||||||
|
'monitoring_messages',
|
||||||
|
persistence_monitoring.MonitoringMessage,
|
||||||
|
persistence_monitoring.MonitoringMessage.timestamp,
|
||||||
|
persistence_monitoring.MonitoringMessage.id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_llm_calls',
|
||||||
|
persistence_monitoring.MonitoringLLMCall,
|
||||||
|
persistence_monitoring.MonitoringLLMCall.timestamp,
|
||||||
|
persistence_monitoring.MonitoringLLMCall.id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_embedding_calls',
|
||||||
|
persistence_monitoring.MonitoringEmbeddingCall,
|
||||||
|
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
||||||
|
persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_errors',
|
||||||
|
persistence_monitoring.MonitoringError,
|
||||||
|
persistence_monitoring.MonitoringError.timestamp,
|
||||||
|
persistence_monitoring.MonitoringError.id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_sessions',
|
||||||
|
persistence_monitoring.MonitoringSession,
|
||||||
|
persistence_monitoring.MonitoringSession.last_activity,
|
||||||
|
persistence_monitoring.MonitoringSession.session_id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_feedback',
|
||||||
|
persistence_monitoring.MonitoringFeedback,
|
||||||
|
persistence_monitoring.MonitoringFeedback.timestamp,
|
||||||
|
persistence_monitoring.MonitoringFeedback.id,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
deleted_counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
for table_name, model_cls, ts_column, pk_column in tables_and_columns:
|
||||||
|
deleted_counts[table_name] = await self._delete_expired_in_batches(
|
||||||
|
model_cls=model_cls,
|
||||||
|
ts_column=ts_column,
|
||||||
|
pk_column=pk_column,
|
||||||
|
cutoff=cutoff,
|
||||||
|
batch_size=batch_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
if sum(deleted_counts.values()) > 0:
|
||||||
|
await self._release_sqlite_space()
|
||||||
|
|
||||||
|
return deleted_counts
|
||||||
|
|
||||||
|
async def _delete_expired_in_batches(
|
||||||
|
self,
|
||||||
|
model_cls: type,
|
||||||
|
ts_column: sqlalchemy.Column,
|
||||||
|
pk_column: sqlalchemy.Column,
|
||||||
|
cutoff: datetime.datetime,
|
||||||
|
batch_size: int,
|
||||||
|
) -> int:
|
||||||
|
deleted_total = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
select_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size)
|
||||||
|
)
|
||||||
|
pk_values = list(select_result.scalars().all())
|
||||||
|
if not pk_values:
|
||||||
|
break
|
||||||
|
|
||||||
|
delete_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values))
|
||||||
|
)
|
||||||
|
deleted = delete_result.rowcount or 0
|
||||||
|
deleted_total += deleted
|
||||||
|
|
||||||
|
if len(pk_values) < batch_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
return deleted_total
|
||||||
|
|
||||||
|
async def _release_sqlite_space(self) -> None:
|
||||||
|
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
||||||
|
if database_type != 'sqlite':
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self.ap.persistence_mgr.get_db_engine().connect() as conn:
|
||||||
|
autocommit_conn = await conn.execution_options(isolation_level='AUTOCOMMIT')
|
||||||
|
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
|
||||||
|
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
|
||||||
|
|
||||||
# ========== Recording Methods ==========
|
# ========== Recording Methods ==========
|
||||||
|
|
||||||
async def record_message(
|
async def record_message(
|
||||||
@@ -1132,3 +1247,314 @@ class MonitoringService:
|
|||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ========== Feedback Methods ==========
|
||||||
|
|
||||||
|
async def record_feedback(
|
||||||
|
self,
|
||||||
|
feedback_id: str,
|
||||||
|
feedback_type: int,
|
||||||
|
feedback_content: str | None = None,
|
||||||
|
inaccurate_reasons: list[str] | None = None,
|
||||||
|
bot_id: str | None = None,
|
||||||
|
bot_name: str | None = None,
|
||||||
|
pipeline_id: str | None = None,
|
||||||
|
pipeline_name: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
message_id: str | None = None,
|
||||||
|
stream_id: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Record user feedback (like/dislike) from AI Bot conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feedback_id: Unique feedback identifier from platform (e.g., WeChat Work)
|
||||||
|
feedback_type: 1 = like (thumbs up), 2 = dislike (thumbs down)
|
||||||
|
feedback_content: Optional user feedback text
|
||||||
|
inaccurate_reasons: List of reasons for inaccurate response (for dislike)
|
||||||
|
bot_id: Bot ID
|
||||||
|
bot_name: Bot name
|
||||||
|
pipeline_id: Pipeline ID
|
||||||
|
pipeline_name: Pipeline name
|
||||||
|
session_id: Session ID
|
||||||
|
message_id: Message ID
|
||||||
|
stream_id: Stream ID (for WeChat Work streaming messages)
|
||||||
|
user_id: User ID
|
||||||
|
platform: Platform name (e.g., 'wecom')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The record ID
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
|
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
|
||||||
|
|
||||||
|
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
|
||||||
|
|
||||||
|
# Handle cancel feedback (type=3): delete existing record
|
||||||
|
if feedback_type == 3:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if record with this feedback_id already exists
|
||||||
|
existing_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
|
||||||
|
)
|
||||||
|
existing_row = existing_result.first()
|
||||||
|
|
||||||
|
if existing_row:
|
||||||
|
# UPDATE existing record
|
||||||
|
existing = existing_row[0] if isinstance(existing_row, tuple) else existing_row
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(MonitoringFeedback)
|
||||||
|
.where(MonitoringFeedback.feedback_id == feedback_id)
|
||||||
|
.values(
|
||||||
|
timestamp=now,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=reasons_json,
|
||||||
|
bot_id=bot_id or existing.bot_id,
|
||||||
|
bot_name=bot_name or existing.bot_name,
|
||||||
|
pipeline_id=pipeline_id or existing.pipeline_id,
|
||||||
|
pipeline_name=pipeline_name or existing.pipeline_name,
|
||||||
|
session_id=session_id or existing.session_id,
|
||||||
|
message_id=message_id or existing.message_id,
|
||||||
|
stream_id=stream_id or existing.stream_id,
|
||||||
|
user_id=user_id or existing.user_id,
|
||||||
|
platform=platform or existing.platform,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return existing.id
|
||||||
|
else:
|
||||||
|
# INSERT new record with IntegrityError defense
|
||||||
|
record_id = str(uuid.uuid4())
|
||||||
|
record_data = {
|
||||||
|
'id': record_id,
|
||||||
|
'timestamp': now,
|
||||||
|
'feedback_id': feedback_id,
|
||||||
|
'feedback_type': feedback_type,
|
||||||
|
'feedback_content': feedback_content,
|
||||||
|
'inaccurate_reasons': reasons_json,
|
||||||
|
'bot_id': bot_id,
|
||||||
|
'bot_name': bot_name,
|
||||||
|
'pipeline_id': pipeline_id,
|
||||||
|
'pipeline_name': pipeline_name,
|
||||||
|
'session_id': session_id,
|
||||||
|
'message_id': message_id,
|
||||||
|
'stream_id': stream_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
'platform': platform,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringFeedback).values(record_data))
|
||||||
|
return record_id
|
||||||
|
except Exception:
|
||||||
|
# UNIQUE constraint conflict (concurrent feedback for same feedback_id)
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(MonitoringFeedback)
|
||||||
|
.where(MonitoringFeedback.feedback_id == feedback_id)
|
||||||
|
.values(
|
||||||
|
timestamp=now,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=reasons_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return feedback_id
|
||||||
|
|
||||||
|
async def get_feedback_stats(
|
||||||
|
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 feedback statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if bot_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||||
|
if pipeline_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||||
|
if start_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||||
|
if end_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||||
|
|
||||||
|
# Get total likes (feedback_type = 1)
|
||||||
|
likes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
|
||||||
|
persistence_monitoring.MonitoringFeedback.feedback_type == 1
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
likes_query = likes_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
likes_result = await self.ap.persistence_mgr.execute_async(likes_query)
|
||||||
|
total_likes = likes_result.scalar() or 0
|
||||||
|
|
||||||
|
# Get total dislikes (feedback_type = 2)
|
||||||
|
dislikes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
|
||||||
|
persistence_monitoring.MonitoringFeedback.feedback_type == 2
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
dislikes_query = dislikes_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
dislikes_result = await self.ap.persistence_mgr.execute_async(dislikes_query)
|
||||||
|
total_dislikes = dislikes_result.scalar() or 0
|
||||||
|
|
||||||
|
# Get total feedback count
|
||||||
|
total_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
|
||||||
|
if conditions:
|
||||||
|
total_query = total_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
total_result = await self.ap.persistence_mgr.execute_async(total_query)
|
||||||
|
total_feedback = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Calculate satisfaction rate
|
||||||
|
satisfaction_rate = (total_likes / total_feedback * 100) if total_feedback > 0 else 0
|
||||||
|
|
||||||
|
# Get feedback by bot
|
||||||
|
bot_stats_query = sqlalchemy.select(
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_id,
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_name,
|
||||||
|
sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id).label('total'),
|
||||||
|
sqlalchemy.func.sum(
|
||||||
|
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 1, 1), else_=0)
|
||||||
|
).label('likes'),
|
||||||
|
sqlalchemy.func.sum(
|
||||||
|
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 2, 1), else_=0)
|
||||||
|
).label('dislikes'),
|
||||||
|
).group_by(
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_id,
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_name,
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
bot_stats_query = bot_stats_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
bot_stats_result = await self.ap.persistence_mgr.execute_async(bot_stats_query)
|
||||||
|
bot_stats = [
|
||||||
|
{
|
||||||
|
'bot_id': row.bot_id,
|
||||||
|
'bot_name': row.bot_name,
|
||||||
|
'total': row.total,
|
||||||
|
'likes': row.likes or 0,
|
||||||
|
'dislikes': row.dislikes or 0,
|
||||||
|
}
|
||||||
|
for row in bot_stats_result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_feedback': total_feedback,
|
||||||
|
'total_likes': total_likes,
|
||||||
|
'total_dislikes': total_dislikes,
|
||||||
|
'satisfaction_rate': round(satisfaction_rate, 2),
|
||||||
|
'by_bot': bot_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_feedback_list(
|
||||||
|
self,
|
||||||
|
bot_ids: list[str] | None = None,
|
||||||
|
pipeline_ids: list[str] | None = None,
|
||||||
|
feedback_type: int | 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 feedback list with filters."""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if bot_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||||
|
if pipeline_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||||
|
if feedback_type is not None:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type)
|
||||||
|
if start_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||||
|
if end_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.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 feedback list
|
||||||
|
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
|
||||||
|
persistence_monitoring.MonitoringFeedback.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)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
self.ap.persistence_mgr.serialize_model(
|
||||||
|
persistence_monitoring.MonitoringFeedback, row[0] if isinstance(row, tuple) else row
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def export_feedback(
|
||||||
|
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 = 100000,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Export feedback as list of dictionaries for CSV conversion."""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if bot_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||||
|
if pipeline_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||||
|
if start_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||||
|
if end_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||||
|
|
||||||
|
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
|
||||||
|
persistence_monitoring.MonitoringFeedback.timestamp.desc()
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
query = query.where(sqlalchemy.and_(*conditions))
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': row[0].id if isinstance(row, tuple) else row.id,
|
||||||
|
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
|
||||||
|
'feedback_id': row[0].feedback_id if isinstance(row, tuple) else row.feedback_id,
|
||||||
|
'feedback_type': 'like'
|
||||||
|
if (row[0].feedback_type if isinstance(row, tuple) else row.feedback_type) == 1
|
||||||
|
else 'dislike',
|
||||||
|
'feedback_content': row[0].feedback_content if isinstance(row, tuple) else row.feedback_content,
|
||||||
|
'inaccurate_reasons': row[0].inaccurate_reasons if isinstance(row, tuple) else row.inaccurate_reasons,
|
||||||
|
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
|
||||||
|
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
|
||||||
|
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
|
||||||
|
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
|
||||||
|
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
|
||||||
|
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
|
||||||
|
'stream_id': row[0].stream_id if isinstance(row, tuple) else row.stream_id,
|
||||||
|
'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,
|
||||||
|
'platform': row[0].platform if isinstance(row, tuple) else row.platform,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|||||||
@@ -113,14 +113,9 @@ class PipelineService:
|
|||||||
return pipeline_data['uuid']
|
return pipeline_data['uuid']
|
||||||
|
|
||||||
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
|
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
|
||||||
if 'uuid' in pipeline_data:
|
pipeline_data = pipeline_data.copy()
|
||||||
del pipeline_data['uuid']
|
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
|
||||||
if 'for_version' in pipeline_data:
|
pipeline_data.pop(protected_field, None)
|
||||||
del pipeline_data['for_version']
|
|
||||||
if 'stages' in pipeline_data:
|
|
||||||
del pipeline_data['stages']
|
|
||||||
if 'is_default' in pipeline_data:
|
|
||||||
del pipeline_data['is_default']
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||||
@@ -220,6 +215,8 @@ class PipelineService:
|
|||||||
bound_mcp_servers: list[str] = None,
|
bound_mcp_servers: list[str] = None,
|
||||||
enable_all_plugins: bool = True,
|
enable_all_plugins: bool = True,
|
||||||
enable_all_mcp_servers: bool = True,
|
enable_all_mcp_servers: bool = True,
|
||||||
|
bound_skills: list[str] = None,
|
||||||
|
enable_all_skills: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update the bound plugins and MCP servers for a pipeline"""
|
"""Update the bound plugins and MCP servers for a pipeline"""
|
||||||
# Get current pipeline
|
# Get current pipeline
|
||||||
@@ -237,9 +234,12 @@ class PipelineService:
|
|||||||
extensions_preferences = pipeline.extensions_preferences or {}
|
extensions_preferences = pipeline.extensions_preferences or {}
|
||||||
extensions_preferences['enable_all_plugins'] = enable_all_plugins
|
extensions_preferences['enable_all_plugins'] = enable_all_plugins
|
||||||
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
||||||
|
extensions_preferences['enable_all_skills'] = enable_all_skills
|
||||||
extensions_preferences['plugins'] = bound_plugins
|
extensions_preferences['plugins'] = bound_plugins
|
||||||
if bound_mcp_servers is not None:
|
if bound_mcp_servers is not None:
|
||||||
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
||||||
|
if bound_skills is not None:
|
||||||
|
extensions_preferences['skills'] = bound_skills
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import traceback
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -16,6 +17,24 @@ class ModelProviderService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
|
if api_keys is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw_keys = [api_keys] if isinstance(api_keys, str) else list(api_keys)
|
||||||
|
normalized_keys = []
|
||||||
|
seen_keys = set()
|
||||||
|
|
||||||
|
for raw_key in raw_keys:
|
||||||
|
normalized_key = raw_key.strip() if isinstance(raw_key, str) else ''
|
||||||
|
if not normalized_key or normalized_key in seen_keys:
|
||||||
|
continue
|
||||||
|
normalized_keys.append(normalized_key)
|
||||||
|
seen_keys.add(normalized_key)
|
||||||
|
|
||||||
|
return normalized_keys
|
||||||
|
|
||||||
async def get_providers(self) -> list[dict]:
|
async def get_providers(self) -> list[dict]:
|
||||||
"""Get all providers"""
|
"""Get all providers"""
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
||||||
@@ -58,6 +77,7 @@ class ModelProviderService:
|
|||||||
async def create_provider(self, provider_data: dict) -> str:
|
async def create_provider(self, provider_data: dict) -> str:
|
||||||
"""Create a new provider"""
|
"""Create a new provider"""
|
||||||
provider_data['uuid'] = str(uuid.uuid4())
|
provider_data['uuid'] = str(uuid.uuid4())
|
||||||
|
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||||
)
|
)
|
||||||
@@ -71,6 +91,8 @@ class ModelProviderService:
|
|||||||
"""Update an existing provider"""
|
"""Update an existing provider"""
|
||||||
if 'uuid' in provider_data:
|
if 'uuid' in provider_data:
|
||||||
del provider_data['uuid']
|
del provider_data['uuid']
|
||||||
|
if 'api_keys' in provider_data:
|
||||||
|
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_model.ModelProvider)
|
sqlalchemy.update(persistence_model.ModelProvider)
|
||||||
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||||
@@ -97,6 +119,14 @@ class ModelProviderService:
|
|||||||
if embedding_result.first() is not None:
|
if embedding_result.first() is not None:
|
||||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
||||||
|
|
||||||
|
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||||
|
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if rerank_result.first() is not None:
|
||||||
|
raise ValueError('Cannot delete provider: Rerank models still reference it')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||||
persistence_model.ModelProvider.uuid == provider_uuid
|
persistence_model.ModelProvider.uuid == provider_uuid
|
||||||
@@ -121,10 +151,19 @@ class ModelProviderService:
|
|||||||
)
|
)
|
||||||
embedding_count = embedding_result.scalar() or 0
|
embedding_count = embedding_result.scalar() or 0
|
||||||
|
|
||||||
return {'llm_count': llm_count, 'embedding_count': embedding_count}
|
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.count())
|
||||||
|
.select_from(persistence_model.RerankModel)
|
||||||
|
.where(persistence_model.RerankModel.provider_uuid == provider_uuid)
|
||||||
|
)
|
||||||
|
rerank_count = rerank_result.scalar() or 0
|
||||||
|
|
||||||
|
return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count}
|
||||||
|
|
||||||
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
||||||
"""Find existing provider or create new one"""
|
"""Find existing provider or create new one"""
|
||||||
|
api_keys = self._normalize_api_keys(api_keys)
|
||||||
|
|
||||||
# Try to find existing provider with same config
|
# Try to find existing provider with same config
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||||
@@ -152,7 +191,7 @@ class ModelProviderService:
|
|||||||
'name': provider_name,
|
'name': provider_name,
|
||||||
'requester': requester,
|
'requester': requester,
|
||||||
'base_url': base_url,
|
'base_url': base_url,
|
||||||
'api_keys': api_keys or [],
|
'api_keys': api_keys,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,6 +200,69 @@ class ModelProviderService:
|
|||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_model.ModelProvider)
|
sqlalchemy.update(persistence_model.ModelProvider)
|
||||||
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
||||||
.values(api_keys=[api_key])
|
.values(api_keys=self._normalize_api_keys(api_key))
|
||||||
)
|
)
|
||||||
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
||||||
|
|
||||||
|
async def scan_provider_models(self, provider_uuid: str, model_type: str | None = None) -> dict:
|
||||||
|
provider = await self.get_provider(provider_uuid)
|
||||||
|
if provider is None:
|
||||||
|
raise ValueError('provider not found')
|
||||||
|
|
||||||
|
runtime_provider = await self.ap.model_mgr.load_provider(provider)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scan_result = await runtime_provider.requester.scan_models(
|
||||||
|
runtime_provider.token_mgr.get_token() if runtime_provider.token_mgr.tokens else None
|
||||||
|
)
|
||||||
|
except NotImplementedError:
|
||||||
|
raise ValueError('current provider does not support model scanning')
|
||||||
|
except Exception as exc:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Failed to scan models for provider {provider_uuid}: {exc}\n{traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
|
|
||||||
|
if isinstance(scan_result, dict):
|
||||||
|
scanned_models = scan_result.get('models', [])
|
||||||
|
debug_info = scan_result.get('debug')
|
||||||
|
else:
|
||||||
|
scanned_models = scan_result
|
||||||
|
debug_info = None
|
||||||
|
|
||||||
|
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)
|
||||||
|
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid)
|
||||||
|
existing_llm_names = {model['name'] for model in llm_models}
|
||||||
|
existing_embedding_names = {model['name'] for model in embedding_models}
|
||||||
|
|
||||||
|
filtered_models = []
|
||||||
|
for model in scanned_models:
|
||||||
|
scanned_type = model.get('type', 'llm')
|
||||||
|
if model_type and scanned_type != model_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_name = model.get('name') or model.get('id')
|
||||||
|
if not model_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered_models.append(
|
||||||
|
{
|
||||||
|
'id': model.get('id', model_name),
|
||||||
|
'name': model_name,
|
||||||
|
'type': scanned_type,
|
||||||
|
'abilities': model.get('abilities', []),
|
||||||
|
'display_name': model.get('display_name'),
|
||||||
|
'description': model.get('description'),
|
||||||
|
'context_length': model.get('context_length'),
|
||||||
|
'owned_by': model.get('owned_by'),
|
||||||
|
'input_modalities': model.get('input_modalities', []),
|
||||||
|
'output_modalities': model.get('output_modalities', []),
|
||||||
|
'already_added': (
|
||||||
|
model_name in existing_embedding_names
|
||||||
|
if scanned_type == 'embedding'
|
||||||
|
else model_name in existing_llm_names
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {'models': filtered_models, 'debug': debug_info}
|
||||||
|
|||||||
428
src/langbot/pkg/api/http/service/skill.py
Normal file
428
src/langbot/pkg/api/http/service/skill.py
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
|
import zipfile
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import quote, unquote, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ....core import app
|
||||||
|
from ....skill.utils import parse_frontmatter
|
||||||
|
|
||||||
|
|
||||||
|
_PUBLIC_SKILL_FIELDS = (
|
||||||
|
'name',
|
||||||
|
'display_name',
|
||||||
|
'description',
|
||||||
|
'instructions',
|
||||||
|
'package_root',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
)
|
||||||
|
|
||||||
|
_GITHUB_ASSET_HOSTS = {
|
||||||
|
'github.com',
|
||||||
|
'api.github.com',
|
||||||
|
'objects.githubusercontent.com',
|
||||||
|
'githubusercontent.com',
|
||||||
|
'raw.githubusercontent.com',
|
||||||
|
'codeload.github.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SkillService:
|
||||||
|
"""Filesystem-backed skill management service."""
|
||||||
|
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application) -> None:
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
def _box_service(self):
|
||||||
|
box_service = getattr(self.ap, 'box_service', None)
|
||||||
|
if box_service is not None and getattr(box_service, 'available', False):
|
||||||
|
return box_service
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _require_box(self, action: str):
|
||||||
|
"""Return the Box service or raise if it is not available.
|
||||||
|
|
||||||
|
Box is the only source of truth for skills. Every read and write
|
||||||
|
operation goes through it — there is no local-filesystem fallback.
|
||||||
|
"""
|
||||||
|
box_service = self._box_service()
|
||||||
|
if box_service is not None:
|
||||||
|
return box_service
|
||||||
|
ap_box = getattr(self.ap, 'box_service', None)
|
||||||
|
if ap_box is None:
|
||||||
|
reason = 'not initialised'
|
||||||
|
elif not getattr(ap_box, 'enabled', True):
|
||||||
|
reason = 'disabled in config (box.enabled = false)'
|
||||||
|
else:
|
||||||
|
connector_error = getattr(ap_box, '_connector_error', '') or 'currently unavailable'
|
||||||
|
reason = f'unavailable: {connector_error}'
|
||||||
|
raise ValueError(
|
||||||
|
f'{action} requires the Box runtime, which is {reason}. '
|
||||||
|
f'Enable Box in config.yaml (box.enabled = true) and ensure the '
|
||||||
|
f'runtime is reachable before retrying.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _require_box_for_write(self, action: str) -> None:
|
||||||
|
"""Backwards-compatible alias preserved for clarity at call sites."""
|
||||||
|
self._require_box(action)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_skill(skill: dict) -> dict:
|
||||||
|
return {field: skill.get(field) for field in _PUBLIC_SKILL_FIELDS if field in skill}
|
||||||
|
|
||||||
|
async def list_skills(self) -> list[dict]:
|
||||||
|
# When Box is unavailable, surface an empty list rather than raising —
|
||||||
|
# the skills page should render cleanly, and the UI separately renders
|
||||||
|
# a "Box disabled / unavailable" banner via useBoxStatus.
|
||||||
|
box_service = self._box_service()
|
||||||
|
if box_service is None:
|
||||||
|
return []
|
||||||
|
return [self._serialize_skill(skill) for skill in await box_service.list_skills()]
|
||||||
|
|
||||||
|
async def get_skill(self, skill_name: str) -> Optional[dict]:
|
||||||
|
box_service = self._box_service()
|
||||||
|
if box_service is None:
|
||||||
|
return None
|
||||||
|
skill = await box_service.get_skill(skill_name)
|
||||||
|
return self._serialize_skill(skill) if skill else None
|
||||||
|
|
||||||
|
async def get_skill_by_name(self, name: str) -> Optional[dict]:
|
||||||
|
return await self.get_skill(name)
|
||||||
|
|
||||||
|
async def create_skill(self, data: dict) -> dict:
|
||||||
|
box_service = self._require_box('Creating a skill')
|
||||||
|
created = await box_service.create_skill(data)
|
||||||
|
await self._reload_skills()
|
||||||
|
return self._serialize_skill(created)
|
||||||
|
|
||||||
|
async def update_skill(self, skill_name: str, data: dict) -> dict:
|
||||||
|
box_service = self._require_box('Editing a skill')
|
||||||
|
updated = await box_service.update_skill(skill_name, data)
|
||||||
|
await self._reload_skills()
|
||||||
|
return self._serialize_skill(updated)
|
||||||
|
|
||||||
|
async def delete_skill(self, skill_name: str) -> bool:
|
||||||
|
box_service = self._require_box('Deleting a skill')
|
||||||
|
await box_service.delete_skill(skill_name)
|
||||||
|
await self._reload_skills()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def list_skill_files(
|
||||||
|
self,
|
||||||
|
skill_name: str,
|
||||||
|
path: str = '.',
|
||||||
|
include_hidden: bool = False,
|
||||||
|
max_entries: int = 200,
|
||||||
|
) -> dict:
|
||||||
|
box_service = self._require_box('Browsing skill files')
|
||||||
|
return await box_service.list_skill_files(skill_name, path, include_hidden, max_entries)
|
||||||
|
|
||||||
|
async def read_skill_file(self, skill_name: str, path: str) -> dict:
|
||||||
|
box_service = self._require_box('Reading a skill file')
|
||||||
|
return await box_service.read_skill_file(skill_name, path)
|
||||||
|
|
||||||
|
async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict:
|
||||||
|
box_service = self._require_box('Editing skill files')
|
||||||
|
result = await box_service.write_skill_file(skill_name, path, content)
|
||||||
|
await self._reload_skills()
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def install_from_github(self, data: dict) -> list[dict]:
|
||||||
|
box_service = self._require_box('Installing a skill from GitHub')
|
||||||
|
owner = str(data['owner']).strip()
|
||||||
|
repo = str(data['repo']).strip()
|
||||||
|
release_tag = str(data.get('release_tag', '')).strip()
|
||||||
|
raw_asset_url = str(data['asset_url']).strip()
|
||||||
|
if self._is_github_skill_md_url(raw_asset_url):
|
||||||
|
return await self._install_github_skill_md(raw_asset_url, owner=owner, repo=repo, data=data)
|
||||||
|
|
||||||
|
asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag)
|
||||||
|
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||||
|
|
||||||
|
zip_bytes = await self._download_github_asset(asset_url)
|
||||||
|
filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip'
|
||||||
|
installed = await box_service.install_skill_zip(
|
||||||
|
zip_bytes,
|
||||||
|
filename,
|
||||||
|
source_paths=data.get('source_paths') or [],
|
||||||
|
source_path=str(data.get('source_path', '') or ''),
|
||||||
|
source_subdir=source_subdir,
|
||||||
|
)
|
||||||
|
await self._reload_skills()
|
||||||
|
return [self._serialize_skill(skill) for skill in installed]
|
||||||
|
|
||||||
|
async def preview_install_from_github(self, data: dict) -> list[dict]:
|
||||||
|
box_service = self._require_box('Previewing a skill from GitHub')
|
||||||
|
owner = str(data['owner']).strip()
|
||||||
|
repo = str(data['repo']).strip()
|
||||||
|
release_tag = str(data.get('release_tag', '')).strip()
|
||||||
|
raw_asset_url = str(data['asset_url']).strip()
|
||||||
|
if self._is_github_skill_md_url(raw_asset_url):
|
||||||
|
return await self._preview_github_skill_md(raw_asset_url, owner=owner, repo=repo)
|
||||||
|
|
||||||
|
asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag)
|
||||||
|
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||||
|
|
||||||
|
zip_bytes = await self._download_github_asset(asset_url)
|
||||||
|
return await box_service.preview_skill_zip(
|
||||||
|
zip_bytes,
|
||||||
|
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
|
||||||
|
source_subdir=source_subdir,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def install_from_zip_upload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
file_bytes: bytes,
|
||||||
|
filename: str,
|
||||||
|
source_paths: list[str] | None = None,
|
||||||
|
source_path: str = '',
|
||||||
|
) -> list[dict]:
|
||||||
|
box_service = self._require_box('Installing a skill from upload')
|
||||||
|
installed = await box_service.install_skill_zip(
|
||||||
|
file_bytes,
|
||||||
|
filename,
|
||||||
|
source_paths=source_paths or [],
|
||||||
|
source_path=source_path,
|
||||||
|
)
|
||||||
|
await self._reload_skills()
|
||||||
|
return [self._serialize_skill(skill) for skill in installed]
|
||||||
|
|
||||||
|
async def preview_install_from_zip_upload(self, *, file_bytes: bytes, filename: str) -> list[dict]:
|
||||||
|
box_service = self._require_box('Previewing a skill upload')
|
||||||
|
return await box_service.preview_skill_zip(file_bytes, filename)
|
||||||
|
|
||||||
|
async def _install_github_skill_md(self, asset_url: str, *, owner: str, repo: str, data: dict) -> list[dict]:
|
||||||
|
box_service = self._require_box('Installing a skill from GitHub')
|
||||||
|
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
|
||||||
|
asset_url,
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
installed = await box_service.install_skill_zip(
|
||||||
|
zip_bytes,
|
||||||
|
filename,
|
||||||
|
source_paths=data.get('source_paths') or [],
|
||||||
|
source_path=str(data.get('source_path', '') or ''),
|
||||||
|
target_suffix='',
|
||||||
|
)
|
||||||
|
await self._reload_skills()
|
||||||
|
return [self._serialize_skill(skill) for skill in installed]
|
||||||
|
|
||||||
|
async def _preview_github_skill_md(self, asset_url: str, *, owner: str, repo: str) -> list[dict]:
|
||||||
|
box_service = self._require_box('Previewing a skill from GitHub')
|
||||||
|
zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
|
||||||
|
asset_url,
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
)
|
||||||
|
return await box_service.preview_skill_zip(zip_bytes, f'{package_name}.zip', target_suffix='')
|
||||||
|
|
||||||
|
async def reload_skills(self) -> list[dict]:
|
||||||
|
await self._reload_skills()
|
||||||
|
return await self.list_skills()
|
||||||
|
|
||||||
|
async def scan_directory_async(self, path: str) -> dict:
|
||||||
|
box_service = self._require_box('Scanning a skill directory')
|
||||||
|
return await box_service.scan_skill_directory(path)
|
||||||
|
|
||||||
|
async def _reload_skills(self) -> None:
|
||||||
|
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||||
|
reload_skills = getattr(skill_mgr, 'reload_skills', None)
|
||||||
|
if not callable(reload_skills):
|
||||||
|
return
|
||||||
|
result = reload_skills()
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
|
||||||
|
async def _download_github_asset(self, asset_url: str) -> bytes:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True, timeout=120) as client:
|
||||||
|
resp = await client.get(asset_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
async def _download_github_skill_directory_as_zip(
|
||||||
|
self, asset_url: str, *, owner: str, repo: str
|
||||||
|
) -> tuple[bytes, str, str]:
|
||||||
|
info = self._parse_github_skill_md_url(asset_url, owner=owner, repo=repo)
|
||||||
|
archive_url = f'https://codeload.github.com/{owner}/{repo}/zip/{quote(info["ref"], safe="/")}'
|
||||||
|
archive_bytes = await self._download_github_asset(archive_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
source_archive = zipfile.ZipFile(io.BytesIO(archive_bytes), 'r')
|
||||||
|
except zipfile.BadZipFile as exc:
|
||||||
|
raise ValueError('GitHub repository archive must be a valid .zip archive') from exc
|
||||||
|
|
||||||
|
with source_archive as source_zip:
|
||||||
|
skill_entry = self._find_github_skill_archive_entry(source_zip, info['file_path'])
|
||||||
|
try:
|
||||||
|
skill_md_content = source_zip.read(skill_entry).decode('utf-8')
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValueError('GitHub SKILL.md must be valid UTF-8 text') from exc
|
||||||
|
|
||||||
|
package_name = self._resolve_github_skill_md_package_name(skill_md_content, info['package_name'])
|
||||||
|
source_skill_dir = posixpath.dirname(posixpath.normpath(skill_entry.filename))
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as target_zip:
|
||||||
|
self._copy_github_skill_directory_to_zip(source_zip, target_zip, source_skill_dir, package_name)
|
||||||
|
return buffer.getvalue(), f'{package_name}.zip', package_name
|
||||||
|
|
||||||
|
def _find_github_skill_archive_entry(self, archive: zipfile.ZipFile, file_path: str) -> zipfile.ZipInfo:
|
||||||
|
normalized_file_path = posixpath.normpath(file_path).lower()
|
||||||
|
for member in archive.infolist():
|
||||||
|
if member.is_dir():
|
||||||
|
continue
|
||||||
|
normalized_member = posixpath.normpath(member.filename)
|
||||||
|
path_parts = normalized_member.split('/', 1)
|
||||||
|
if len(path_parts) != 2:
|
||||||
|
continue
|
||||||
|
archive_relative_path = path_parts[1].lower()
|
||||||
|
if archive_relative_path == normalized_file_path:
|
||||||
|
return member
|
||||||
|
raise ValueError(f'GitHub archive does not contain requested SKILL.md: {file_path}')
|
||||||
|
|
||||||
|
def _copy_github_skill_directory_to_zip(
|
||||||
|
self,
|
||||||
|
source_zip: zipfile.ZipFile,
|
||||||
|
target_zip: zipfile.ZipFile,
|
||||||
|
source_skill_dir: str,
|
||||||
|
package_name: str,
|
||||||
|
) -> None:
|
||||||
|
normalized_source_dir = posixpath.normpath(source_skill_dir)
|
||||||
|
source_prefix = f'{normalized_source_dir}/'
|
||||||
|
copied_files = 0
|
||||||
|
|
||||||
|
for member in source_zip.infolist():
|
||||||
|
normalized_member = posixpath.normpath(member.filename)
|
||||||
|
if normalized_member != normalized_source_dir and not normalized_member.startswith(source_prefix):
|
||||||
|
continue
|
||||||
|
|
||||||
|
relative_path = posixpath.relpath(normalized_member, normalized_source_dir)
|
||||||
|
if relative_path in ('', '.'):
|
||||||
|
continue
|
||||||
|
if relative_path.startswith('../') or relative_path == '..' or posixpath.isabs(relative_path):
|
||||||
|
raise ValueError(f'GitHub archive contains an unsafe skill path: {member.filename}')
|
||||||
|
|
||||||
|
target_name = f'{package_name}/{relative_path}'
|
||||||
|
if member.is_dir() and not target_name.endswith('/'):
|
||||||
|
target_name = f'{target_name}/'
|
||||||
|
target_info = zipfile.ZipInfo(target_name, date_time=member.date_time)
|
||||||
|
target_info.external_attr = member.external_attr
|
||||||
|
target_info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
|
||||||
|
if member.is_dir():
|
||||||
|
target_zip.writestr(target_info, b'')
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_zip.writestr(target_info, source_zip.read(member))
|
||||||
|
copied_files += 1
|
||||||
|
|
||||||
|
if copied_files == 0:
|
||||||
|
raise ValueError('GitHub skill directory is empty')
|
||||||
|
|
||||||
|
def _uploaded_skill_target_stem(self, filename: str) -> str:
|
||||||
|
stem = os.path.splitext(os.path.basename(str(filename or '').strip()))[0]
|
||||||
|
safe_stem = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '-' for ch in stem).strip('-_')
|
||||||
|
if not safe_stem:
|
||||||
|
safe_stem = 'uploaded-skill'
|
||||||
|
return safe_stem
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_github_skill_md_url(asset_url: str) -> bool:
|
||||||
|
parsed = urlparse(str(asset_url or '').strip())
|
||||||
|
normalized_path = posixpath.normpath(parsed.path or '/')
|
||||||
|
return normalized_path.lower().endswith('/skill.md')
|
||||||
|
|
||||||
|
def _parse_github_skill_md_url(self, asset_url: str, *, owner: str, repo: str) -> dict:
|
||||||
|
parsed = urlparse(str(asset_url or '').strip())
|
||||||
|
if parsed.scheme != 'https' or not parsed.netloc:
|
||||||
|
raise ValueError('asset_url must be a valid HTTPS GitHub SKILL.md URL')
|
||||||
|
|
||||||
|
host = parsed.netloc.lower()
|
||||||
|
path_parts = [unquote(part) for part in (parsed.path or '').split('/') if part]
|
||||||
|
if host == 'github.com':
|
||||||
|
if (
|
||||||
|
len(path_parts) < 5
|
||||||
|
or path_parts[0] != owner
|
||||||
|
or path_parts[1] != repo
|
||||||
|
or path_parts[2]
|
||||||
|
not in (
|
||||||
|
'blob',
|
||||||
|
'raw',
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo blob path')
|
||||||
|
ref = path_parts[3]
|
||||||
|
file_path = '/'.join(path_parts[4:])
|
||||||
|
elif host == 'raw.githubusercontent.com':
|
||||||
|
if len(path_parts) < 4 or path_parts[0] != owner or path_parts[1] != repo:
|
||||||
|
raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo raw path')
|
||||||
|
ref = path_parts[2]
|
||||||
|
file_path = '/'.join(path_parts[3:])
|
||||||
|
else:
|
||||||
|
raise ValueError('asset_url must point to a GitHub SKILL.md file')
|
||||||
|
|
||||||
|
normalized_file_path = posixpath.normpath(file_path)
|
||||||
|
normalized_file_path_lower = normalized_file_path.lower()
|
||||||
|
if normalized_file_path_lower != 'skill.md' and not normalized_file_path_lower.endswith('/skill.md'):
|
||||||
|
raise ValueError('GitHub skill import requires a URL ending with SKILL.md')
|
||||||
|
|
||||||
|
parent_dir = posixpath.basename(posixpath.dirname(normalized_file_path)) or repo
|
||||||
|
return {
|
||||||
|
'ref': ref,
|
||||||
|
'file_path': normalized_file_path,
|
||||||
|
'package_name': self._uploaded_skill_target_stem(parent_dir),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_github_skill_md_package_name(self, content: str, fallback: str) -> str:
|
||||||
|
metadata, _instructions = parse_frontmatter(content)
|
||||||
|
candidate = str(metadata.get('name') or fallback or '').strip()
|
||||||
|
try:
|
||||||
|
return self._validate_skill_name(candidate)
|
||||||
|
except ValueError:
|
||||||
|
return self._validate_skill_name(fallback)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_github_asset_url(asset_url: str, *, owner: str, repo: str, release_tag: str) -> str:
|
||||||
|
parsed = urlparse(str(asset_url).strip())
|
||||||
|
if parsed.scheme != 'https' or not parsed.netloc:
|
||||||
|
raise ValueError('asset_url must be a valid HTTPS GitHub asset URL')
|
||||||
|
|
||||||
|
host = parsed.netloc.lower()
|
||||||
|
if host not in _GITHUB_ASSET_HOSTS:
|
||||||
|
raise ValueError('asset_url must point to a GitHub-hosted release asset or archive')
|
||||||
|
|
||||||
|
normalized_path = posixpath.normpath(parsed.path or '/')
|
||||||
|
allowed_prefixes = [
|
||||||
|
f'/repos/{owner}/{repo}/',
|
||||||
|
f'/{owner}/{repo}/',
|
||||||
|
]
|
||||||
|
if not any(normalized_path.startswith(prefix) for prefix in allowed_prefixes):
|
||||||
|
raise ValueError('asset_url does not match the requested owner/repo')
|
||||||
|
|
||||||
|
if release_tag and release_tag not in parsed.path and release_tag not in parsed.query:
|
||||||
|
raise ValueError('asset_url does not match the requested release_tag')
|
||||||
|
|
||||||
|
return parsed.geturl()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_skill_name(name: str) -> str:
|
||||||
|
name = str(name or '').strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError('Skill name is required')
|
||||||
|
if not name.replace('-', '').replace('_', '').isalnum():
|
||||||
|
raise ValueError('Skill name can only contain letters, numbers, hyphens and underscores')
|
||||||
|
if len(name) > 64:
|
||||||
|
raise ValueError('Skill name cannot exceed 64 characters')
|
||||||
|
return name
|
||||||
@@ -179,7 +179,7 @@ class SpaceService:
|
|||||||
space_url = space_config['url']
|
space_url = space_config['url']
|
||||||
|
|
||||||
session = httpclient.get_session()
|
session = httpclient.get_session()
|
||||||
async with session.get(f'{space_url}/api/v1/models') as response:
|
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
raise ValueError(f'Failed to get models: {await response.text()}')
|
raise ValueError(f'Failed to get models: {await response.text()}')
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class UserService:
|
|||||||
|
|
||||||
user_obj = result_list[0]
|
user_obj = result_list[0]
|
||||||
|
|
||||||
# Check if this is a Space account
|
# Check if this user has a local password set
|
||||||
if user_obj.account_type == 'space':
|
if not user_obj.password:
|
||||||
raise ValueError('请使用 Space 账户登录')
|
raise ValueError('请使用 Space 账户登录')
|
||||||
|
|
||||||
ph = argon2.PasswordHasher()
|
ph = argon2.PasswordHasher()
|
||||||
@@ -108,9 +108,8 @@ class UserService:
|
|||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
|
|
||||||
# Space accounts cannot change password locally
|
if not user_obj.password:
|
||||||
if user_obj.account_type == 'space':
|
raise ValueError('No local password set, please set a password first')
|
||||||
raise ValueError('Space account cannot change password locally')
|
|
||||||
|
|
||||||
ph.verify(user_obj.password, current_password)
|
ph.verify(user_obj.password, current_password)
|
||||||
|
|
||||||
|
|||||||
5
src/langbot/pkg/box/__init__.py
Normal file
5
src/langbot/pkg/box/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""LangBot Box runtime package."""
|
||||||
|
|
||||||
|
from .workspace import BoxWorkspaceSession
|
||||||
|
|
||||||
|
__all__ = ['BoxWorkspaceSession']
|
||||||
354
src/langbot/pkg/box/connector.py
Normal file
354
src/langbot/pkg/box/connector.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import typing
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from langbot_plugin.entities.io.actions.enums import CommonAction
|
||||||
|
from langbot_plugin.runtime.io.handler import Handler
|
||||||
|
from langbot_plugin.runtime.io.connection import Connection
|
||||||
|
|
||||||
|
from langbot_plugin.box.client import ActionRPCBoxClient
|
||||||
|
from langbot_plugin.box.errors import BoxRuntimeUnavailableError
|
||||||
|
from langbot_plugin.box.actions import LangBotToBoxAction
|
||||||
|
|
||||||
|
from ..utils import platform
|
||||||
|
from ..utils.managed_runtime import ManagedRuntimeConnector
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..core import app as core_app
|
||||||
|
|
||||||
|
|
||||||
|
# Default Docker Compose service name for the standalone Box container.
|
||||||
|
_DOCKER_BOX_HOST = 'langbot_box'
|
||||||
|
_DEFAULT_PORT = 5410
|
||||||
|
|
||||||
|
_HEARTBEAT_INTERVAL_SEC = 20
|
||||||
|
|
||||||
|
# Top-level keys under ``box`` that are LangBot-internal and should not be
|
||||||
|
# forwarded to the Box runtime.
|
||||||
|
_INTERNAL_BOX_CONFIG_KEYS = frozenset({'runtime'})
|
||||||
|
|
||||||
|
|
||||||
|
def _get_box_config(ap) -> dict:
|
||||||
|
"""Return the 'box' section from instance config.
|
||||||
|
|
||||||
|
Environment-variable overrides are handled uniformly by
|
||||||
|
``LoadConfigStage._apply_env_overrides_to_config`` using the
|
||||||
|
``SECTION__SUBSECTION__KEY`` convention (e.g. ``BOX__LOCAL__HOST_ROOT``,
|
||||||
|
``BOX__LOCAL__ALLOWED_MOUNT_ROOTS="/a,/b"``) before this is read, so no
|
||||||
|
box-specific env parsing is needed here.
|
||||||
|
"""
|
||||||
|
instance_config = getattr(ap, 'instance_config', None)
|
||||||
|
config_data = getattr(instance_config, 'data', {}) if instance_config is not None else {}
|
||||||
|
return dict(config_data.get('box', {}) or {})
|
||||||
|
|
||||||
|
|
||||||
|
def _get_runtime_endpoint(box_cfg: dict) -> str:
|
||||||
|
runtime_cfg = box_cfg.get('runtime') or {}
|
||||||
|
return str(runtime_cfg.get('endpoint', '')).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_config_for_runtime(box_cfg: dict) -> dict:
|
||||||
|
return {k: v for k, v in box_cfg.items() if k not in _INTERNAL_BOX_CONFIG_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_box_ws_relay_url(ap: core_app.Application) -> str:
|
||||||
|
"""Derive the WS relay base URL used for managed-process attach.
|
||||||
|
|
||||||
|
The WS relay serves the ``/v1/sessions/{id}/managed-process/ws`` endpoint
|
||||||
|
on the *relay* port (default 5410).
|
||||||
|
"""
|
||||||
|
box_cfg = _get_box_config(ap)
|
||||||
|
|
||||||
|
# Explicit runtime endpoint takes precedence. The config value is a base
|
||||||
|
# URL; endpoint-specific paths are appended by the SDK client.
|
||||||
|
endpoint = _get_runtime_endpoint(box_cfg)
|
||||||
|
if endpoint:
|
||||||
|
parsed = urlparse(endpoint)
|
||||||
|
scheme = parsed.scheme or 'ws'
|
||||||
|
if scheme == 'ws':
|
||||||
|
scheme = 'http'
|
||||||
|
elif scheme == 'wss':
|
||||||
|
scheme = 'https'
|
||||||
|
host = parsed.hostname or '127.0.0.1'
|
||||||
|
port = parsed.port or _DEFAULT_PORT
|
||||||
|
return f'{scheme}://{host}:{port}'
|
||||||
|
|
||||||
|
# In Docker, relay lives on the box runtime container.
|
||||||
|
if platform.get_platform() == 'docker':
|
||||||
|
return f'http://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}'
|
||||||
|
|
||||||
|
return f'http://127.0.0.1:{_DEFAULT_PORT}'
|
||||||
|
|
||||||
|
|
||||||
|
class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||||
|
"""Connect to the Box runtime via action RPC.
|
||||||
|
|
||||||
|
Transport decision (mirrors Plugin runtime logic):
|
||||||
|
1. Docker / --standalone-box / explicit runtime.endpoint -> WebSocket to external Box process
|
||||||
|
2. Windows (non-Docker) -> subprocess + WebSocket (Windows lacks async stdio pipe)
|
||||||
|
3. Unix / macOS -> subprocess + stdio pipe
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ap: core_app.Application,
|
||||||
|
runtime_disconnect_callback: typing.Callable[
|
||||||
|
['BoxRuntimeConnector'], typing.Coroutine[typing.Any, typing.Any, None]
|
||||||
|
]
|
||||||
|
| None = None,
|
||||||
|
):
|
||||||
|
super().__init__(ap)
|
||||||
|
self.runtime_disconnect_callback = runtime_disconnect_callback
|
||||||
|
self.configured_runtime_endpoint = self._load_configured_runtime_endpoint()
|
||||||
|
self.ws_relay_base_url = resolve_box_ws_relay_url(ap)
|
||||||
|
self.client = ActionRPCBoxClient(logger=ap.logger)
|
||||||
|
|
||||||
|
self._handler: Handler | None = None
|
||||||
|
self._handler_task: asyncio.Task | None = None
|
||||||
|
self._ctrl_task: asyncio.Task | None = None
|
||||||
|
self._heartbeat_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
# Parse the relay URL once for reuse.
|
||||||
|
parsed = urlparse(self.ws_relay_base_url)
|
||||||
|
self._relay_host = parsed.hostname or '127.0.0.1'
|
||||||
|
self._relay_port = parsed.port or _DEFAULT_PORT
|
||||||
|
self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap))
|
||||||
|
|
||||||
|
def _uses_websocket(self) -> bool:
|
||||||
|
"""Whether the connector should use WebSocket to reach the Box runtime.
|
||||||
|
|
||||||
|
True when:
|
||||||
|
- Running inside Docker (Box runtime is a separate container)
|
||||||
|
- The ``--standalone-box`` CLI flag was passed
|
||||||
|
- An explicit ``runtime.endpoint`` was configured
|
||||||
|
"""
|
||||||
|
return bool(
|
||||||
|
self.configured_runtime_endpoint
|
||||||
|
or platform.get_platform() == 'docker'
|
||||||
|
or platform.use_websocket_to_connect_box_runtime()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
if self._uses_websocket():
|
||||||
|
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
|
||||||
|
await self._start_subprocess_then_ws()
|
||||||
|
else:
|
||||||
|
await self._connect_remote_ws()
|
||||||
|
else:
|
||||||
|
await self._start_local_stdio()
|
||||||
|
|
||||||
|
# Start heartbeat after successful connection
|
||||||
|
if self._heartbeat_task is None:
|
||||||
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||||
|
|
||||||
|
# -- heartbeat -----------------------------------------------------------
|
||||||
|
|
||||||
|
async def _heartbeat_loop(self) -> None:
|
||||||
|
"""Periodically ping the Box runtime to detect silent disconnections."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
|
||||||
|
try:
|
||||||
|
await self.ping()
|
||||||
|
self.ap.logger.debug('Heartbeat to Box runtime success.')
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.debug(f'Failed to heartbeat to Box runtime: {e}')
|
||||||
|
|
||||||
|
async def ping(self) -> None:
|
||||||
|
if self._handler is None:
|
||||||
|
raise BoxRuntimeUnavailableError('Box runtime is not connected')
|
||||||
|
await self._handler.call_action(CommonAction.PING, {})
|
||||||
|
|
||||||
|
# -- transport paths -----------------------------------------------------
|
||||||
|
|
||||||
|
async def _start_local_stdio(self) -> None:
|
||||||
|
"""Launch box server as subprocess and connect via stdio (Unix/macOS)."""
|
||||||
|
from langbot_plugin.runtime.io.controllers.stdio.client import StdioClientController
|
||||||
|
|
||||||
|
self.ap.logger.info('Use stdio to connect to box runtime')
|
||||||
|
python_path = sys.executable
|
||||||
|
env = os.environ.copy()
|
||||||
|
if self._filtered_box_config:
|
||||||
|
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
|
||||||
|
|
||||||
|
connected = asyncio.Event()
|
||||||
|
connect_error: list[Exception] = []
|
||||||
|
|
||||||
|
ctrl = StdioClientController(
|
||||||
|
command=python_path,
|
||||||
|
# Launched through the same CLI entry point as the plugin runtime
|
||||||
|
# (cli.__init__ <subcommand>); `-s` selects the stdio transport,
|
||||||
|
# mirroring `rt -s`.
|
||||||
|
args=['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', str(self._relay_port)],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
self._ctrl_task = asyncio.create_task(
|
||||||
|
ctrl.run(self._make_connection_callback('stdio', connected, connect_error))
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(connected.wait(), timeout=30.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise BoxRuntimeUnavailableError('box runtime subprocess did not connect in time')
|
||||||
|
|
||||||
|
if connect_error:
|
||||||
|
raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}')
|
||||||
|
|
||||||
|
self._subprocess = ctrl.process
|
||||||
|
|
||||||
|
async def _start_subprocess_then_ws(self) -> None:
|
||||||
|
"""Launch box server as detached subprocess, then connect via WS (Windows)."""
|
||||||
|
self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws')
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
if self._filtered_box_config:
|
||||||
|
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
|
||||||
|
|
||||||
|
python_path = sys.executable
|
||||||
|
# Launched through the same CLI entry point as the plugin runtime
|
||||||
|
# (cli.__init__ <subcommand>); no flag => WebSocket transport.
|
||||||
|
self.runtime_subprocess = await asyncio.create_subprocess_exec(
|
||||||
|
python_path,
|
||||||
|
'-m',
|
||||||
|
'langbot_plugin.cli.__init__',
|
||||||
|
'box',
|
||||||
|
'--ws-control-port',
|
||||||
|
str(self._relay_port),
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
self.runtime_subprocess_task = asyncio.create_task(self.runtime_subprocess.wait())
|
||||||
|
|
||||||
|
ws_url = f'ws://localhost:{self._relay_port}/rpc/ws'
|
||||||
|
await self._connect_ws(ws_url, '(windows) WebSocket')
|
||||||
|
|
||||||
|
async def _connect_remote_ws(self) -> None:
|
||||||
|
"""Connect to a remote (or Docker) box server via WebSocket."""
|
||||||
|
ws_url = self._resolve_rpc_ws_url()
|
||||||
|
self.ap.logger.info(f'Use WebSocket to connect to box runtime ({ws_url})')
|
||||||
|
await self._connect_ws(ws_url, 'WebSocket')
|
||||||
|
|
||||||
|
# -- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolve_rpc_ws_url(self) -> str:
|
||||||
|
"""Determine the action-RPC WebSocket URL.
|
||||||
|
|
||||||
|
All endpoints share a single port; action RPC is at ``/rpc/ws``.
|
||||||
|
"""
|
||||||
|
if self.configured_runtime_endpoint:
|
||||||
|
base = self.configured_runtime_endpoint.rstrip('/')
|
||||||
|
parsed = urlparse(base)
|
||||||
|
scheme = parsed.scheme or 'ws'
|
||||||
|
if scheme in ('http', 'https'):
|
||||||
|
scheme = 'wss' if scheme == 'https' else 'ws'
|
||||||
|
host = parsed.hostname or '127.0.0.1'
|
||||||
|
port = parsed.port or _DEFAULT_PORT
|
||||||
|
return f'{scheme}://{host}:{port}/rpc/ws'
|
||||||
|
|
||||||
|
if platform.get_platform() == 'docker':
|
||||||
|
return f'ws://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}/rpc/ws'
|
||||||
|
|
||||||
|
return f'ws://localhost:{self._relay_port}/rpc/ws'
|
||||||
|
|
||||||
|
async def _connect_ws(self, ws_url: str, transport_name: str) -> None:
|
||||||
|
"""Shared WebSocket connection procedure."""
|
||||||
|
from langbot_plugin.runtime.io.controllers.ws.client import WebSocketClientController
|
||||||
|
|
||||||
|
connected = asyncio.Event()
|
||||||
|
connect_error: list[Exception] = []
|
||||||
|
|
||||||
|
async def on_connect_failed(ctrl, exc):
|
||||||
|
if exc is not None:
|
||||||
|
self.ap.logger.error(f'Failed to connect to Box runtime ({ws_url}): {exc}')
|
||||||
|
else:
|
||||||
|
self.ap.logger.error(f'Failed to connect to Box runtime ({ws_url}), trying to reconnect...')
|
||||||
|
connect_error.append(exc or BoxRuntimeUnavailableError('ws connection failed'))
|
||||||
|
connected.set()
|
||||||
|
if self.runtime_disconnect_callback is not None:
|
||||||
|
await self.runtime_disconnect_callback(self)
|
||||||
|
|
||||||
|
ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed)
|
||||||
|
self._ctrl_task = asyncio.create_task(
|
||||||
|
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error))
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(connected.wait(), timeout=30.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise BoxRuntimeUnavailableError(f'box runtime ws connection timed out ({ws_url})')
|
||||||
|
|
||||||
|
if connect_error:
|
||||||
|
raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}')
|
||||||
|
|
||||||
|
def _make_connection_callback(
|
||||||
|
self,
|
||||||
|
transport_name: str,
|
||||||
|
connected: asyncio.Event,
|
||||||
|
connect_error: list[Exception],
|
||||||
|
):
|
||||||
|
async def new_connection_callback(connection: Connection) -> None:
|
||||||
|
handler = Handler(connection)
|
||||||
|
self._handler = handler
|
||||||
|
self.client.set_handler(handler)
|
||||||
|
self._handler_task = asyncio.create_task(handler.run())
|
||||||
|
try:
|
||||||
|
await handler.call_action(CommonAction.PING, {})
|
||||||
|
if self._filtered_box_config:
|
||||||
|
await handler.call_action(LangBotToBoxAction.INIT, self._filtered_box_config)
|
||||||
|
self.ap.logger.debug('Sent box configuration to Box runtime via INIT.')
|
||||||
|
self.ap.logger.info(f'Connected to Box runtime via {transport_name}.')
|
||||||
|
connected.set()
|
||||||
|
await self._handler_task
|
||||||
|
except Exception as exc:
|
||||||
|
if not connected.is_set():
|
||||||
|
connect_error.append(exc)
|
||||||
|
connected.set()
|
||||||
|
return
|
||||||
|
|
||||||
|
# If we reach here, handler.run() returned normally (connection
|
||||||
|
# closed) or raised after the initial handshake succeeded.
|
||||||
|
# Either way, treat it as a disconnect.
|
||||||
|
if connected.is_set():
|
||||||
|
if self._uses_websocket():
|
||||||
|
self.ap.logger.error('Disconnected from Box runtime, trying to reconnect...')
|
||||||
|
if self.runtime_disconnect_callback is not None:
|
||||||
|
await self.runtime_disconnect_callback(self)
|
||||||
|
else:
|
||||||
|
self.ap.logger.error(
|
||||||
|
'Disconnected from Box runtime via stdio. '
|
||||||
|
'Cannot automatically reconnect — please restart LangBot.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_connection_callback
|
||||||
|
|
||||||
|
# -- lifecycle -----------------------------------------------------------
|
||||||
|
|
||||||
|
def dispose(self) -> None:
|
||||||
|
if self._heartbeat_task is not None:
|
||||||
|
self._heartbeat_task.cancel()
|
||||||
|
self._heartbeat_task = None
|
||||||
|
|
||||||
|
if self._handler_task is not None:
|
||||||
|
self._handler_task.cancel()
|
||||||
|
self._handler_task = None
|
||||||
|
|
||||||
|
if self._ctrl_task is not None:
|
||||||
|
self._ctrl_task.cancel()
|
||||||
|
self._ctrl_task = None
|
||||||
|
|
||||||
|
# stdio-managed subprocess (stored as self._subprocess by _start_local_stdio)
|
||||||
|
if hasattr(self, '_subprocess') and self._subprocess is not None and self._subprocess.returncode is None:
|
||||||
|
self.ap.logger.info('Terminating managed box runtime process...')
|
||||||
|
self._subprocess.terminate()
|
||||||
|
|
||||||
|
# Subprocess launched by ManagedRuntimeConnector._start_runtime_subprocess (Windows path)
|
||||||
|
self._dispose_subprocess()
|
||||||
|
|
||||||
|
# -- config helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_configured_runtime_endpoint(self) -> str:
|
||||||
|
return _get_runtime_endpoint(_get_box_config(self.ap))
|
||||||
98
src/langbot/pkg/box/policy.py
Normal file
98
src/langbot/pkg/box/policy.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Three-layer security policy for LangBot Box.
|
||||||
|
|
||||||
|
The design separates concerns into three independent layers, aligned with
|
||||||
|
OpenCode / OpenClaw patterns:
|
||||||
|
|
||||||
|
1. **SandboxPolicy** – *where* tools run (host vs sandbox).
|
||||||
|
2. **ToolPolicy** – *which* tools are allowed (allow/deny lists).
|
||||||
|
3. **ElevatedPolicy** – *whether* a single exec call may temporarily
|
||||||
|
escape the default sandbox boundary.
|
||||||
|
|
||||||
|
These three layers are orthogonal:
|
||||||
|
- ToolPolicy is a hard boundary; ``elevated`` cannot bypass a denied tool.
|
||||||
|
- SandboxPolicy decides the default execution location.
|
||||||
|
- ElevatedPolicy only affects ``exec`` and only when the framework allows it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
# ── Layer 1: Sandbox Policy ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxMode(str, enum.Enum):
|
||||||
|
"""Determines when agent execution is routed through the sandbox."""
|
||||||
|
|
||||||
|
OFF = 'off'
|
||||||
|
"""Sandbox disabled; all exec runs on the host."""
|
||||||
|
|
||||||
|
NON_DEFAULT = 'non_default'
|
||||||
|
"""Only non-default sessions are sandboxed (e.g. sub-agents, MCP)."""
|
||||||
|
|
||||||
|
ALL = 'all'
|
||||||
|
"""Every agent exec call is routed through the sandbox."""
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxPolicy:
|
||||||
|
"""Decides whether a given execution context should use the sandbox."""
|
||||||
|
|
||||||
|
def __init__(self, mode: SandboxMode = SandboxMode.ALL):
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
def should_sandbox(self, *, is_default_session: bool = True) -> bool:
|
||||||
|
if self.mode == SandboxMode.OFF:
|
||||||
|
return False
|
||||||
|
if self.mode == SandboxMode.ALL:
|
||||||
|
return True
|
||||||
|
# NON_DEFAULT: sandbox everything except the default session
|
||||||
|
return not is_default_session
|
||||||
|
|
||||||
|
|
||||||
|
# ── Layer 2: Tool Policy ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ToolPolicy:
|
||||||
|
"""Controls which tools are available to the current agent/session.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- ``deny`` always takes precedence over ``allow``.
|
||||||
|
- An empty ``allow`` list means "all tools allowed" (no allowlist filter).
|
||||||
|
- ``elevated`` cannot bypass a denied tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
allow: Sequence[str] = (),
|
||||||
|
deny: Sequence[str] = (),
|
||||||
|
):
|
||||||
|
self._allow: frozenset[str] = frozenset(allow)
|
||||||
|
self._deny: frozenset[str] = frozenset(deny)
|
||||||
|
|
||||||
|
def is_tool_allowed(self, tool_name: str) -> bool:
|
||||||
|
if tool_name in self._deny:
|
||||||
|
return False
|
||||||
|
if self._allow and tool_name not in self._allow:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Layer 3: Elevated Policy ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ElevatedPolicy:
|
||||||
|
"""Controls whether ``exec`` may request temporary privilege escalation.
|
||||||
|
|
||||||
|
``elevated`` only applies to the ``exec`` tool. It means "run this
|
||||||
|
command outside the default sandbox boundary" (e.g. with network, or
|
||||||
|
on the host). The framework decides whether to honor the request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, allow_elevated: bool = False, require_approval: bool = True):
|
||||||
|
self.allow_elevated = allow_elevated
|
||||||
|
self.require_approval = require_approval
|
||||||
|
|
||||||
|
def is_elevation_permitted(self) -> bool:
|
||||||
|
return self.allow_elevated
|
||||||
797
src/langbot/pkg/box/service.py
Normal file
797
src/langbot/pkg/box/service.py
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import collections
|
||||||
|
import datetime as _dt
|
||||||
|
import enum
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pydantic
|
||||||
|
|
||||||
|
from langbot_plugin.box.client import BoxRuntimeClient
|
||||||
|
from .connector import BoxRuntimeConnector, _get_box_config
|
||||||
|
from langbot_plugin.box.errors import BoxError, BoxValidationError
|
||||||
|
from langbot_plugin.box.models import (
|
||||||
|
BUILTIN_PROFILES,
|
||||||
|
BoxExecutionResult,
|
||||||
|
BoxManagedProcessInfo,
|
||||||
|
BoxManagedProcessSpec,
|
||||||
|
BoxProfile,
|
||||||
|
BoxSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
_INT_ADAPTER = pydantic.TypeAdapter(int)
|
||||||
|
_UTC = _dt.timezone.utc
|
||||||
|
_MAX_RECENT_ERRORS = 50
|
||||||
|
_MIB = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _is_path_under(path: str, root: str) -> bool:
|
||||||
|
"""Check whether *path* equals *root* or is a child of *root*."""
|
||||||
|
return path == root or path.startswith(f'{root}{os.sep}')
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..core import app as core_app
|
||||||
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
|
|
||||||
|
|
||||||
|
class BoxService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ap: core_app.Application,
|
||||||
|
client: BoxRuntimeClient | None = None,
|
||||||
|
output_limit_chars: int = 4000,
|
||||||
|
):
|
||||||
|
self.ap = ap
|
||||||
|
self._enabled = self._load_enabled()
|
||||||
|
self._runtime_connector: BoxRuntimeConnector | None = None
|
||||||
|
if client is None:
|
||||||
|
# Always construct a connector — its __init__ is side-effect free
|
||||||
|
# (no I/O, no subprocess). When ``box.enabled = false`` we simply
|
||||||
|
# skip ``connector.initialize()`` so no connection is attempted.
|
||||||
|
self._runtime_connector = BoxRuntimeConnector(ap, runtime_disconnect_callback=self._on_runtime_disconnect)
|
||||||
|
client = self._runtime_connector.client
|
||||||
|
self.client = client
|
||||||
|
self.output_limit_chars = output_limit_chars
|
||||||
|
self.host_root = self._load_host_root()
|
||||||
|
self.allowed_mount_roots = self._load_allowed_mount_roots()
|
||||||
|
self.default_workspace = self._load_default_workspace()
|
||||||
|
self.profile = self._load_profile()
|
||||||
|
self.custom_image = self._load_custom_image()
|
||||||
|
self.workspace_quota_mb = self._load_workspace_quota_mb()
|
||||||
|
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
|
||||||
|
self._shutdown_task = None
|
||||||
|
self._available = False
|
||||||
|
self._connector_error: str = ''
|
||||||
|
self._reconnecting = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
"""Whether Box is enabled in config. False means the operator has
|
||||||
|
deliberately turned the sandbox off via ``box.enabled = false``.
|
||||||
|
Disabled and "enabled but unavailable" are reported as the same
|
||||||
|
``available = False`` to consumers, but distinguished in get_status."""
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
self._ensure_default_workspace()
|
||||||
|
if not self._enabled:
|
||||||
|
# Disabled by config: do NOT connect to a remote runtime, do NOT
|
||||||
|
# fork a stdio subprocess. Every consumer of box_service should
|
||||||
|
# gate on ``available`` and degrade gracefully.
|
||||||
|
self._available = False
|
||||||
|
self._connector_error = 'Box runtime is disabled in config (box.enabled = false)'
|
||||||
|
self.ap.logger.info(
|
||||||
|
'Box runtime disabled by config; sandbox features (exec/read/write/edit, '
|
||||||
|
'skill add/edit, stdio MCP) will be unavailable.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self._runtime_connector is not None:
|
||||||
|
await self._runtime_connector.initialize()
|
||||||
|
else:
|
||||||
|
await self.client.initialize()
|
||||||
|
self._available = True
|
||||||
|
self._connector_error = ''
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'LangBot Box runtime initialized: profile={self.profile.name} '
|
||||||
|
f'default_workspace={self.default_workspace or "(none)"}'
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
|
||||||
|
self._available = False
|
||||||
|
self._connector_error = str(exc)
|
||||||
|
|
||||||
|
async def _on_runtime_disconnect(self, connector: BoxRuntimeConnector) -> None:
|
||||||
|
"""Called by the connector when the Box runtime connection drops.
|
||||||
|
|
||||||
|
Spawns a background reconnection loop so the caller is not blocked.
|
||||||
|
Skipped entirely when Box is disabled by config — that path should
|
||||||
|
never have connected in the first place.
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
if self._reconnecting:
|
||||||
|
return # Another reconnect loop is already running
|
||||||
|
self._reconnecting = True
|
||||||
|
self._available = False
|
||||||
|
self._connector_error = 'Disconnected from Box runtime'
|
||||||
|
self.ap.logger.warning('Box runtime disconnected, sandbox features temporarily disabled.')
|
||||||
|
asyncio.create_task(self._reconnect_loop(connector))
|
||||||
|
|
||||||
|
async def _reconnect_loop(self, connector: BoxRuntimeConnector) -> None:
|
||||||
|
"""Retry reconnection with exponential backoff (3s → 60s max)."""
|
||||||
|
delay = 3
|
||||||
|
max_delay = 60
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
self.ap.logger.info(f'Attempting to reconnect to Box runtime in {delay}s...')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
try:
|
||||||
|
connector.dispose()
|
||||||
|
await connector.initialize()
|
||||||
|
self._available = True
|
||||||
|
self._connector_error = ''
|
||||||
|
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self._connector_error = str(exc)
|
||||||
|
self.ap.logger.warning(f'Box runtime reconnection failed: {exc}')
|
||||||
|
delay = min(delay * 2, max_delay)
|
||||||
|
finally:
|
||||||
|
self._reconnecting = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
async def execute_spec_payload(
|
||||||
|
self,
|
||||||
|
spec_payload: dict,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
*,
|
||||||
|
skip_host_mount_validation: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
if not self._available:
|
||||||
|
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
||||||
|
try:
|
||||||
|
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
|
||||||
|
except BoxError as exc:
|
||||||
|
self._record_error(exc, query)
|
||||||
|
raise
|
||||||
|
self.ap.logger.info(
|
||||||
|
'LangBot Box request: '
|
||||||
|
f'query_id={query.query_id} '
|
||||||
|
f'spec={json.dumps(self._summarize_spec(spec), ensure_ascii=False)}'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._enforce_workspace_quota(spec, phase='before execution')
|
||||||
|
except BoxError as exc:
|
||||||
|
self._record_error(exc, query)
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
result = await self.client.execute(spec)
|
||||||
|
except BoxError as exc:
|
||||||
|
self._record_error(exc, query)
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
await self._enforce_workspace_quota(spec, phase='after execution')
|
||||||
|
except BoxError as exc:
|
||||||
|
await self._cleanup_exceeded_session(spec)
|
||||||
|
self._record_error(exc, query)
|
||||||
|
raise
|
||||||
|
self.ap.logger.info(
|
||||||
|
'LangBot Box result: '
|
||||||
|
f'query_id={query.query_id} '
|
||||||
|
f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}'
|
||||||
|
)
|
||||||
|
return self._serialize_result(result)
|
||||||
|
|
||||||
|
def resolve_box_session_id(self, query: pipeline_query.Query) -> str:
|
||||||
|
"""Resolve the Box session_id from the pipeline's template and query variables."""
|
||||||
|
template = (
|
||||||
|
(query.pipeline_config or {})
|
||||||
|
.get('ai', {})
|
||||||
|
.get('local-agent', {})
|
||||||
|
.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
||||||
|
)
|
||||||
|
variables = dict(query.variables or {})
|
||||||
|
launcher_type = getattr(query, 'launcher_type', None)
|
||||||
|
if hasattr(launcher_type, 'value'):
|
||||||
|
launcher_type = launcher_type.value
|
||||||
|
launcher_id = getattr(query, 'launcher_id', None)
|
||||||
|
sender_id = getattr(query, 'sender_id', None)
|
||||||
|
query_id = getattr(query, 'query_id', None)
|
||||||
|
|
||||||
|
variables.setdefault('query_id', str(query_id or 'unknown'))
|
||||||
|
variables.setdefault('launcher_type', str(launcher_type or 'query'))
|
||||||
|
variables.setdefault('launcher_id', str(launcher_id or query_id or 'unknown'))
|
||||||
|
variables.setdefault('sender_id', str(sender_id or launcher_id or query_id or 'unknown'))
|
||||||
|
variables.setdefault('global', 'global')
|
||||||
|
return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
|
||||||
|
|
||||||
|
def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]:
|
||||||
|
"""Build extra_mounts entries for all pipeline-bound skills.
|
||||||
|
|
||||||
|
This ensures that when a container is first created it already has
|
||||||
|
all skill packages mounted, regardless of which skill is currently
|
||||||
|
activated.
|
||||||
|
|
||||||
|
Skills whose ``package_root`` is missing or no longer a directory on
|
||||||
|
the LangBot-visible filesystem are skipped with a warning instead of
|
||||||
|
being passed through to the backend. Without this guard the three
|
||||||
|
backends behave inconsistently on a stale mount: nsjail refuses to
|
||||||
|
start the sandbox (failing every exec in the session), Docker
|
||||||
|
silently auto-creates a root-owned empty directory on the host, and
|
||||||
|
E2B silently skips the upload — none of which surfaces an
|
||||||
|
actionable error to the agent or operator.
|
||||||
|
"""
|
||||||
|
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||||
|
if skill_mgr is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
from ..provider.tools.loaders import skill as skill_loader
|
||||||
|
|
||||||
|
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||||
|
mounts: list[dict] = []
|
||||||
|
for skill_name, skill_data in visible_skills.items():
|
||||||
|
package_root = str(skill_data.get('package_root', '') or '').strip()
|
||||||
|
if not package_root:
|
||||||
|
continue
|
||||||
|
if not os.path.isdir(package_root):
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Skill "{skill_name}" package_root missing on filesystem '
|
||||||
|
f'({package_root}); skipping mount to prevent sandbox failures. '
|
||||||
|
f'The skill cache may be stale — consider reloading skills.'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
mounts.append(
|
||||||
|
{
|
||||||
|
'host_path': package_root,
|
||||||
|
'mount_path': f'/workspace/.skills/{skill_name}',
|
||||||
|
'mode': 'rw',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return mounts
|
||||||
|
|
||||||
|
async def execute_tool(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||||
|
"""Execute an agent-facing ``exec`` tool call.
|
||||||
|
|
||||||
|
Translates the agent-facing ``command`` field to the internal
|
||||||
|
``BoxSpec.cmd`` field and injects the session id from the query.
|
||||||
|
"""
|
||||||
|
spec_payload: dict = {'cmd': parameters['command']}
|
||||||
|
|
||||||
|
# Pass through allowed agent-facing fields
|
||||||
|
for key in ('workdir', 'timeout_sec', 'env'):
|
||||||
|
if key in parameters:
|
||||||
|
spec_payload[key] = parameters[key]
|
||||||
|
|
||||||
|
# Inject context the agent must not control
|
||||||
|
spec_payload.setdefault('session_id', self.resolve_box_session_id(query))
|
||||||
|
|
||||||
|
# Mount all pipeline-bound skills so they are available in the container
|
||||||
|
if 'extra_mounts' not in spec_payload:
|
||||||
|
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
||||||
|
|
||||||
|
return await self.execute_spec_payload(spec_payload, query)
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
await self.client.shutdown()
|
||||||
|
|
||||||
|
def dispose(self):
|
||||||
|
if self._runtime_connector is not None:
|
||||||
|
self._runtime_connector.dispose()
|
||||||
|
loop = getattr(self.ap, 'event_loop', None)
|
||||||
|
if loop is not None and not loop.is_closed() and (self._shutdown_task is None or self._shutdown_task.done()):
|
||||||
|
self._shutdown_task = loop.create_task(self.shutdown())
|
||||||
|
|
||||||
|
async def get_sessions(self) -> list[dict]:
|
||||||
|
if not self._available:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return await self.client.get_sessions()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec:
|
||||||
|
spec_payload = dict(spec_payload)
|
||||||
|
spec_payload.setdefault('env', {})
|
||||||
|
if spec_payload.get('host_path') in (None, '') and self.default_workspace is not None:
|
||||||
|
spec_payload['host_path'] = self.default_workspace
|
||||||
|
if spec_payload.get('workspace_quota_mb') in (None, '') and self.workspace_quota_mb is not None:
|
||||||
|
spec_payload['workspace_quota_mb'] = self.workspace_quota_mb
|
||||||
|
|
||||||
|
# Global custom image overrides profile default (but not caller-specified image)
|
||||||
|
if self.custom_image and 'image' not in spec_payload:
|
||||||
|
spec_payload['image'] = self.custom_image
|
||||||
|
|
||||||
|
self._apply_profile(spec_payload)
|
||||||
|
|
||||||
|
try:
|
||||||
|
spec = BoxSpec.model_validate(spec_payload)
|
||||||
|
except pydantic.ValidationError as exc:
|
||||||
|
first_error = exc.errors()[0]
|
||||||
|
raise BoxValidationError(first_error.get('msg', 'invalid box arguments')) from exc
|
||||||
|
|
||||||
|
if not skip_host_mount_validation:
|
||||||
|
self._validate_host_mount(spec)
|
||||||
|
return spec
|
||||||
|
|
||||||
|
async def create_session(self, spec_payload: dict, *, skip_host_mount_validation: bool = False) -> dict:
|
||||||
|
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
|
||||||
|
return await self.client.create_session(spec)
|
||||||
|
|
||||||
|
async def start_managed_process(self, session_id: str, process_payload: dict) -> BoxManagedProcessInfo:
|
||||||
|
process_spec = BoxManagedProcessSpec.model_validate(process_payload)
|
||||||
|
return await self.client.start_managed_process(session_id, process_spec)
|
||||||
|
|
||||||
|
async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo:
|
||||||
|
return await self.client.get_managed_process(session_id, process_id)
|
||||||
|
|
||||||
|
async def stop_managed_process(self, session_id: str, process_id: str = 'default') -> None:
|
||||||
|
return await self.client.stop_managed_process(session_id, process_id)
|
||||||
|
|
||||||
|
def get_managed_process_websocket_url(self, session_id: str, process_id: str = 'default') -> str:
|
||||||
|
getter = getattr(self.client, 'get_managed_process_websocket_url', None)
|
||||||
|
if getter is None:
|
||||||
|
raise BoxValidationError('box runtime client does not support managed process websocket attach')
|
||||||
|
ws_relay_base_url = (
|
||||||
|
self._runtime_connector.ws_relay_base_url
|
||||||
|
if self._runtime_connector is not None
|
||||||
|
else 'http://127.0.0.1:5410'
|
||||||
|
)
|
||||||
|
return getter(session_id, ws_relay_base_url, process_id)
|
||||||
|
|
||||||
|
async def list_skills(self) -> list[dict]:
|
||||||
|
return await self.client.list_skills()
|
||||||
|
|
||||||
|
async def get_skill(self, name: str) -> dict | None:
|
||||||
|
return await self.client.get_skill(name)
|
||||||
|
|
||||||
|
async def create_skill(self, skill: dict) -> dict:
|
||||||
|
return await self.client.create_skill(skill)
|
||||||
|
|
||||||
|
async def update_skill(self, name: str, skill: dict) -> dict:
|
||||||
|
return await self.client.update_skill(name, skill)
|
||||||
|
|
||||||
|
async def delete_skill(self, name: str) -> None:
|
||||||
|
await self.client.delete_skill(name)
|
||||||
|
|
||||||
|
async def scan_skill_directory(self, path: str) -> dict:
|
||||||
|
return await self.client.scan_skill_directory(path)
|
||||||
|
|
||||||
|
async def list_skill_files(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
path: str = '.',
|
||||||
|
include_hidden: bool = False,
|
||||||
|
max_entries: int = 200,
|
||||||
|
) -> dict:
|
||||||
|
return await self.client.list_skill_files(name, path, include_hidden, max_entries)
|
||||||
|
|
||||||
|
async def read_skill_file(self, name: str, path: str) -> dict:
|
||||||
|
return await self.client.read_skill_file(name, path)
|
||||||
|
|
||||||
|
async def write_skill_file(self, name: str, path: str, content: str) -> dict:
|
||||||
|
return await self.client.write_skill_file(name, path, content)
|
||||||
|
|
||||||
|
async def preview_skill_zip(
|
||||||
|
self,
|
||||||
|
file_bytes: bytes,
|
||||||
|
filename: str,
|
||||||
|
source_subdir: str = '',
|
||||||
|
target_suffix: str = 'upload',
|
||||||
|
) -> list[dict]:
|
||||||
|
return await self.client.preview_skill_zip(file_bytes, filename, source_subdir, target_suffix)
|
||||||
|
|
||||||
|
async def install_skill_zip(
|
||||||
|
self,
|
||||||
|
file_bytes: bytes,
|
||||||
|
filename: str,
|
||||||
|
source_paths: list[str] | None = None,
|
||||||
|
source_path: str = '',
|
||||||
|
source_subdir: str = '',
|
||||||
|
target_suffix: str = 'upload',
|
||||||
|
) -> list[dict]:
|
||||||
|
return await self.client.install_skill_zip(
|
||||||
|
file_bytes,
|
||||||
|
filename,
|
||||||
|
source_paths,
|
||||||
|
source_path,
|
||||||
|
source_subdir,
|
||||||
|
target_suffix,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _serialize_result(self, result: BoxExecutionResult) -> dict:
|
||||||
|
stdout, stdout_truncated = self._truncate(result.stdout)
|
||||||
|
stderr, stderr_truncated = self._truncate(result.stderr)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'session_id': result.session_id,
|
||||||
|
'backend': result.backend_name,
|
||||||
|
'status': result.status.value,
|
||||||
|
'ok': result.ok,
|
||||||
|
'exit_code': result.exit_code,
|
||||||
|
'stdout': stdout,
|
||||||
|
'stderr': stderr,
|
||||||
|
'stdout_truncated': stdout_truncated,
|
||||||
|
'stderr_truncated': stderr_truncated,
|
||||||
|
'duration_ms': result.duration_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _truncate(self, text: str) -> tuple[str, bool]:
|
||||||
|
if len(text) <= self.output_limit_chars:
|
||||||
|
return text, False
|
||||||
|
if self.output_limit_chars <= 0:
|
||||||
|
return '', True
|
||||||
|
|
||||||
|
head_size = 0
|
||||||
|
tail_size = 0
|
||||||
|
notice = ''
|
||||||
|
# Recompute once the omitted count is known so the final payload
|
||||||
|
# stays within output_limit_chars even after adding the notice.
|
||||||
|
for _ in range(4):
|
||||||
|
omitted = max(len(text) - head_size - tail_size, 0)
|
||||||
|
notice = f'\n\n... [{omitted} characters truncated] ...\n\n'
|
||||||
|
available = self.output_limit_chars - len(notice)
|
||||||
|
if available <= 0:
|
||||||
|
return notice[: self.output_limit_chars], True
|
||||||
|
|
||||||
|
new_head_size = int(available * 0.6)
|
||||||
|
new_tail_size = available - new_head_size
|
||||||
|
if new_head_size == head_size and new_tail_size == tail_size:
|
||||||
|
break
|
||||||
|
head_size = new_head_size
|
||||||
|
tail_size = new_tail_size
|
||||||
|
|
||||||
|
head = text[:head_size]
|
||||||
|
tail = text[-tail_size:] if tail_size else ''
|
||||||
|
truncated = f'{head}{notice}{tail}'
|
||||||
|
return truncated[: self.output_limit_chars], True
|
||||||
|
|
||||||
|
def _summarize_spec(self, spec: BoxSpec) -> dict:
|
||||||
|
cmd = spec.cmd.strip()
|
||||||
|
if len(cmd) > 400:
|
||||||
|
cmd = f'{cmd[:397]}...'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'session_id': spec.session_id,
|
||||||
|
'workdir': spec.workdir,
|
||||||
|
'mount_path': spec.mount_path,
|
||||||
|
'timeout_sec': spec.timeout_sec,
|
||||||
|
'network': spec.network.value,
|
||||||
|
'image': spec.image,
|
||||||
|
'host_path': spec.host_path,
|
||||||
|
'host_path_mode': spec.host_path_mode.value,
|
||||||
|
'cpus': spec.cpus,
|
||||||
|
'memory_mb': spec.memory_mb,
|
||||||
|
'pids_limit': spec.pids_limit,
|
||||||
|
'read_only_rootfs': spec.read_only_rootfs,
|
||||||
|
'workspace_quota_mb': spec.workspace_quota_mb,
|
||||||
|
'env_keys': sorted(spec.env.keys()),
|
||||||
|
'cmd': cmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _summarize_result(self, result: BoxExecutionResult) -> dict:
|
||||||
|
stdout_preview = result.stdout[:200]
|
||||||
|
stderr_preview = result.stderr[:200]
|
||||||
|
if len(result.stdout) > 200:
|
||||||
|
stdout_preview = f'{stdout_preview}...'
|
||||||
|
if len(result.stderr) > 200:
|
||||||
|
stderr_preview = f'{stderr_preview}...'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'session_id': result.session_id,
|
||||||
|
'backend': result.backend_name,
|
||||||
|
'status': result.status.value,
|
||||||
|
'exit_code': result.exit_code,
|
||||||
|
'duration_ms': result.duration_ms,
|
||||||
|
'stdout_preview': stdout_preview,
|
||||||
|
'stderr_preview': stderr_preview,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _local_config(self) -> dict:
|
||||||
|
"""Return ``box.local`` from instance config.
|
||||||
|
|
||||||
|
Environment overrides are applied uniformly by
|
||||||
|
``LoadConfigStage._apply_env_overrides_to_config`` (e.g.
|
||||||
|
``BOX__LOCAL__HOST_ROOT``) before this is read, so no box-specific
|
||||||
|
env parsing happens here.
|
||||||
|
"""
|
||||||
|
return dict(_get_box_config(self.ap).get('local') or {})
|
||||||
|
|
||||||
|
def _load_allowed_mount_roots(self) -> list[str]:
|
||||||
|
configured_roots = self._local_config().get('allowed_mount_roots', [])
|
||||||
|
# The unified env-override mechanism stores a brand-new key as a raw
|
||||||
|
# string when the key is absent from config.yaml. Accept a
|
||||||
|
# comma-separated string as well as a list so that
|
||||||
|
# ``BOX__LOCAL__ALLOWED_MOUNT_ROOTS="/a,/b"`` keeps working even when
|
||||||
|
# the config file has no ``box.local.allowed_mount_roots`` entry.
|
||||||
|
if isinstance(configured_roots, str):
|
||||||
|
configured_roots = [item.strip() for item in configured_roots.split(',') if item.strip()]
|
||||||
|
|
||||||
|
normalized_roots: list[str] = []
|
||||||
|
for root in configured_roots:
|
||||||
|
root_value = str(root).strip()
|
||||||
|
if not root_value:
|
||||||
|
continue
|
||||||
|
normalized_roots.append(os.path.realpath(os.path.abspath(root_value)))
|
||||||
|
|
||||||
|
if not normalized_roots and self.host_root is not None:
|
||||||
|
normalized_roots.append(self.host_root)
|
||||||
|
|
||||||
|
return normalized_roots
|
||||||
|
|
||||||
|
def _load_host_root(self) -> str | None:
|
||||||
|
host_root = str(self._local_config().get('host_root', '')).strip()
|
||||||
|
if not host_root:
|
||||||
|
return None
|
||||||
|
return os.path.realpath(os.path.abspath(host_root))
|
||||||
|
|
||||||
|
def _load_default_workspace(self) -> str | None:
|
||||||
|
default_workspace = str(self._local_config().get('default_workspace', '')).strip()
|
||||||
|
if not default_workspace:
|
||||||
|
if self.host_root is None:
|
||||||
|
return None
|
||||||
|
default_workspace = os.path.join(self.host_root, 'default')
|
||||||
|
elif not os.path.isabs(default_workspace) and self.host_root is not None:
|
||||||
|
default_workspace = os.path.join(self.host_root, default_workspace)
|
||||||
|
return os.path.realpath(os.path.abspath(default_workspace))
|
||||||
|
|
||||||
|
def get_skills_root(self) -> str | None:
|
||||||
|
skills_root = str(self._local_config().get('skills_root', '') or 'skills').strip()
|
||||||
|
if not skills_root:
|
||||||
|
skills_root = 'skills'
|
||||||
|
if not os.path.isabs(skills_root) and self.host_root is not None:
|
||||||
|
skills_root = os.path.join(self.host_root, skills_root)
|
||||||
|
return os.path.realpath(os.path.abspath(skills_root))
|
||||||
|
|
||||||
|
def _load_enabled(self) -> bool:
|
||||||
|
"""Read ``box.enabled`` (top-level, not ``box.local.*``). Default True
|
||||||
|
— disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings,
|
||||||
|
and the standard env-overridden truthy values that
|
||||||
|
``LoadConfigStage._apply_env_overrides_to_config`` produces."""
|
||||||
|
raw = _get_box_config(self.ap).get('enabled', True)
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return raw
|
||||||
|
return str(raw).strip().lower() not in ('false', '0', 'no', 'off', '')
|
||||||
|
|
||||||
|
def _load_custom_image(self) -> str | None:
|
||||||
|
raw = str(self._local_config().get('image', '') or '').strip()
|
||||||
|
return raw or None
|
||||||
|
|
||||||
|
def _load_workspace_quota_mb(self) -> int | None:
|
||||||
|
raw_value = self._local_config().get('workspace_quota_mb')
|
||||||
|
if raw_value in (None, ''):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = _INT_ADAPTER.validate_python(raw_value)
|
||||||
|
except pydantic.ValidationError as exc:
|
||||||
|
raise BoxValidationError('workspace_quota_mb must be an integer greater than or equal to 0') from exc
|
||||||
|
if value < 0:
|
||||||
|
raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0')
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _ensure_default_workspace(self):
|
||||||
|
if self.default_workspace is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.isdir(self.default_workspace):
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.exists(self.default_workspace):
|
||||||
|
raise BoxValidationError('box.local.default_workspace must point to a directory on the host')
|
||||||
|
|
||||||
|
if not self.allowed_mount_roots:
|
||||||
|
raise BoxValidationError(
|
||||||
|
'box.local.default_workspace cannot be created because no allowed_mount_roots are configured'
|
||||||
|
)
|
||||||
|
|
||||||
|
for allowed_root in self.allowed_mount_roots:
|
||||||
|
if _is_path_under(self.default_workspace, allowed_root):
|
||||||
|
os.makedirs(self.default_workspace, exist_ok=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
allowed_roots = ', '.join(self.allowed_mount_roots)
|
||||||
|
raise BoxValidationError(f'box.local.default_workspace is outside allowed_mount_roots: {allowed_roots}')
|
||||||
|
|
||||||
|
def _validate_host_mount(self, spec: BoxSpec):
|
||||||
|
if spec.host_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
host_path = os.path.realpath(spec.host_path)
|
||||||
|
if not os.path.isdir(host_path):
|
||||||
|
raise BoxValidationError('host_path must point to an existing directory on the host')
|
||||||
|
|
||||||
|
if not self.allowed_mount_roots:
|
||||||
|
raise BoxValidationError('host_path mounting is disabled because no allowed_mount_roots are configured')
|
||||||
|
|
||||||
|
for allowed_root in self.allowed_mount_roots:
|
||||||
|
if _is_path_under(host_path, allowed_root):
|
||||||
|
return
|
||||||
|
|
||||||
|
allowed_roots = ', '.join(self.allowed_mount_roots)
|
||||||
|
raise BoxValidationError(f'host_path is outside allowed_mount_roots: {allowed_roots}')
|
||||||
|
|
||||||
|
def _load_profile(self) -> BoxProfile:
|
||||||
|
profile_name = str(self._local_config().get('profile', 'default')).strip() or 'default'
|
||||||
|
|
||||||
|
profile = BUILTIN_PROFILES.get(profile_name)
|
||||||
|
if profile is None:
|
||||||
|
available = ', '.join(sorted(BUILTIN_PROFILES))
|
||||||
|
raise BoxValidationError(f"unknown box profile '{profile_name}', available profiles: {available}")
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def _apply_profile(self, params: dict):
|
||||||
|
"""Merge profile defaults into *params* in-place, enforce locked fields and clamp timeout."""
|
||||||
|
profile = self.profile
|
||||||
|
_PROFILE_FIELDS = (
|
||||||
|
'image',
|
||||||
|
'network',
|
||||||
|
'timeout_sec',
|
||||||
|
'host_path_mode',
|
||||||
|
'cpus',
|
||||||
|
'memory_mb',
|
||||||
|
'pids_limit',
|
||||||
|
'read_only_rootfs',
|
||||||
|
'workspace_quota_mb',
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in _PROFILE_FIELDS:
|
||||||
|
profile_value = getattr(profile, field)
|
||||||
|
raw_value = profile_value.value if isinstance(profile_value, enum.Enum) else profile_value
|
||||||
|
|
||||||
|
if field in profile.locked:
|
||||||
|
params[field] = raw_value
|
||||||
|
elif field not in params:
|
||||||
|
params[field] = raw_value
|
||||||
|
|
||||||
|
timeout = params.get('timeout_sec')
|
||||||
|
try:
|
||||||
|
normalized_timeout = _INT_ADAPTER.validate_python(timeout)
|
||||||
|
except pydantic.ValidationError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if normalized_timeout > profile.max_timeout_sec:
|
||||||
|
params['timeout_sec'] = profile.max_timeout_sec
|
||||||
|
|
||||||
|
def _get_workspace_size_bytes(self, root: str) -> int:
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
def _walk(path: str):
|
||||||
|
nonlocal total
|
||||||
|
try:
|
||||||
|
with os.scandir(path) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
try:
|
||||||
|
if entry.is_symlink():
|
||||||
|
total += entry.stat(follow_symlinks=False).st_size
|
||||||
|
continue
|
||||||
|
if entry.is_dir(follow_symlinks=False):
|
||||||
|
_walk(entry.path)
|
||||||
|
continue
|
||||||
|
total += entry.stat(follow_symlinks=False).st_size
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
|
||||||
|
_walk(root)
|
||||||
|
return total
|
||||||
|
|
||||||
|
async def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None:
|
||||||
|
if spec.host_path is None or spec.workspace_quota_mb <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
host_path = os.path.realpath(spec.host_path)
|
||||||
|
if not os.path.isdir(host_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Walk the workspace off the event loop — this runs on every
|
||||||
|
# quota-enforced exec, and a large tree would otherwise block the whole
|
||||||
|
# asyncio runtime (all bots/pipelines) for the duration of the scan.
|
||||||
|
used_bytes = await asyncio.to_thread(self._get_workspace_size_bytes, host_path)
|
||||||
|
limit_bytes = spec.workspace_quota_mb * _MIB
|
||||||
|
if used_bytes <= limit_bytes:
|
||||||
|
return
|
||||||
|
|
||||||
|
raise BoxValidationError(
|
||||||
|
f'workspace quota exceeded {phase}: '
|
||||||
|
f'used={used_bytes} bytes limit={limit_bytes} bytes '
|
||||||
|
f'host_path={host_path} session_id={spec.session_id}'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _cleanup_exceeded_session(self, spec: BoxSpec) -> None:
|
||||||
|
try:
|
||||||
|
await self.client.delete_session(spec.session_id)
|
||||||
|
except Exception as exc:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
'Failed to clean up Box session after workspace quota was exceeded: '
|
||||||
|
f'session_id={spec.session_id} error={exc}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Observability ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _record_error(self, exc: Exception, query: pipeline_query.Query):
|
||||||
|
self._recent_errors.append(
|
||||||
|
{
|
||||||
|
'timestamp': _dt.datetime.now(_UTC).isoformat(),
|
||||||
|
'type': type(exc).__name__,
|
||||||
|
'message': str(exc),
|
||||||
|
'query_id': str(query.query_id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_recent_errors(self) -> list[dict]:
|
||||||
|
return list(self._recent_errors)
|
||||||
|
|
||||||
|
def get_system_guidance(self) -> str:
|
||||||
|
"""Return LLM system-prompt guidance for the exec tool.
|
||||||
|
|
||||||
|
All execution-specific prompt text is kept here so that callers
|
||||||
|
(e.g. LocalAgentRunner) stay free of box domain knowledge.
|
||||||
|
"""
|
||||||
|
guidance = (
|
||||||
|
'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, '
|
||||||
|
'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, '
|
||||||
|
'JSON, or other data and asks for a computed answer, prefer running a short Python script via exec '
|
||||||
|
'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation '
|
||||||
|
'details, do not include the generated script in the final answer; return the result and a brief explanation only.'
|
||||||
|
)
|
||||||
|
if self.default_workspace:
|
||||||
|
guidance += (
|
||||||
|
' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or '
|
||||||
|
'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '
|
||||||
|
'user for directory parameters unless they explicitly need a different directory.'
|
||||||
|
)
|
||||||
|
return guidance
|
||||||
|
|
||||||
|
async def get_status(self) -> dict:
|
||||||
|
if not self._available:
|
||||||
|
return {
|
||||||
|
'available': False,
|
||||||
|
'enabled': self._enabled,
|
||||||
|
'profile': self.profile.name,
|
||||||
|
'recent_error_count': len(self._recent_errors),
|
||||||
|
'connector_error': self._connector_error,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
runtime_status = await self.client.get_status()
|
||||||
|
except Exception as exc:
|
||||||
|
# RPC failed — the runtime likely just disconnected and the
|
||||||
|
# heartbeat hasn't flipped _available yet.
|
||||||
|
return {
|
||||||
|
'available': False,
|
||||||
|
'enabled': self._enabled,
|
||||||
|
'profile': self.profile.name,
|
||||||
|
'recent_error_count': len(self._recent_errors),
|
||||||
|
'connector_error': str(exc),
|
||||||
|
}
|
||||||
|
# Backend state can be unavailable even when the connector is healthy
|
||||||
|
# (operator selected nsjail but the binary is missing, Docker daemon
|
||||||
|
# went down after the runtime started, E2B credentials wrong, ...).
|
||||||
|
# Report the combined state in the top-level ``available`` so the
|
||||||
|
# frontend banner / ``useBoxStatus`` hook / native-tool gate all
|
||||||
|
# agree on "actually usable" rather than "connector alive". The
|
||||||
|
# detailed ``backend`` object stays in the payload so the dialog
|
||||||
|
# can still show which backend was tried.
|
||||||
|
backend_info = runtime_status.get('backend') if isinstance(runtime_status, dict) else None
|
||||||
|
backend_ok = bool(backend_info and backend_info.get('available', False))
|
||||||
|
payload = {
|
||||||
|
**runtime_status,
|
||||||
|
'available': backend_ok,
|
||||||
|
'enabled': self._enabled,
|
||||||
|
'profile': self.profile.name,
|
||||||
|
'recent_error_count': len(self._recent_errors),
|
||||||
|
}
|
||||||
|
if not backend_ok and 'connector_error' not in payload:
|
||||||
|
backend_name = backend_info.get('name') if backend_info else None
|
||||||
|
if backend_name:
|
||||||
|
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
||||||
|
else:
|
||||||
|
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
|
||||||
|
return payload
|
||||||
413
src/langbot/pkg/box/workspace.py
Normal file
413
src/langbot/pkg/box/workspace.py
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
"""Reusable workspace/session helpers built on top of Box.
|
||||||
|
|
||||||
|
This module is the middle layer between the raw Box runtime primitives and
|
||||||
|
application-specific flows such as skills or MCP stdio.
|
||||||
|
|
||||||
|
It intentionally stays generic:
|
||||||
|
- path and virtualenv rewriting are workspace concerns
|
||||||
|
- Python project detection/bootstrap are workspace concerns
|
||||||
|
- session exec / managed-process helpers are workspace concerns
|
||||||
|
|
||||||
|
Higher layers add their own semantics on top, for example:
|
||||||
|
- skills choose a stable per-skill session id and use repeated exec
|
||||||
|
- MCP stdio chooses how to prepare dependencies and attaches to a managed process
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import textwrap
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
PYTHON_MANIFEST_FILES = (
|
||||||
|
'requirements.txt',
|
||||||
|
'pyproject.toml',
|
||||||
|
'setup.py',
|
||||||
|
'setup.cfg',
|
||||||
|
)
|
||||||
|
_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'})
|
||||||
|
_VENV_BIN_DIRS = frozenset({'bin', 'Scripts'})
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_host_path(path: str | None) -> str:
|
||||||
|
if path is None:
|
||||||
|
return ''
|
||||||
|
stripped = str(path).strip()
|
||||||
|
if not stripped:
|
||||||
|
return ''
|
||||||
|
return os.path.realpath(os.path.abspath(stripped))
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = '/workspace') -> str:
|
||||||
|
"""Translate a host path into the path visible inside the sandbox mount."""
|
||||||
|
if not host_path or not path:
|
||||||
|
return path
|
||||||
|
normalized_host = os.path.realpath(host_path)
|
||||||
|
normalized_path = os.path.realpath(path)
|
||||||
|
if normalized_path.startswith(normalized_host + '/'):
|
||||||
|
return mount_path + normalized_path[len(normalized_host) :]
|
||||||
|
if normalized_path == normalized_host:
|
||||||
|
return mount_path
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_venv_path(directory: str) -> str:
|
||||||
|
"""Collapse ``.../.venv/bin`` style paths back to the project root."""
|
||||||
|
parts = directory.replace('\\', '/').split('/')
|
||||||
|
for i in range(len(parts) - 1, 0, -1):
|
||||||
|
if parts[i] in _VENV_BIN_DIRS and i >= 1:
|
||||||
|
venv_dir = parts[i - 1]
|
||||||
|
if venv_dir in _VENV_DIRS:
|
||||||
|
project_root = '/'.join(parts[: i - 1])
|
||||||
|
return project_root if project_root else '/'
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def infer_workspace_host_path(command: str, args: list[str] | None = None) -> str | None:
|
||||||
|
"""Infer the project/workspace root from absolute command/arg paths."""
|
||||||
|
candidates: list[str] = []
|
||||||
|
for part in [command, *(args or [])]:
|
||||||
|
if not os.path.isabs(part):
|
||||||
|
continue
|
||||||
|
if os.path.exists(part):
|
||||||
|
directory = os.path.dirname(part)
|
||||||
|
candidates.append(os.path.realpath(unwrap_venv_path(directory)))
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
common = os.path.commonpath(candidates)
|
||||||
|
return common if common != '/' else None
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str = '/workspace') -> str:
|
||||||
|
"""Rewrite host venv interpreters to plain ``python`` inside the sandbox.
|
||||||
|
|
||||||
|
Once a project is mounted into the sandbox, host virtualenv paths are no
|
||||||
|
longer valid. For those paths we intentionally drop down to ``python`` and
|
||||||
|
let the sandbox-side environment/bootstrap decide what interpreter to use.
|
||||||
|
"""
|
||||||
|
if not host_path or not command:
|
||||||
|
return command
|
||||||
|
normalized_host = os.path.realpath(host_path)
|
||||||
|
normalized_command = os.path.realpath(command)
|
||||||
|
if not normalized_command.startswith(normalized_host + '/'):
|
||||||
|
return command
|
||||||
|
rel = normalized_command[len(normalized_host) + 1 :]
|
||||||
|
parts = rel.replace('\\', '/').split('/')
|
||||||
|
if len(parts) >= 3 and parts[0] in _VENV_DIRS and parts[1] in _VENV_BIN_DIRS and parts[2].startswith('python'):
|
||||||
|
return 'python'
|
||||||
|
return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path)
|
||||||
|
|
||||||
|
|
||||||
|
def list_python_manifest_files(host_path: str | None) -> list[str]:
|
||||||
|
normalized_root = normalize_host_path(host_path)
|
||||||
|
if not normalized_root:
|
||||||
|
return []
|
||||||
|
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_python_workspace(host_path: str | None) -> str | None:
|
||||||
|
"""Return the generic Python workspace shape, without app-specific policy."""
|
||||||
|
manifest_files = set(list_python_manifest_files(host_path))
|
||||||
|
if not manifest_files:
|
||||||
|
return None
|
||||||
|
if {'pyproject.toml', 'setup.py', 'setup.cfg'} & manifest_files:
|
||||||
|
return 'package'
|
||||||
|
if 'requirements.txt' in manifest_files:
|
||||||
|
return 'requirements'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def should_prepare_python_env(host_path: str | None) -> bool:
|
||||||
|
normalized_root = normalize_host_path(host_path)
|
||||||
|
if not normalized_root:
|
||||||
|
return False
|
||||||
|
if os.path.isdir(os.path.join(normalized_root, '.venv')):
|
||||||
|
return True
|
||||||
|
return bool(list_python_manifest_files(normalized_root))
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace') -> str:
|
||||||
|
"""Wrap a command with a reusable sandbox-local Python env bootstrap.
|
||||||
|
|
||||||
|
This is the generic "workspace is a Python project" path used by mutable
|
||||||
|
workspaces such as skills. Read-only installation strategies stay in the
|
||||||
|
higher-level caller because they are application policy, not workspace
|
||||||
|
semantics.
|
||||||
|
"""
|
||||||
|
bootstrap = textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
set -e
|
||||||
|
|
||||||
|
_LB_VENV_DIR="{mount_path}/.venv"
|
||||||
|
_LB_META_DIR="{mount_path}/.langbot"
|
||||||
|
_LB_META_FILE="$_LB_META_DIR/python-env.json"
|
||||||
|
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
|
||||||
|
_LB_TMP_DIR="{mount_path}/.tmp"
|
||||||
|
_LB_PIP_CACHE_DIR="{mount_path}/.cache/pip"
|
||||||
|
|
||||||
|
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
||||||
|
export TMPDIR="$_LB_TMP_DIR"
|
||||||
|
export TEMP="$_LB_TMP_DIR"
|
||||||
|
export TMP="$_LB_TMP_DIR"
|
||||||
|
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
||||||
|
|
||||||
|
_lb_python_meta() {{
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = "{mount_path}"
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
manifest_files = []
|
||||||
|
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
|
||||||
|
path = os.path.join(root, rel)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
manifest_files.append(rel)
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
digest.update(rel.encode("utf-8"))
|
||||||
|
digest.update(b"\\0")
|
||||||
|
digest.update(handle.read())
|
||||||
|
digest.update(b"\\0")
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{{
|
||||||
|
"python_executable": sys.executable,
|
||||||
|
"python_version": list(sys.version_info[:3]),
|
||||||
|
"manifest_files": manifest_files,
|
||||||
|
"manifest_sha256": digest.hexdigest(),
|
||||||
|
}},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
}}
|
||||||
|
|
||||||
|
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||||
|
_LB_NEEDS_BOOTSTRAP=0
|
||||||
|
|
||||||
|
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||||
|
_LB_NEEDS_BOOTSTRAP=1
|
||||||
|
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||||
|
_LB_NEEDS_BOOTSTRAP=1
|
||||||
|
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||||
|
_LB_NEEDS_BOOTSTRAP=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||||
|
_LB_LOCK_WAIT=0
|
||||||
|
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
||||||
|
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
||||||
|
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
_lb_cleanup_lock() {{
|
||||||
|
rmdir "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||||
|
}}
|
||||||
|
trap _lb_cleanup_lock EXIT INT TERM
|
||||||
|
|
||||||
|
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||||
|
_LB_NEEDS_BOOTSTRAP=0
|
||||||
|
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||||
|
_LB_NEEDS_BOOTSTRAP=1
|
||||||
|
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||||
|
_LB_NEEDS_BOOTSTRAP=1
|
||||||
|
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||||
|
_LB_NEEDS_BOOTSTRAP=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||||
|
rm -rf "$_LB_VENV_DIR"
|
||||||
|
python -m venv "$_LB_VENV_DIR"
|
||||||
|
. "$_LB_VENV_DIR/bin/activate"
|
||||||
|
python -m pip install --upgrade pip setuptools wheel
|
||||||
|
if [ -f "{mount_path}/requirements.txt" ]; then
|
||||||
|
python -m pip install -r "{mount_path}/requirements.txt"
|
||||||
|
elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then
|
||||||
|
python -m pip install "{mount_path}"
|
||||||
|
fi
|
||||||
|
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
export VIRTUAL_ENV="$_LB_VENV_DIR"
|
||||||
|
export PATH="$_LB_VENV_DIR/bin:$PATH"
|
||||||
|
{command}
|
||||||
|
"""
|
||||||
|
).strip()
|
||||||
|
return bootstrap + '\n'
|
||||||
|
|
||||||
|
|
||||||
|
class BoxWorkspaceSession:
|
||||||
|
"""High-level handle for one reusable workspace-backed Box session.
|
||||||
|
|
||||||
|
The Box runtime already understands sessions and managed processes. This
|
||||||
|
wrapper adds LangBot's workspace-centric view on top: a mounted host path,
|
||||||
|
a stable ``session_id``, optional environment defaults, and convenience
|
||||||
|
helpers for exec or long-running processes inside that workspace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
box_service,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
host_path: str | None = None,
|
||||||
|
host_path_mode: str = 'rw',
|
||||||
|
workdir: str = '/workspace',
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
mount_path: str = '/workspace',
|
||||||
|
network: str | None = None,
|
||||||
|
read_only_rootfs: bool | None = None,
|
||||||
|
image: str | None = None,
|
||||||
|
cpus: float | None = None,
|
||||||
|
memory_mb: int | None = None,
|
||||||
|
pids_limit: int | None = None,
|
||||||
|
persistent: bool = False,
|
||||||
|
):
|
||||||
|
self.box_service = box_service
|
||||||
|
self.session_id = session_id
|
||||||
|
self.host_path = host_path
|
||||||
|
self.host_path_mode = host_path_mode
|
||||||
|
self.workdir = workdir
|
||||||
|
self.env = dict(env or {})
|
||||||
|
self.mount_path = mount_path
|
||||||
|
self.network = network
|
||||||
|
self.read_only_rootfs = read_only_rootfs
|
||||||
|
self.image = image
|
||||||
|
self.cpus = cpus
|
||||||
|
self.memory_mb = memory_mb
|
||||||
|
self.pids_limit = pids_limit
|
||||||
|
self.persistent = persistent
|
||||||
|
|
||||||
|
def rewrite_path(self, path: str) -> str:
|
||||||
|
return rewrite_mounted_path(path, self.host_path, mount_path=self.mount_path)
|
||||||
|
|
||||||
|
def rewrite_venv_command(self, command: str) -> str:
|
||||||
|
return rewrite_venv_command(command, self.host_path, mount_path=self.mount_path)
|
||||||
|
|
||||||
|
def build_session_payload(self) -> dict[str, Any]:
|
||||||
|
# Keep this payload generic so callers can reuse the same workspace
|
||||||
|
# handle for plain exec, file-producing tasks, or managed processes.
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
'session_id': self.session_id,
|
||||||
|
'workdir': self.workdir,
|
||||||
|
'env': self.env,
|
||||||
|
'persistent': self.persistent,
|
||||||
|
}
|
||||||
|
if self.network is not None:
|
||||||
|
payload['network'] = self.network
|
||||||
|
if self.read_only_rootfs is not None:
|
||||||
|
payload['read_only_rootfs'] = self.read_only_rootfs
|
||||||
|
if self.host_path:
|
||||||
|
payload['host_path'] = self.host_path
|
||||||
|
payload['host_path_mode'] = self.host_path_mode
|
||||||
|
for key in ('image', 'cpus', 'memory_mb', 'pids_limit'):
|
||||||
|
value = getattr(self, key)
|
||||||
|
if value is not None:
|
||||||
|
payload[key] = value
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def build_exec_payload(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
*,
|
||||||
|
workdir: str | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
timeout_sec: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# Exec payloads inherit the session-level workspace config, then layer
|
||||||
|
# per-call command/workdir/env overrides on top.
|
||||||
|
payload = self.build_session_payload()
|
||||||
|
payload['cmd'] = cmd
|
||||||
|
payload['workdir'] = workdir or self.workdir
|
||||||
|
if timeout_sec is not None:
|
||||||
|
payload['timeout_sec'] = timeout_sec
|
||||||
|
resolved_env = self.env if env is None else env
|
||||||
|
if resolved_env:
|
||||||
|
payload['env'] = resolved_env
|
||||||
|
elif 'env' in payload and not payload['env']:
|
||||||
|
payload.pop('env')
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def execute_raw(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
*,
|
||||||
|
workdir: str | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
timeout_sec: int | None = None,
|
||||||
|
):
|
||||||
|
payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec)
|
||||||
|
return await self.box_service.client.execute(self.box_service.build_spec(payload))
|
||||||
|
|
||||||
|
async def execute_for_query(
|
||||||
|
self,
|
||||||
|
query,
|
||||||
|
cmd: str,
|
||||||
|
*,
|
||||||
|
workdir: str | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
timeout_sec: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec)
|
||||||
|
return await self.box_service.execute_spec_payload(payload, query)
|
||||||
|
|
||||||
|
async def create_session(self):
|
||||||
|
return await self.box_service.create_session(self.build_session_payload())
|
||||||
|
|
||||||
|
def build_process_payload(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
args: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
cwd: str = '/workspace',
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# Managed processes run inside the same workspace model as one-shot
|
||||||
|
# execs, so path/venv rewriting is shared here.
|
||||||
|
normalized_command = command
|
||||||
|
normalized_args = list(args or [])
|
||||||
|
normalized_cwd = cwd
|
||||||
|
if self.host_path:
|
||||||
|
normalized_command = self.rewrite_venv_command(command)
|
||||||
|
normalized_args = [self.rewrite_path(arg) for arg in normalized_args]
|
||||||
|
normalized_cwd = self.rewrite_path(cwd)
|
||||||
|
return {
|
||||||
|
'command': normalized_command,
|
||||||
|
'args': normalized_args,
|
||||||
|
'env': dict(env or {}),
|
||||||
|
'cwd': normalized_cwd,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def start_managed_process(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
args: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
process_id: str = 'default',
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
cwd: str = '/workspace',
|
||||||
|
):
|
||||||
|
payload = self.build_process_payload(command, args, env=env, cwd=cwd)
|
||||||
|
payload['process_id'] = process_id
|
||||||
|
return await self.box_service.start_managed_process(self.session_id, payload)
|
||||||
|
|
||||||
|
async def get_managed_process(self, process_id: str = 'default'):
|
||||||
|
return await self.box_service.get_managed_process(self.session_id, process_id)
|
||||||
|
|
||||||
|
async def stop_managed_process(self, process_id: str = 'default') -> None:
|
||||||
|
await self.box_service.stop_managed_process(self.session_id, process_id)
|
||||||
|
|
||||||
|
def get_managed_process_websocket_url(self, process_id: str = 'default') -> str:
|
||||||
|
return self.box_service.get_managed_process_websocket_url(self.session_id, process_id)
|
||||||
|
|
||||||
|
async def cleanup(self) -> None:
|
||||||
|
await self.box_service.client.delete_session(self.session_id)
|
||||||
@@ -9,6 +9,7 @@ from ..platform import botmgr as im_mgr
|
|||||||
from ..platform.webhook_pusher import WebhookPusher
|
from ..platform.webhook_pusher import WebhookPusher
|
||||||
from ..provider.session import sessionmgr as llm_session_mgr
|
from ..provider.session import sessionmgr as llm_session_mgr
|
||||||
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
||||||
|
from ..box import service as box_service_module
|
||||||
|
|
||||||
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
||||||
from ..config import manager as config_mgr
|
from ..config import manager as config_mgr
|
||||||
@@ -31,7 +32,8 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import apikey as apikey_service
|
||||||
from ..api.http.service import webhook as webhook_service
|
from ..api.http.service import webhook as webhook_service
|
||||||
from ..api.http.service import monitoring as monitoring_service
|
from ..api.http.service import monitoring as monitoring_service
|
||||||
|
from ..api.http.service import skill as skill_service
|
||||||
|
from ..api.http.service import maintenance as maintenance_service
|
||||||
from ..discover import engine as discover_engine
|
from ..discover import engine as discover_engine
|
||||||
from ..storage import mgr as storagemgr
|
from ..storage import mgr as storagemgr
|
||||||
from ..utils import logcache
|
from ..utils import logcache
|
||||||
@@ -42,6 +44,7 @@ from ..rag.service import RAGRuntimeService
|
|||||||
from ..vector import mgr as vectordb_mgr
|
from ..vector import mgr as vectordb_mgr
|
||||||
from ..telemetry import telemetry as telemetry_module
|
from ..telemetry import telemetry as telemetry_module
|
||||||
from ..survey import manager as survey_module
|
from ..survey import manager as survey_module
|
||||||
|
from ..skill import manager as skill_mgr
|
||||||
|
|
||||||
|
|
||||||
class Application:
|
class Application:
|
||||||
@@ -69,6 +72,7 @@ class Application:
|
|||||||
|
|
||||||
# TODO move to pipeline
|
# TODO move to pipeline
|
||||||
tool_mgr: llm_tool_mgr.ToolManager = None
|
tool_mgr: llm_tool_mgr.ToolManager = None
|
||||||
|
box_service: box_service_module.BoxService = None
|
||||||
|
|
||||||
# ======= Config manager =======
|
# ======= Config manager =======
|
||||||
|
|
||||||
@@ -133,6 +137,8 @@ class Application:
|
|||||||
|
|
||||||
embedding_models_service: model_service.EmbeddingModelsService = None
|
embedding_models_service: model_service.EmbeddingModelsService = None
|
||||||
|
|
||||||
|
rerank_models_service: model_service.RerankModelsService = None
|
||||||
|
|
||||||
provider_service: provider_service.ModelProviderService = None
|
provider_service: provider_service.ModelProviderService = None
|
||||||
|
|
||||||
pipeline_service: pipeline_service.PipelineService = None
|
pipeline_service: pipeline_service.PipelineService = None
|
||||||
@@ -153,6 +159,12 @@ class Application:
|
|||||||
|
|
||||||
monitoring_service: monitoring_service.MonitoringService = None
|
monitoring_service: monitoring_service.MonitoringService = None
|
||||||
|
|
||||||
|
skill_service: skill_service.SkillService = None
|
||||||
|
|
||||||
|
skill_mgr: skill_mgr.SkillManager = None
|
||||||
|
|
||||||
|
maintenance_service: maintenance_service.MaintenanceService = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -188,6 +200,77 @@ class Application:
|
|||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Start monitoring data cleanup task if enabled
|
||||||
|
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||||
|
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||||
|
if auto_cleanup_cfg.get('enabled', True):
|
||||||
|
retention_days = self._get_positive_int_config(
|
||||||
|
auto_cleanup_cfg.get('retention_days', 30),
|
||||||
|
default=30,
|
||||||
|
name='monitoring.auto_cleanup.retention_days',
|
||||||
|
)
|
||||||
|
delete_batch_size = self._get_positive_int_config(
|
||||||
|
auto_cleanup_cfg.get('delete_batch_size', 1000),
|
||||||
|
default=1000,
|
||||||
|
name='monitoring.auto_cleanup.delete_batch_size',
|
||||||
|
)
|
||||||
|
check_interval_hours = self._get_positive_float_config(
|
||||||
|
auto_cleanup_cfg.get('check_interval_hours', 1),
|
||||||
|
default=1,
|
||||||
|
name='monitoring.auto_cleanup.check_interval_hours',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitoring_cleanup_loop():
|
||||||
|
check_interval_seconds = check_interval_hours * 3600
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
deleted = await self.monitoring_service.cleanup_expired_records(
|
||||||
|
retention_days,
|
||||||
|
batch_size=delete_batch_size,
|
||||||
|
)
|
||||||
|
total_deleted = sum(deleted.values())
|
||||||
|
if total_deleted > 0:
|
||||||
|
self.logger.info(
|
||||||
|
f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
|
||||||
|
f'(retention={retention_days}d): {deleted}'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Monitoring auto-cleanup error: {e}')
|
||||||
|
await asyncio.sleep(check_interval_seconds)
|
||||||
|
|
||||||
|
self.task_mgr.create_task(
|
||||||
|
monitoring_cleanup_loop(),
|
||||||
|
name='monitoring-cleanup',
|
||||||
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start storage/log maintenance task if enabled
|
||||||
|
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||||
|
if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:
|
||||||
|
check_interval_hours = self._get_positive_float_config(
|
||||||
|
storage_cleanup_cfg.get('check_interval_hours', 1),
|
||||||
|
default=1,
|
||||||
|
name='storage.cleanup.check_interval_hours',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def storage_cleanup_loop():
|
||||||
|
check_interval_seconds = check_interval_hours * 3600
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
deleted = await self.maintenance_service.cleanup_expired_files()
|
||||||
|
total_deleted = sum(deleted.values())
|
||||||
|
if total_deleted > 0:
|
||||||
|
self.logger.info(f'Storage maintenance: deleted expired files: {deleted}')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Storage maintenance error: {e}')
|
||||||
|
await asyncio.sleep(check_interval_seconds)
|
||||||
|
|
||||||
|
self.task_mgr.create_task(
|
||||||
|
storage_cleanup_loop(),
|
||||||
|
name='storage-maintenance',
|
||||||
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
|
)
|
||||||
|
|
||||||
self.task_mgr.create_task(
|
self.task_mgr.create_task(
|
||||||
never_ending(),
|
never_ending(),
|
||||||
name='never-ending-task',
|
name='never-ending-task',
|
||||||
@@ -202,8 +285,33 @@ class Application:
|
|||||||
self.logger.error(f'Application runtime fatal exception: {e}')
|
self.logger.error(f'Application runtime fatal exception: {e}')
|
||||||
self.logger.debug(f'Traceback: {traceback.format_exc()}')
|
self.logger.debug(f'Traceback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
def _get_positive_int_config(self, value, default: int, name: str) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
if parsed < 1:
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def _get_positive_float_config(self, value, default: float, name: str) -> float:
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
if parsed <= 0:
|
||||||
|
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||||
|
return default
|
||||||
|
return parsed
|
||||||
|
|
||||||
def dispose(self):
|
def dispose(self):
|
||||||
|
if self.plugin_connector is not None:
|
||||||
self.plugin_connector.dispose()
|
self.plugin_connector.dispose()
|
||||||
|
if self.box_service is not None:
|
||||||
|
self.box_service.dispose()
|
||||||
|
|
||||||
async def print_web_access_info(self):
|
async def print_web_access_info(self):
|
||||||
"""Print access webui tips"""
|
"""Print access webui tips"""
|
||||||
|
|||||||
@@ -46,11 +46,13 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
|
|||||||
|
|
||||||
|
|
||||||
async def main(loop: asyncio.AbstractEventLoop):
|
async def main(loop: asyncio.AbstractEventLoop):
|
||||||
|
app_inst: app.Application | None = None
|
||||||
try:
|
try:
|
||||||
# Hang system signal processing
|
# Hang system signal processing
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
|
if app_inst is not None:
|
||||||
app_inst.dispose()
|
app_inst.dispose()
|
||||||
print('[Signal] Program exit.')
|
print('[Signal] Program exit.')
|
||||||
os._exit(0)
|
os._exit(0)
|
||||||
@@ -60,4 +62,6 @@ async def main(loop: asyncio.AbstractEventLoop):
|
|||||||
app_inst = await make_app(loop)
|
app_inst = await make_app(loop)
|
||||||
await app_inst.run()
|
await app_inst.run()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
if app_inst is not None:
|
||||||
|
app_inst.dispose()
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
27
src/langbot/pkg/core/migrations/m042_weknora_api.py
Normal file
27
src/langbot/pkg/core/migrations/m042_weknora_api.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class('weknora-api-config', 42)
|
||||||
|
class WeKnoraAPICfgMigration(migration.Migration):
|
||||||
|
"""WeKnora API 配置迁移"""
|
||||||
|
|
||||||
|
async def need_migrate(self) -> bool:
|
||||||
|
"""判断当前环境是否需要运行此迁移"""
|
||||||
|
return 'weknora-api' not in self.ap.provider_cfg.data
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""执行迁移"""
|
||||||
|
self.ap.provider_cfg.data['weknora-api'] = {
|
||||||
|
'base-url': 'http://localhost:8080/api/v1',
|
||||||
|
'app-type': 'agent',
|
||||||
|
'api-key': '',
|
||||||
|
'agent-id': 'builtin-smart-reasoning',
|
||||||
|
'knowledge-base-ids': [],
|
||||||
|
'web-search-enabled': False,
|
||||||
|
'timeout': 120,
|
||||||
|
'base-prompt': '请回答用户的问题。',
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.ap.provider_cfg.dump_config()
|
||||||
30
src/langbot/pkg/core/migrations/m043_deerflow_api.py
Normal file
30
src/langbot/pkg/core/migrations/m043_deerflow_api.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class('deerflow-api-config', 43)
|
||||||
|
class DeerFlowAPICfgMigration(migration.Migration):
|
||||||
|
"""DeerFlow API 配置迁移"""
|
||||||
|
|
||||||
|
async def need_migrate(self) -> bool:
|
||||||
|
"""判断当前环境是否需要运行此迁移"""
|
||||||
|
return 'deerflow-api' not in self.ap.provider_cfg.data
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""执行迁移"""
|
||||||
|
self.ap.provider_cfg.data['deerflow-api'] = {
|
||||||
|
'api-base': 'http://127.0.0.1:2026',
|
||||||
|
'api-key': '',
|
||||||
|
'auth-header': '',
|
||||||
|
'assistant-id': 'lead_agent',
|
||||||
|
'model-name': '',
|
||||||
|
'thinking-enabled': False,
|
||||||
|
'plan-mode': False,
|
||||||
|
'subagent-enabled': False,
|
||||||
|
'max-concurrent-subagents': 3,
|
||||||
|
'timeout': 300,
|
||||||
|
'recursion-limit': 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.ap.provider_cfg.dump_config()
|
||||||
@@ -6,6 +6,7 @@ from .. import stage, app
|
|||||||
from ...utils import version, proxy
|
from ...utils import version, proxy
|
||||||
from ...pipeline import pool, controller, pipelinemgr
|
from ...pipeline import pool, controller, pipelinemgr
|
||||||
from ...pipeline import aggregator as message_aggregator
|
from ...pipeline import aggregator as message_aggregator
|
||||||
|
from ...box import service as box_service
|
||||||
from ...plugin import connector as plugin_connector
|
from ...plugin import connector as plugin_connector
|
||||||
from ...command import cmdmgr
|
from ...command import cmdmgr
|
||||||
from ...provider.session import sessionmgr as llm_session_mgr
|
from ...provider.session import sessionmgr as llm_session_mgr
|
||||||
@@ -28,6 +29,9 @@ from ...api.http.service import mcp as mcp_service
|
|||||||
from ...api.http.service import apikey as apikey_service
|
from ...api.http.service import apikey as apikey_service
|
||||||
from ...api.http.service import webhook as webhook_service
|
from ...api.http.service import webhook as webhook_service
|
||||||
from ...api.http.service import monitoring as monitoring_service
|
from ...api.http.service import monitoring as monitoring_service
|
||||||
|
from ...api.http.service import skill as skill_service
|
||||||
|
from ...skill import manager as skill_mgr
|
||||||
|
from ...api.http.service import maintenance as maintenance_service
|
||||||
from ...discover import engine as discover_engine
|
from ...discover import engine as discover_engine
|
||||||
from ...storage import mgr as storagemgr
|
from ...storage import mgr as storagemgr
|
||||||
from ...utils import logcache
|
from ...utils import logcache
|
||||||
@@ -61,6 +65,9 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
||||||
ap.embedding_models_service = embedding_models_service_inst
|
ap.embedding_models_service = embedding_models_service_inst
|
||||||
|
|
||||||
|
rerank_models_service_inst = model_service.RerankModelsService(ap)
|
||||||
|
ap.rerank_models_service = rerank_models_service_inst
|
||||||
|
|
||||||
provider_service_inst = provider_service.ModelProviderService(ap)
|
provider_service_inst = provider_service.ModelProviderService(ap)
|
||||||
ap.provider_service = provider_service_inst
|
ap.provider_service = provider_service_inst
|
||||||
|
|
||||||
@@ -82,6 +89,9 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||||
ap.webhook_service = webhook_service_inst
|
ap.webhook_service = webhook_service_inst
|
||||||
|
|
||||||
|
skill_service_inst = skill_service.SkillService(ap)
|
||||||
|
ap.skill_service = skill_service_inst
|
||||||
|
|
||||||
proxy_mgr = proxy.ProxyManager(ap)
|
proxy_mgr = proxy.ProxyManager(ap)
|
||||||
await proxy_mgr.initialize()
|
await proxy_mgr.initialize()
|
||||||
ap.proxy_mgr = proxy_mgr
|
ap.proxy_mgr = proxy_mgr
|
||||||
@@ -125,6 +135,10 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
await llm_session_mgr_inst.initialize()
|
await llm_session_mgr_inst.initialize()
|
||||||
ap.sess_mgr = llm_session_mgr_inst
|
ap.sess_mgr = llm_session_mgr_inst
|
||||||
|
|
||||||
|
box_service_inst = box_service.BoxService(ap)
|
||||||
|
await box_service_inst.initialize()
|
||||||
|
ap.box_service = box_service_inst
|
||||||
|
|
||||||
llm_tool_mgr_inst = llm_tool_mgr.ToolManager(ap)
|
llm_tool_mgr_inst = llm_tool_mgr.ToolManager(ap)
|
||||||
await llm_tool_mgr_inst.initialize()
|
await llm_tool_mgr_inst.initialize()
|
||||||
ap.tool_mgr = llm_tool_mgr_inst
|
ap.tool_mgr = llm_tool_mgr_inst
|
||||||
@@ -145,6 +159,11 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
msg_aggregator_inst = message_aggregator.MessageAggregator(ap)
|
msg_aggregator_inst = message_aggregator.MessageAggregator(ap)
|
||||||
ap.msg_aggregator = msg_aggregator_inst
|
ap.msg_aggregator = msg_aggregator_inst
|
||||||
|
|
||||||
|
# Initialize skill manager
|
||||||
|
skill_mgr_inst = skill_mgr.SkillManager(ap)
|
||||||
|
await skill_mgr_inst.initialize()
|
||||||
|
ap.skill_mgr = skill_mgr_inst
|
||||||
|
|
||||||
rag_mgr_inst = rag_mgr.RAGManager(ap)
|
rag_mgr_inst = rag_mgr.RAGManager(ap)
|
||||||
await rag_mgr_inst.initialize()
|
await rag_mgr_inst.initialize()
|
||||||
ap.rag_mgr = rag_mgr_inst
|
ap.rag_mgr = rag_mgr_inst
|
||||||
@@ -164,6 +183,9 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
||||||
ap.monitoring_service = monitoring_service_inst
|
ap.monitoring_service = monitoring_service_inst
|
||||||
|
|
||||||
|
maintenance_service_inst = maintenance_service.MaintenanceService(ap)
|
||||||
|
ap.maintenance_service = maintenance_service_inst
|
||||||
|
|
||||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
await plugin_connector_inst.initialize()
|
await plugin_connector_inst.initialize()
|
||||||
|
|||||||
@@ -80,8 +80,12 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
|||||||
if i == len(keys) - 1:
|
if i == len(keys) - 1:
|
||||||
# At the final key
|
# At the final key
|
||||||
if key in current:
|
if key in current:
|
||||||
if isinstance(current[key], (dict, list)):
|
if isinstance(current[key], list):
|
||||||
# Skip dict and list types
|
# Convert comma-separated string to list
|
||||||
|
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
|
||||||
|
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||||
|
elif isinstance(current[key], dict):
|
||||||
|
# Skip dict types
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Valid scalar value - convert and set it
|
# Valid scalar value - convert and set it
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
import datetime
|
import datetime
|
||||||
|
import time
|
||||||
|
|
||||||
from . import app
|
from . import app
|
||||||
from . import entities as core_entities
|
from . import entities as core_entities
|
||||||
@@ -17,9 +18,13 @@ class TaskContext:
|
|||||||
log: str
|
log: str
|
||||||
"""Log"""
|
"""Log"""
|
||||||
|
|
||||||
|
metadata: dict
|
||||||
|
"""Structured metadata for progress reporting"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.current_action = 'default'
|
self.current_action = 'default'
|
||||||
self.log = ''
|
self.log = ''
|
||||||
|
self.metadata = {}
|
||||||
|
|
||||||
def _log(self, msg: str):
|
def _log(self, msg: str):
|
||||||
self.log += msg + '\n'
|
self.log += msg + '\n'
|
||||||
@@ -38,7 +43,7 @@ class TaskContext:
|
|||||||
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
|
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {'current_action': self.current_action, 'log': self.log}
|
return {'current_action': self.current_action, 'log': self.log, 'metadata': self.metadata}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def new() -> TaskContext:
|
def new() -> TaskContext:
|
||||||
@@ -115,6 +120,7 @@ class TaskWrapper:
|
|||||||
self.label = label if label != '' else name
|
self.label = label if label != '' else name
|
||||||
self.task.set_name(name)
|
self.task.set_name(name)
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
|
self.created_at = time.time()
|
||||||
|
|
||||||
def assume_exception(self):
|
def assume_exception(self):
|
||||||
try:
|
try:
|
||||||
@@ -150,6 +156,7 @@ class TaskWrapper:
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'label': self.label,
|
'label': self.label,
|
||||||
'scopes': [scope.value for scope in self.scopes],
|
'scopes': [scope.value for scope in self.scopes],
|
||||||
|
'created_at': self.created_at,
|
||||||
'task_context': self.task_context.to_dict(),
|
'task_context': self.task_context.to_dict(),
|
||||||
'runtime': {
|
'runtime': {
|
||||||
'done': self.task.done(),
|
'done': self.task.done(),
|
||||||
@@ -189,6 +196,8 @@ class AsyncTaskManager:
|
|||||||
) -> TaskWrapper:
|
) -> TaskWrapper:
|
||||||
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
|
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
|
||||||
self.tasks.append(wrapper)
|
self.tasks.append(wrapper)
|
||||||
|
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
|
||||||
|
self._prune_completed_tasks()
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def create_user_task(
|
def create_user_task(
|
||||||
@@ -211,9 +220,23 @@ class AsyncTaskManager:
|
|||||||
def get_tasks_dict(
|
def get_tasks_dict(
|
||||||
self,
|
self,
|
||||||
type: str = None,
|
type: str = None,
|
||||||
|
kind: str = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type],
|
'tasks': [
|
||||||
|
t.to_dict()
|
||||||
|
for t in self.tasks
|
||||||
|
if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
|
||||||
|
],
|
||||||
|
'id_index': TaskWrapper._id_index,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
completed = sum(1 for t in self.tasks if t.task.done())
|
||||||
|
return {
|
||||||
|
'total': len(self.tasks),
|
||||||
|
'running': len(self.tasks) - completed,
|
||||||
|
'completed': completed,
|
||||||
'id_index': TaskWrapper._id_index,
|
'id_index': TaskWrapper._id_index,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,3 +257,27 @@ class AsyncTaskManager:
|
|||||||
if not wrapper.task.done():
|
if not wrapper.task.done():
|
||||||
wrapper.task.cancel()
|
wrapper.task.cancel()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _prune_completed_tasks(self):
|
||||||
|
completed_limit = (
|
||||||
|
self.ap.instance_config.data.get('system', {})
|
||||||
|
.get('task_retention', {})
|
||||||
|
.get(
|
||||||
|
'completed_limit',
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
completed_limit = int(completed_limit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
completed_limit = 200
|
||||||
|
if completed_limit < 1:
|
||||||
|
completed_limit = 1
|
||||||
|
|
||||||
|
completed_tasks = [wrapper for wrapper in self.tasks if wrapper.task.done()]
|
||||||
|
overflow = len(completed_tasks) - completed_limit
|
||||||
|
if overflow <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
remove_ids = {wrapper.id for wrapper in completed_tasks[:overflow]}
|
||||||
|
self.tasks = [wrapper for wrapper in self.tasks if wrapper.id not in remove_ids]
|
||||||
|
|||||||
@@ -17,11 +17,23 @@ class I18nString(pydantic.BaseModel):
|
|||||||
"""英文"""
|
"""英文"""
|
||||||
|
|
||||||
zh_Hans: typing.Optional[str] = None
|
zh_Hans: typing.Optional[str] = None
|
||||||
"""中文"""
|
"""简体中文"""
|
||||||
|
|
||||||
|
zh_Hant: typing.Optional[str] = None
|
||||||
|
"""繁体中文"""
|
||||||
|
|
||||||
ja_JP: typing.Optional[str] = None
|
ja_JP: typing.Optional[str] = None
|
||||||
"""日文"""
|
"""日文"""
|
||||||
|
|
||||||
|
th_TH: typing.Optional[str] = None
|
||||||
|
"""泰文"""
|
||||||
|
|
||||||
|
vi_VN: typing.Optional[str] = None
|
||||||
|
"""越南文"""
|
||||||
|
|
||||||
|
es_ES: typing.Optional[str] = None
|
||||||
|
"""西班牙文"""
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""转换为字典"""
|
"""转换为字典"""
|
||||||
dic = {}
|
dic = {}
|
||||||
@@ -29,8 +41,16 @@ class I18nString(pydantic.BaseModel):
|
|||||||
dic['en_US'] = self.en_US
|
dic['en_US'] = self.en_US
|
||||||
if self.zh_Hans is not None:
|
if self.zh_Hans is not None:
|
||||||
dic['zh_Hans'] = self.zh_Hans
|
dic['zh_Hans'] = self.zh_Hans
|
||||||
|
if self.zh_Hant is not None:
|
||||||
|
dic['zh_Hant'] = self.zh_Hant
|
||||||
if self.ja_JP is not None:
|
if self.ja_JP is not None:
|
||||||
dic['ja_JP'] = self.ja_JP
|
dic['ja_JP'] = self.ja_JP
|
||||||
|
if self.th_TH is not None:
|
||||||
|
dic['th_TH'] = self.th_TH
|
||||||
|
if self.vi_VN is not None:
|
||||||
|
dic['vi_VN'] = self.vi_VN
|
||||||
|
if self.es_ES is not None:
|
||||||
|
dic['es_ES'] = self.es_ES
|
||||||
return dic
|
return dic
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Bot(Base):
|
|||||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
sqlalchemy.DateTime,
|
sqlalchemy.DateTime,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ class MCPServer(Base):
|
|||||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
|
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
|
||||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
|
# Markdown documentation captured from LangBot Space at install time so the
|
||||||
|
# detail page can show docs even when the server is offline / has no tools.
|
||||||
|
# Empty string for manually-created servers that have no marketplace README.
|
||||||
|
readme = sqlalchemy.Column(sqlalchemy.Text, nullable=False, server_default='', default='')
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
sqlalchemy.DateTime,
|
sqlalchemy.DateTime,
|
||||||
|
|||||||
@@ -59,3 +59,22 @@ class EmbeddingModel(Base):
|
|||||||
server_default=sqlalchemy.func.now(),
|
server_default=sqlalchemy.func.now(),
|
||||||
onupdate=sqlalchemy.func.now(),
|
onupdate=sqlalchemy.func.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RerankModel(Base):
|
||||||
|
"""Rerank model"""
|
||||||
|
|
||||||
|
__tablename__ = 'rerank_models'
|
||||||
|
|
||||||
|
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||||
|
name = sqlalchemy.Column(sqlalchemy.String(255), 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,
|
||||||
|
nullable=False,
|
||||||
|
server_default=sqlalchemy.func.now(),
|
||||||
|
onupdate=sqlalchemy.func.now(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -106,3 +106,26 @@ class MonitoringEmbeddingCall(Base):
|
|||||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
message_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
|
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringFeedback(Base):
|
||||||
|
"""User feedback records (like/dislike) from AI Bot conversations"""
|
||||||
|
|
||||||
|
__tablename__ = 'monitoring_feedback'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||||
|
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||||
|
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||||
|
feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike
|
||||||
|
feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text
|
||||||
|
inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons
|
||||||
|
# Context fields
|
||||||
|
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom
|
||||||
|
|||||||
0
src/langbot/pkg/persistence/alembic/__init__.py
Normal file
0
src/langbot/pkg/persistence/alembic/__init__.py
Normal file
51
src/langbot/pkg/persistence/alembic/env.py
Normal file
51
src/langbot/pkg/persistence/alembic/env.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Alembic environment for LangBot.
|
||||||
|
|
||||||
|
This env.py is designed to be called programmatically (not via CLI).
|
||||||
|
It supports both SQLite and PostgreSQL.
|
||||||
|
|
||||||
|
The sync connection is passed via config attributes by the runner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode — emit SQL without a live connection."""
|
||||||
|
url = context.config.get_main_option('sqlalchemy.url')
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={'paramstyle': 'named'},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations with a live sync connection passed via config attributes."""
|
||||||
|
connection: Connection = context.config.attributes.get('connection')
|
||||||
|
if connection is None:
|
||||||
|
raise RuntimeError('connection not provided in alembic config attributes')
|
||||||
|
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
# render_as_batch=True is critical for SQLite ALTER TABLE support
|
||||||
|
render_as_batch=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
src/langbot/pkg/persistence/alembic/script.py.mako
Normal file
24
src/langbot/pkg/persistence/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Alembic script.py.mako — template for auto-generated revisions
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""baseline: stamp existing schema (db version 25)
|
||||||
|
|
||||||
|
This is a no-op migration that marks the starting point for Alembic.
|
||||||
|
All tables already exist via create_all() + legacy DBMigration system.
|
||||||
|
|
||||||
|
Revision ID: 0001_baseline
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-04-08
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = '0001_baseline'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# No-op: existing schema is already at database_version=25
|
||||||
|
# This revision serves as the Alembic baseline.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
62
src/langbot/pkg/persistence/alembic/versions/0002_sample.py
Normal file
62
src/langbot/pkg/persistence/alembic/versions/0002_sample.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""example: sample migration demonstrating Alembic patterns
|
||||||
|
|
||||||
|
This is a SAMPLE showing how to write migrations that work
|
||||||
|
seamlessly across SQLite and PostgreSQL. Delete or adapt as needed.
|
||||||
|
|
||||||
|
Revision ID: 0002_sample
|
||||||
|
Revises: 0001_baseline
|
||||||
|
Create Date: 2026-04-08
|
||||||
|
|
||||||
|
Patterns demonstrated:
|
||||||
|
1. Schema change (add column) — works on both DBs via render_as_batch
|
||||||
|
2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = '0002_sample'
|
||||||
|
down_revision = '0001_baseline'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""
|
||||||
|
EXAMPLE: Uncomment to use. This shows the patterns.
|
||||||
|
|
||||||
|
# --- Pattern 1: Schema change (add/drop column) ---
|
||||||
|
# render_as_batch=True in env.py makes this work on SQLite too.
|
||||||
|
#
|
||||||
|
# op.add_column('pipelines', sa.Column('description', sa.String(512), server_default=''))
|
||||||
|
|
||||||
|
# --- Pattern 2: Data migration (read + modify JSON field) ---
|
||||||
|
# No if/else for sqlite vs postgres needed!
|
||||||
|
#
|
||||||
|
# conn = op.get_bind()
|
||||||
|
# rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall()
|
||||||
|
# for row in rows:
|
||||||
|
# config = json.loads(row[1]) if isinstance(row[1], str) else row[1]
|
||||||
|
# # Modify the config
|
||||||
|
# config.setdefault('ai', {}).setdefault('some_new_key', 'default_value')
|
||||||
|
# conn.execute(
|
||||||
|
# sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"),
|
||||||
|
# {"cfg": json.dumps(config), "uuid": row[0]}
|
||||||
|
# )
|
||||||
|
|
||||||
|
# --- Pattern 3: Create a new table ---
|
||||||
|
#
|
||||||
|
# op.create_table(
|
||||||
|
# 'audit_log',
|
||||||
|
# sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
# sa.Column('action', sa.String(255), nullable=False),
|
||||||
|
# sa.Column('detail', sa.Text),
|
||||||
|
# sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
|
||||||
|
# )
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""
|
||||||
|
# op.drop_column('pipelines', 'description')
|
||||||
|
# op.drop_table('audit_log')
|
||||||
|
"""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""add rerank_models table
|
||||||
|
|
||||||
|
Revision ID: 0003_add_rerank_models
|
||||||
|
Revises: 0002_sample
|
||||||
|
Create Date: 2026-04-19
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0003_add_rerank_models'
|
||||||
|
down_revision = '0002_sample'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check if table already exists (may have been created by create_all())
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
if 'rerank_models' not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
'rerank_models',
|
||||||
|
sa.Column('uuid', sa.String(255), primary_key=True, unique=True),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('provider_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('extra_args', sa.JSON, nullable=False, server_default='{}'),
|
||||||
|
sa.Column('prefered_ranking', sa.Integer, nullable=False, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('rerank_models')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add readme column to mcp_servers
|
||||||
|
|
||||||
|
Revision ID: 0004_add_mcp_readme
|
||||||
|
Revises: 0003_add_rerank_models
|
||||||
|
Create Date: 2026-06-06
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0004_add_mcp_readme'
|
||||||
|
down_revision = '0003_add_rerank_models'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add ``readme`` to mcp_servers if the table exists and the column is missing
|
||||||
|
# (the table may have been created by create_all() with the column already
|
||||||
|
# present on fresh installs, so guard against duplicate-add).
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
if 'mcp_servers' not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('mcp_servers')}
|
||||||
|
if 'readme' not in columns:
|
||||||
|
op.add_column(
|
||||||
|
'mcp_servers',
|
||||||
|
sa.Column('readme', sa.Text(), nullable=False, server_default=''),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('mcp_servers', 'readme')
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user