Compare commits
2 Commits
feat/workf
...
copilot/bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eefb0ddde1 | ||
|
|
4c7ee6a9c9 |
109
.github/workflows/run-tests.yml
vendored
@@ -4,29 +4,25 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, ready_for_review, synchronize]
|
types: [opened, ready_for_review, synchronize]
|
||||||
paths:
|
paths:
|
||||||
- 'src/langbot/**'
|
- 'pkg/**'
|
||||||
- '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:
|
paths:
|
||||||
- 'src/langbot/**'
|
- 'pkg/**'
|
||||||
- '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'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Unit Tests
|
name: Run Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -43,13 +39,28 @@ jobs:
|
|||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v4
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --dev
|
run: |
|
||||||
|
uv sync --dev
|
||||||
|
|
||||||
- name: Run unit + smoke tests
|
- name: Run unit tests
|
||||||
run: uv run pytest tests/unit_tests/ tests/smoke/ -q --tb=short
|
run: |
|
||||||
|
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()
|
||||||
@@ -58,79 +69,3 @@ 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
|
|
||||||
109
.github/workflows/test-migrations.yml
vendored
@@ -9,13 +9,11 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'src/langbot/pkg/persistence/**'
|
- 'src/langbot/pkg/persistence/**'
|
||||||
- 'src/langbot/pkg/entity/persistence/**'
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
- 'tests/integration/persistence/**'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
paths:
|
paths:
|
||||||
- 'src/langbot/pkg/persistence/**'
|
- 'src/langbot/pkg/persistence/**'
|
||||||
- 'src/langbot/pkg/entity/persistence/**'
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
- 'tests/integration/persistence/**'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-migrations-sqlite:
|
test-migrations-sqlite:
|
||||||
@@ -36,8 +34,52 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --dev
|
run: uv sync --dev
|
||||||
|
|
||||||
- name: Run SQLite migration tests
|
- name: Test Alembic upgrade (SQLite)
|
||||||
run: uv run pytest tests/integration/persistence/test_migrations.py -q --tb=short
|
run: |
|
||||||
|
uv run python -c "
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
engine = create_async_engine('sqlite+aiosqlite:///test_migrations.db')
|
||||||
|
|
||||||
|
# Create all tables (simulates existing DB)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
# Stamp baseline
|
||||||
|
await run_alembic_stamp(engine, '0001_baseline')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
|
||||||
|
print(f'Stamped: {rev}')
|
||||||
|
|
||||||
|
# Upgrade to head
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
print(f'After upgrade: {rev}')
|
||||||
|
assert rev is not None, 'Expected a revision after upgrade'
|
||||||
|
|
||||||
|
# Verify idempotent
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev2 = await get_alembic_current(engine)
|
||||||
|
assert rev2 == rev, f'Expected {rev}, got {rev2}'
|
||||||
|
print(f'Idempotent check passed: {rev2}')
|
||||||
|
|
||||||
|
# Fresh DB: upgrade from scratch
|
||||||
|
engine2 = create_async_engine('sqlite+aiosqlite:///test_migrations_fresh.db')
|
||||||
|
async with engine2.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await run_alembic_upgrade(engine2, 'head')
|
||||||
|
rev3 = await get_alembic_current(engine2)
|
||||||
|
print(f'Fresh DB upgrade: {rev3}')
|
||||||
|
assert rev3 is not None
|
||||||
|
|
||||||
|
print('All SQLite migration tests passed!')
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
|
||||||
test-migrations-postgres:
|
test-migrations-postgres:
|
||||||
name: Migrations (PostgreSQL)
|
name: Migrations (PostgreSQL)
|
||||||
@@ -72,7 +114,58 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --dev
|
run: uv sync --dev
|
||||||
|
|
||||||
- name: Run PostgreSQL migration tests
|
- name: Test Alembic upgrade (PostgreSQL)
|
||||||
env:
|
run: |
|
||||||
TEST_POSTGRES_URL: postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test
|
uv run python -c "
|
||||||
run: uv run pytest tests/integration/persistence/test_migrations_postgres.py -q --tb=short
|
import asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
|
||||||
|
|
||||||
|
DB_URL = 'postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test'
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
engine = create_async_engine(DB_URL)
|
||||||
|
|
||||||
|
# Create all tables
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
# Stamp baseline
|
||||||
|
await run_alembic_stamp(engine, '0001_baseline')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
|
||||||
|
print(f'Stamped: {rev}')
|
||||||
|
|
||||||
|
# Upgrade to head
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
print(f'After upgrade: {rev}')
|
||||||
|
assert rev is not None
|
||||||
|
|
||||||
|
# Verify idempotent
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev2 = await get_alembic_current(engine)
|
||||||
|
assert rev2 == rev, f'Expected {rev}, got {rev2}'
|
||||||
|
print(f'Idempotent check passed: {rev2}')
|
||||||
|
|
||||||
|
# Fresh DB: drop all and upgrade from scratch
|
||||||
|
engine2 = create_async_engine(DB_URL.replace('langbot_test', 'langbot_fresh'))
|
||||||
|
|
||||||
|
# Create fresh database
|
||||||
|
from sqlalchemy import text
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text('COMMIT'))
|
||||||
|
await conn.execute(text('CREATE DATABASE langbot_fresh'))
|
||||||
|
|
||||||
|
async with engine2.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await run_alembic_upgrade(engine2, 'head')
|
||||||
|
rev3 = await get_alembic_current(engine2)
|
||||||
|
print(f'Fresh DB upgrade: {rev3}')
|
||||||
|
assert rev3 is not None
|
||||||
|
|
||||||
|
print('All PostgreSQL migration tests passed!')
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -47,7 +47,6 @@ plugins.bak
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
src/langbot/web/
|
src/langbot/web/
|
||||||
testsdk/
|
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/dist
|
/dist
|
||||||
|
|||||||
36
Makefile
@@ -1,36 +0,0 @@
|
|||||||
# 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/
|
|
||||||
82
README.md
@@ -47,8 +47,6 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
|||||||
|
|
||||||
[→ Learn more about all features](https://link.langbot.app/en/docs/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/).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -86,48 +84,45 @@ docker compose up -d
|
|||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | Official |
|
| Discord | ✅ | |
|
||||||
| Telegram | ✅ | Official |
|
| Telegram | ✅ | |
|
||||||
| Slack | ✅ | Official |
|
| Slack | ✅ | |
|
||||||
| LINE | ✅ | Official |
|
| LINE | ✅ | |
|
||||||
| QQ | ✅ | Personal & Official API (Channel, DM, Group) |
|
| QQ | ✅ | Personal & Official API |
|
||||||
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
||||||
| WeChat | ✅ | Personal & Official Account |
|
| WeChat | ✅ | Personal & Official Account |
|
||||||
| Lark | ✅ | Official |
|
| Lark | ✅ | |
|
||||||
| DingTalk | ✅ | Official |
|
| DingTalk | ✅ | |
|
||||||
| KOOK | ✅ | Official |
|
| KOOK | ✅ | |
|
||||||
| 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 | ✅ |
|
||||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
| [xAI](https://x.ai/) | LLM | ✅ |
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
||||||
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
||||||
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
||||||
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
||||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
|
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
|
||||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
|
||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
|
||||||
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
||||||
| [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://link.langbot.app/en/docs/features)
|
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
@@ -135,23 +130,22 @@ docker compose up -d
|
|||||||
|
|
||||||
## 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 |
|
||||||
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
|
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
20
README_CN.md
@@ -47,8 +47,6 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
|||||||
|
|
||||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](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/)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -89,16 +87,13 @@ docker compose up -d
|
|||||||
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 个人微信、微信公众号 |
|
| 微信 | ✅ | 个人微信、微信公众号 |
|
||||||
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
||||||
| 飞书 | ✅ | 官方 |
|
| 飞书 | ✅ | |
|
||||||
| 钉钉 | ✅ | 官方 |
|
| 钉钉 | ✅ | |
|
||||||
| Satori | ✅ | |
|
| Discord | ✅ | |
|
||||||
| Discord | ✅ | 官方 |
|
| Telegram | ✅ | |
|
||||||
| Telegram | ✅ | 官方 |
|
| Slack | ✅ | |
|
||||||
| Slack | ✅ | 官方 |
|
| LINE | ✅ | |
|
||||||
| LINE | ✅ | 官方 |
|
| KOOK | ✅ | |
|
||||||
| KOOK | ✅ | 官方 |
|
|
||||||
| Email | ✅ | 只 Matrix、Satori |
|
|
||||||
| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,7 +124,6 @@ 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://link.langbot.app/zh/docs/features)
|
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
|||||||
21
README_ES.md
@@ -46,8 +46,6 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
|||||||
|
|
||||||
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
[→ 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/).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Inicio Rápido
|
## Inicio Rápido
|
||||||
@@ -85,19 +83,17 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plataforma | Estado | Notas |
|
| Plataforma | Estado | Notas |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | Oficial |
|
| Discord | ✅ | |
|
||||||
| Telegram | ✅ | Oficial |
|
| Telegram | ✅ | |
|
||||||
| Slack | ✅ | Oficial |
|
| Slack | ✅ | |
|
||||||
| LINE | ✅ | Oficial |
|
| LINE | ✅ | |
|
||||||
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) |
|
| QQ | ✅ | Personal y API Oficial |
|
||||||
| 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 | ✅ | Oficial |
|
| Lark | ✅ | |
|
||||||
| DingTalk | ✅ | Oficial |
|
| DingTalk | ✅ | |
|
||||||
| KOOK | ✅ | Oficial |
|
| KOOK | ✅ | |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
| Email | ✅ | Matrix, Satori |
|
|
||||||
| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,7 +122,6 @@ 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://link.langbot.app/en/docs/features)
|
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
21
README_FR.md
@@ -46,8 +46,6 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
|||||||
|
|
||||||
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
[→ 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/).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Démarrage Rapide
|
## Démarrage Rapide
|
||||||
@@ -85,19 +83,17 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plateforme | Statut | Notes |
|
| Plateforme | Statut | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | Officiel |
|
| Discord | ✅ | |
|
||||||
| Telegram | ✅ | Officiel |
|
| Telegram | ✅ | |
|
||||||
| Slack | ✅ | Officiel |
|
| Slack | ✅ | |
|
||||||
| LINE | ✅ | Officiel |
|
| LINE | ✅ | |
|
||||||
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) |
|
| QQ | ✅ | Personnel & API Officielle |
|
||||||
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
||||||
| WeChat | ✅ | Personnel & Compte Officiel |
|
| WeChat | ✅ | Personnel & Compte Officiel |
|
||||||
| Lark | ✅ | Officiel |
|
| Lark | ✅ | |
|
||||||
| DingTalk | ✅ | Officiel |
|
| DingTalk | ✅ | |
|
||||||
| KOOK | ✅ | Officiel |
|
| KOOK | ✅ | |
|
||||||
| 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. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,7 +122,6 @@ 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://link.langbot.app/en/docs/features)
|
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
23
README_JP.md
@@ -46,8 +46,6 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
|||||||
|
|
||||||
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
[→ すべての機能について詳しく見る](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/)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## クイックスタート
|
## クイックスタート
|
||||||
@@ -85,19 +83,17 @@ docker compose up -d
|
|||||||
|
|
||||||
| プラットフォーム | ステータス | 備考 |
|
| プラットフォーム | ステータス | 備考 |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | 公式 |
|
| Discord | ✅ | |
|
||||||
| Telegram | ✅ | 公式 |
|
| Telegram | ✅ | |
|
||||||
| Slack | ✅ | 公式 |
|
| Slack | ✅ | |
|
||||||
| LINE | ✅ | 公式 |
|
| LINE | ✅ | |
|
||||||
| QQ | ✅ | 個人・公式API(チャンネル・DM・グループ) |
|
| 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 など複数のブリッジ先プラットフォームに対応 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,7 +122,6 @@ 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://link.langbot.app/en/docs/features)
|
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
21
README_KO.md
@@ -46,8 +46,6 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
|||||||
|
|
||||||
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 기능 자세히 보기](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/).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
@@ -85,19 +83,17 @@ docker compose up -d
|
|||||||
|
|
||||||
| 플랫폼 | 상태 | 비고 |
|
| 플랫폼 | 상태 | 비고 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| Discord | ✅ | 공식 |
|
| Discord | ✅ | |
|
||||||
| Telegram | ✅ | 공식 |
|
| Telegram | ✅ | |
|
||||||
| Slack | ✅ | 공식 |
|
| Slack | ✅ | |
|
||||||
| LINE | ✅ | 공식 |
|
| LINE | ✅ | |
|
||||||
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) |
|
| QQ | ✅ | 개인 및 공식 API |
|
||||||
| 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 등 여러 브리지 플랫폼 지원 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,7 +122,6 @@ 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://link.langbot.app/en/docs/features)
|
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
21
README_RU.md
@@ -46,8 +46,6 @@ LangBot — это **платформа с открытым исходным к
|
|||||||
|
|
||||||
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
[→ Подробнее обо всех возможностях](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/).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
@@ -85,19 +83,17 @@ 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 и другие |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,7 +122,6 @@ 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://link.langbot.app/en/docs/features)
|
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
21
README_TW.md
@@ -48,8 +48,6 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
|||||||
|
|
||||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](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/)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速開始
|
## 快速開始
|
||||||
@@ -87,19 +85,17 @@ docker compose up -d
|
|||||||
|
|
||||||
| 平台 | 狀態 | 備註 |
|
| 平台 | 狀態 | 備註 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Discord | ✅ | 官方 |
|
|
||||||
| Telegram | ✅ | 官方 |
|
|
||||||
| Slack | ✅ | 官方 |
|
|
||||||
| LINE | ✅ | 官方 |
|
|
||||||
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
||||||
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
|
||||||
| 微信 | ✅ | 個人微信、微信公眾號 |
|
| 微信 | ✅ | 個人微信、微信公眾號 |
|
||||||
| 飛書 | ✅ | 官方 |
|
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
||||||
| 釘釘 | ✅ | 官方 |
|
| 飛書 | ✅ | |
|
||||||
| KOOK | ✅ | 官方 |
|
| 釘釘 | ✅ | |
|
||||||
|
| Discord | ✅ | |
|
||||||
|
| Telegram | ✅ | |
|
||||||
|
| Slack | ✅ | |
|
||||||
|
| LINE | ✅ | |
|
||||||
|
| KOOK | ✅ | |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
| Email | ✅ | 只 Matrix、Satori |
|
|
||||||
| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -128,7 +124,6 @@ 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(語音合成)
|
||||||
|
|
||||||
|
|||||||
21
README_VI.md
@@ -46,8 +46,6 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
|||||||
|
|
||||||
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
[→ 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/).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bắt đầu nhanh
|
## Bắt đầu nhanh
|
||||||
@@ -85,19 +83,17 @@ 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 | ✅ | Chính thức |
|
| Discord | ✅ | |
|
||||||
| Telegram | ✅ | Chính thức |
|
| Telegram | ✅ | |
|
||||||
| Slack | ✅ | Chính thức |
|
| Slack | ✅ | |
|
||||||
| LINE | ✅ | Chính thức |
|
| LINE | ✅ | |
|
||||||
| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) |
|
| QQ | ✅ | Cá nhân & API chính thức |
|
||||||
| 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 | ✅ | Chính thức |
|
| Lark | ✅ | |
|
||||||
| DingTalk | ✅ | Chính thức |
|
| DingTalk | ✅ | |
|
||||||
| KOOK | ✅ | Chính thức |
|
| KOOK | ✅ | |
|
||||||
| 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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,7 +122,6 @@ 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://link.langbot.app/en/docs/features)
|
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
|||||||
163
compare_nodes.py
@@ -1,163 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Compare YAML node definitions with frontend node-configs."""
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import json
|
|
||||||
|
|
||||||
# 1. Parse YAML files
|
|
||||||
yaml_dir = 'src/langbot/templates/metadata/nodes'
|
|
||||||
yaml_nodes = {}
|
|
||||||
|
|
||||||
for filename in sorted(os.listdir(yaml_dir)):
|
|
||||||
if filename.endswith('.yaml'):
|
|
||||||
filepath = os.path.join(yaml_dir, filename)
|
|
||||||
with open(filepath, 'r') as f:
|
|
||||||
data = yaml.safe_load(f)
|
|
||||||
node_name = data.get('name', filename.replace('.yaml', ''))
|
|
||||||
yaml_nodes[node_name] = {
|
|
||||||
'category': data.get('category', ''),
|
|
||||||
'inputs': [i['name'] for i in data.get('inputs', [])],
|
|
||||||
'outputs': [o['name'] for o in data.get('outputs', [])],
|
|
||||||
'config': [c['name'] for c in data.get('config', [])]
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Parse frontend node-configs TypeScript files
|
|
||||||
node_configs_dir = 'web/src/app/home/workflows/components/workflow-editor/node-configs'
|
|
||||||
|
|
||||||
frontend_nodes = {}
|
|
||||||
|
|
||||||
def parse_ts_file(filepath):
|
|
||||||
"""Parse a TypeScript file to extract node configurations."""
|
|
||||||
with open(filepath, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Find all node type definitions
|
|
||||||
# Pattern: nodeType: 'xxx'
|
|
||||||
node_type_pattern = r"nodeType:\s*'([^']+)'"
|
|
||||||
node_types = re.findall(node_type_pattern, content)
|
|
||||||
|
|
||||||
# For each node type, extract inputs, outputs, and config
|
|
||||||
for node_type in node_types:
|
|
||||||
# Find the config object for this node type
|
|
||||||
# Look for the section between this nodeType and the next one or end of object
|
|
||||||
pattern = rf"nodeType:\s*'({re.escape(node_type)})'.*?(?=nodeType:|export\s+(const|function)|$)"
|
|
||||||
match = re.search(pattern, content, re.DOTALL)
|
|
||||||
|
|
||||||
if match:
|
|
||||||
section = match.group(0)
|
|
||||||
|
|
||||||
# Extract inputs
|
|
||||||
inputs = re.findall(r"createInput\('([^']+)'", section)
|
|
||||||
|
|
||||||
# Extract outputs
|
|
||||||
outputs = re.findall(r"createOutput\('([^']+)'", section)
|
|
||||||
|
|
||||||
# Extract config names
|
|
||||||
config_names = re.findall(r"name:\s*'([^']+)'", section)
|
|
||||||
# Remove duplicates while preserving order
|
|
||||||
seen = set()
|
|
||||||
unique_config = []
|
|
||||||
for c in config_names:
|
|
||||||
if c not in seen:
|
|
||||||
seen.add(c)
|
|
||||||
unique_config.append(c)
|
|
||||||
|
|
||||||
frontend_nodes[node_type] = {
|
|
||||||
'inputs': inputs,
|
|
||||||
'outputs': outputs,
|
|
||||||
'config': unique_config
|
|
||||||
}
|
|
||||||
|
|
||||||
# Parse all config files
|
|
||||||
for filename in os.listdir(node_configs_dir):
|
|
||||||
if filename.endswith('.ts') and filename != 'types.ts' and filename != 'index.ts':
|
|
||||||
filepath = os.path.join(node_configs_dir, filename)
|
|
||||||
parse_ts_file(filepath)
|
|
||||||
|
|
||||||
# 3. Compare and report differences
|
|
||||||
print("=" * 80)
|
|
||||||
print("WORKFLOW NODE COMPARISON REPORT: YAML vs Frontend")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
all_node_types = sorted(set(list(yaml_nodes.keys()) + list(frontend_nodes.keys())))
|
|
||||||
|
|
||||||
discrepancies = []
|
|
||||||
|
|
||||||
for node_type in all_node_types:
|
|
||||||
yaml_def = yaml_nodes.get(node_type)
|
|
||||||
frontend_def = frontend_nodes.get(node_type)
|
|
||||||
|
|
||||||
node_discrepancies = []
|
|
||||||
|
|
||||||
if not yaml_def:
|
|
||||||
print(f"\n⚠️ {node_type}: ONLY in frontend (not in YAML)")
|
|
||||||
continue
|
|
||||||
if not frontend_def:
|
|
||||||
print(f"\n⚠️ {node_type}: ONLY in YAML (not in frontend)")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Compare inputs
|
|
||||||
yaml_inputs = set(yaml_def['inputs'])
|
|
||||||
frontend_inputs = set(frontend_def['inputs'])
|
|
||||||
if yaml_inputs != frontend_inputs:
|
|
||||||
only_yaml = yaml_inputs - frontend_inputs
|
|
||||||
only_frontend = frontend_inputs - yaml_inputs
|
|
||||||
node_discrepancies.append({
|
|
||||||
'type': 'inputs',
|
|
||||||
'only_yaml': list(only_yaml),
|
|
||||||
'only_frontend': list(only_frontend)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Compare outputs
|
|
||||||
yaml_outputs = set(yaml_def['outputs'])
|
|
||||||
frontend_outputs = set(frontend_def['outputs'])
|
|
||||||
if yaml_outputs != frontend_outputs:
|
|
||||||
only_yaml = yaml_outputs - frontend_outputs
|
|
||||||
only_frontend = frontend_outputs - yaml_outputs
|
|
||||||
node_discrepancies.append({
|
|
||||||
'type': 'outputs',
|
|
||||||
'only_yaml': list(only_yaml),
|
|
||||||
'only_frontend': list(only_frontend)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Compare config
|
|
||||||
yaml_config = set(yaml_def['config'])
|
|
||||||
frontend_config = set(frontend_def['config'])
|
|
||||||
if yaml_config != frontend_config:
|
|
||||||
only_yaml = yaml_config - frontend_config
|
|
||||||
only_frontend = frontend_config - yaml_config
|
|
||||||
node_discrepancies.append({
|
|
||||||
'type': 'config',
|
|
||||||
'only_yaml': list(only_yaml),
|
|
||||||
'only_frontend': list(only_frontend)
|
|
||||||
})
|
|
||||||
|
|
||||||
if node_discrepancies:
|
|
||||||
print(f"\n❌ {node_type} ({yaml_def['category']}): HAS DISCREPANCIES")
|
|
||||||
for d in node_discrepancies:
|
|
||||||
print(f" {d['type']}:")
|
|
||||||
if d['only_yaml']:
|
|
||||||
print(f" Only in YAML: {d['only_yaml']}")
|
|
||||||
if d['only_frontend']:
|
|
||||||
print(f" Only in Frontend: {d['only_frontend']}")
|
|
||||||
discrepancies.append((node_type, node_discrepancies))
|
|
||||||
else:
|
|
||||||
print(f"\n✅ {node_type} ({yaml_def['category']}): OK")
|
|
||||||
|
|
||||||
print(f"\n{'=' * 80}")
|
|
||||||
print(f"SUMMARY: {len(discrepancies)} nodes with discrepancies out of {len(all_node_types)} total")
|
|
||||||
print(f"{'=' * 80}")
|
|
||||||
|
|
||||||
# Output as JSON for further processing
|
|
||||||
output = {
|
|
||||||
'yaml_nodes': {k: v for k, v in yaml_nodes.items()},
|
|
||||||
'frontend_nodes': {k: v for k, v in frontend_nodes.items()},
|
|
||||||
'discrepancies': {k: v for k, v in discrepancies}
|
|
||||||
}
|
|
||||||
|
|
||||||
with open('node_comparison.json', 'w') as f:
|
|
||||||
json.dump(output, f, indent=2)
|
|
||||||
|
|
||||||
print(f"\nDetailed comparison saved to node_comparison.json")
|
|
||||||
@@ -1,713 +0,0 @@
|
|||||||
# Workflow 系统开发者文档
|
|
||||||
|
|
||||||
本文档面向 LangBot 开发者,详细介绍 Workflow 系统的技术架构、核心组件和扩展方法。
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
- [系统架构概述](#系统架构概述)
|
|
||||||
- [目录结构](#目录结构)
|
|
||||||
- [核心组件](#核心组件)
|
|
||||||
- [后端模块](#后端模块)
|
|
||||||
- [前端组件](#前端组件)
|
|
||||||
- [数据库表结构](#数据库表结构)
|
|
||||||
- [API 接口文档](#api-接口文档)
|
|
||||||
- [如何添加新节点类型](#如何添加新节点类型)
|
|
||||||
- [调试功能实现](#调试功能实现)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 系统架构概述
|
|
||||||
|
|
||||||
Workflow 系统采用前后端分离架构,主要包含以下层次:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ 前端层 (React) │
|
|
||||||
│ ┌─────────────┬──────────────┬──────────────┬───────────┐ │
|
|
||||||
│ │ 可视化编辑器 │ 节点面板 │ 属性面板 │ 调试器 │ │
|
|
||||||
│ │ ReactFlow │ NodePalette │ PropertyPanel│ Debugger │ │
|
|
||||||
│ └─────────────┴──────────────┴──────────────┴───────────┘ │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ API 层 (Quart) │
|
|
||||||
│ ┌─────────────┬──────────────┬──────────────────────────┐ │
|
|
||||||
│ │ Workflow API│ Debug API │ Node Types API │ │
|
|
||||||
│ └─────────────┴──────────────┴──────────────────────────┘ │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ 核心引擎层 (Python) │
|
|
||||||
│ ┌─────────────┬──────────────┬──────────────┬───────────┐ │
|
|
||||||
│ │ Executor │ Registry │ Node │ Entities │ │
|
|
||||||
│ │ 执行引擎 │ 节点注册表 │ 节点基类 │ 数据结构 │ │
|
|
||||||
│ └─────────────┴──────────────┴──────────────┴───────────┘ │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ 存储层 (SQLAlchemy) │
|
|
||||||
│ ┌─────────────┬──────────────┬──────────────────────────┐ │
|
|
||||||
│ │ Workflow │ Executions │ Triggers │ │
|
|
||||||
│ └─────────────┴──────────────┴──────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
### 后端代码结构
|
|
||||||
|
|
||||||
```
|
|
||||||
LangBot/src/langbot/pkg/
|
|
||||||
├── workflow/ # Workflow 核心模块
|
|
||||||
│ ├── __init__.py # 模块初始化,导出公共接口
|
|
||||||
│ ├── entities.py # 数据实体定义
|
|
||||||
│ ├── executor.py # 执行引擎
|
|
||||||
│ ├── node.py # 节点基类和装饰器
|
|
||||||
│ ├── registry.py # 节点类型注册表
|
|
||||||
│ └── nodes/ # 内置节点实现
|
|
||||||
│ ├── __init__.py # 注册所有内置节点
|
|
||||||
│ ├── trigger.py # 触发节点
|
|
||||||
│ ├── process.py # 处理节点
|
|
||||||
│ ├── control.py # 控制节点
|
|
||||||
│ └── action.py # 动作节点
|
|
||||||
├── entity/persistence/
|
|
||||||
│ └── workflow.py # 数据库模型
|
|
||||||
├── api/http/
|
|
||||||
│ ├── controller/groups/workflows/
|
|
||||||
│ │ └── workflows.py # API 路由控制器
|
|
||||||
│ └── service/
|
|
||||||
│ └── workflow.py # 业务逻辑服务
|
|
||||||
└── persistence/migrations/
|
|
||||||
└── dbm026_workflow_tables.py # 数据库迁移
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端代码结构
|
|
||||||
|
|
||||||
```
|
|
||||||
LangBot/web/src/app/home/workflows/
|
|
||||||
├── page.tsx # Workflow 列表页
|
|
||||||
├── WorkflowDetailContent.tsx # 详情页内容
|
|
||||||
├── store/
|
|
||||||
│ └── useWorkflowStore.ts # Zustand 状态管理
|
|
||||||
└── components/
|
|
||||||
├── workflow-editor/ # 可视化编辑器
|
|
||||||
│ ├── index.ts # 导出
|
|
||||||
│ ├── WorkflowEditorComponent.tsx # 主编辑器组件
|
|
||||||
│ ├── WorkflowNodeComponent.tsx # 自定义节点组件
|
|
||||||
│ ├── NodePalette.tsx # 节点面板
|
|
||||||
│ ├── PropertyPanel.tsx # 属性面板
|
|
||||||
│ └── node-configs/ # 节点配置元数据
|
|
||||||
│ ├── types.ts # 配置类型定义
|
|
||||||
│ ├── trigger-configs.ts
|
|
||||||
│ ├── ai-configs.ts
|
|
||||||
│ ├── process-configs.ts
|
|
||||||
│ ├── control-configs.ts
|
|
||||||
│ ├── action-configs.ts
|
|
||||||
│ ├── integration-configs.ts
|
|
||||||
│ └── index.ts # 配置汇总
|
|
||||||
├── workflow-debugger/ # 调试器组件
|
|
||||||
│ ├── index.ts
|
|
||||||
│ └── WorkflowDebugger.tsx
|
|
||||||
├── workflow-form/ # 表单组件
|
|
||||||
│ └── WorkflowFormComponent.tsx
|
|
||||||
└── workflow-executions/ # 执行历史组件
|
|
||||||
└── WorkflowExecutionsTab.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核心组件
|
|
||||||
|
|
||||||
### 后端模块
|
|
||||||
|
|
||||||
#### 1. 执行引擎 (WorkflowExecutor)
|
|
||||||
|
|
||||||
位置:[`executor.py`](../../src/langbot/pkg/workflow/executor.py)
|
|
||||||
|
|
||||||
执行引擎负责工作流的实际执行,包括:
|
|
||||||
|
|
||||||
- **拓扑排序**:确定节点执行顺序
|
|
||||||
- **节点执行**:调用各节点的 execute 方法
|
|
||||||
- **控制流处理**:处理条件分支、循环、并行执行
|
|
||||||
- **错误处理**:支持重试机制
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WorkflowExecutor:
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
workflow: WorkflowDefinition,
|
|
||||||
context: ExecutionContext,
|
|
||||||
start_node_id: Optional[str] = None
|
|
||||||
) -> ExecutionContext:
|
|
||||||
"""执行工作流"""
|
|
||||||
# 1. 构建执行图
|
|
||||||
# 2. 初始化节点状态
|
|
||||||
# 3. 找到起始节点
|
|
||||||
# 4. 按拓扑顺序执行
|
|
||||||
```
|
|
||||||
|
|
||||||
**调试执行器 (DebugWorkflowExecutor)**
|
|
||||||
|
|
||||||
继承自 WorkflowExecutor,增加了调试支持:
|
|
||||||
|
|
||||||
- 断点支持
|
|
||||||
- 单步执行
|
|
||||||
- 暂停/继续
|
|
||||||
- 实时日志
|
|
||||||
|
|
||||||
```python
|
|
||||||
class DebugWorkflowExecutor(WorkflowExecutor):
|
|
||||||
async def execute_debug(
|
|
||||||
self,
|
|
||||||
workflow: WorkflowDefinition,
|
|
||||||
context: ExecutionContext,
|
|
||||||
debug_state: DebugExecutionState,
|
|
||||||
) -> ExecutionContext:
|
|
||||||
"""调试模式执行"""
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 节点注册表 (NodeTypeRegistry)
|
|
||||||
|
|
||||||
位置:[`registry.py`](../../src/langbot/pkg/workflow/registry.py)
|
|
||||||
|
|
||||||
单例模式管理所有节点类型:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class NodeTypeRegistry:
|
|
||||||
_instance: Optional['NodeTypeRegistry'] = None
|
|
||||||
|
|
||||||
def register(self, node_type: str, node_class: type[WorkflowNode]):
|
|
||||||
"""注册节点类型"""
|
|
||||||
|
|
||||||
def create_instance(self, node_type: str, node_id: str, config: dict) -> WorkflowNode:
|
|
||||||
"""创建节点实例"""
|
|
||||||
|
|
||||||
def list_all(self) -> list[dict]:
|
|
||||||
"""获取所有节点类型的 Schema"""
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 节点基类 (WorkflowNode)
|
|
||||||
|
|
||||||
位置:[`node.py`](../../src/langbot/pkg/workflow/node.py)
|
|
||||||
|
|
||||||
所有节点必须继承此基类:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WorkflowNode(abc.ABC):
|
|
||||||
# 节点元数据
|
|
||||||
type_name: str = ""
|
|
||||||
name: str = ""
|
|
||||||
description: str = ""
|
|
||||||
category: str = "misc"
|
|
||||||
icon: str = ""
|
|
||||||
|
|
||||||
# 端口定义
|
|
||||||
inputs: list[NodePort] = []
|
|
||||||
outputs: list[NodePort] = []
|
|
||||||
|
|
||||||
# 配置 Schema
|
|
||||||
config_schema: list[NodeConfig] = []
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
inputs: dict[str, Any],
|
|
||||||
context: ExecutionContext
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""执行节点逻辑"""
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. 数据实体 (entities.py)
|
|
||||||
|
|
||||||
主要数据结构:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WorkflowDefinition:
|
|
||||||
"""工作流定义"""
|
|
||||||
uuid: str
|
|
||||||
name: str
|
|
||||||
nodes: list[NodeDefinition]
|
|
||||||
edges: list[EdgeDefinition]
|
|
||||||
settings: WorkflowSettings
|
|
||||||
|
|
||||||
class ExecutionContext:
|
|
||||||
"""执行上下文"""
|
|
||||||
execution_id: str
|
|
||||||
workflow_id: str
|
|
||||||
status: ExecutionStatus
|
|
||||||
variables: dict
|
|
||||||
node_states: dict[str, NodeState]
|
|
||||||
history: list[ExecutionStep]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端组件
|
|
||||||
|
|
||||||
#### 1. WorkflowEditorComponent
|
|
||||||
|
|
||||||
主编辑器组件,基于 React Flow 实现:
|
|
||||||
|
|
||||||
- **画布交互**:拖拽、缩放、平移
|
|
||||||
- **节点连接**:自动验证端口类型
|
|
||||||
- **撤销/重做**:基于历史记录栈
|
|
||||||
- **复制/粘贴**:支持多选复制
|
|
||||||
|
|
||||||
关键功能:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
function WorkflowEditorInner() {
|
|
||||||
const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useWorkflowStore();
|
|
||||||
|
|
||||||
// 拖放添加节点
|
|
||||||
const onDrop = useCallback((event: React.DragEvent) => {
|
|
||||||
const type = event.dataTransfer.getData('application/reactflow');
|
|
||||||
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
||||||
addNode(type, position);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 复制粘贴
|
|
||||||
const handleCopy = useCallback(() => { ... }, []);
|
|
||||||
const handlePaste = useCallback(() => { ... }, []);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. NodePalette
|
|
||||||
|
|
||||||
节点面板组件,展示可用节点类型:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
function NodePalette() {
|
|
||||||
// 按类别组织节点
|
|
||||||
const categories = [
|
|
||||||
{ id: 'trigger', name: '触发节点', icon: Zap },
|
|
||||||
{ id: 'ai', name: 'AI 节点', icon: Brain },
|
|
||||||
{ id: 'process', name: '处理节点', icon: Cpu },
|
|
||||||
{ id: 'control', name: '控制节点', icon: GitBranch },
|
|
||||||
{ id: 'action', name: '动作节点', icon: Send },
|
|
||||||
{ id: 'integration', name: '集成节点', icon: Plug },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 拖拽开始
|
|
||||||
const onDragStart = (event: React.DragEvent, nodeType: string) => {
|
|
||||||
event.dataTransfer.setData('application/reactflow', nodeType);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. PropertyPanel
|
|
||||||
|
|
||||||
属性面板组件,动态渲染节点配置表单:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
function PropertyPanel() {
|
|
||||||
const { selectedNodeId, nodes, updateNodeData } = useWorkflowStore();
|
|
||||||
|
|
||||||
// 根据节点类型获取配置元数据
|
|
||||||
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
|
||||||
const nodeConfig = getNodeConfig(selectedNode?.data?.nodeType);
|
|
||||||
|
|
||||||
// 动态渲染配置字段
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{nodeConfig?.fields.map(field => (
|
|
||||||
<ConfigField key={field.name} field={field} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. WorkflowDebugger
|
|
||||||
|
|
||||||
调试器组件,支持实时调试:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
function WorkflowDebugger({ workflowUuid, workflow }) {
|
|
||||||
const [debugState, setDebugState] = useState<DebugState>('idle');
|
|
||||||
const [executionId, setExecutionId] = useState<string>('');
|
|
||||||
const [logs, setLogs] = useState<ExecutionLog[]>([]);
|
|
||||||
|
|
||||||
// 启动调试
|
|
||||||
const startDebug = async () => {
|
|
||||||
const result = await backendClient.post(
|
|
||||||
`/api/v1/workflows/${workflowUuid}/debug/start`,
|
|
||||||
{ context, variables, breakpoints }
|
|
||||||
);
|
|
||||||
setExecutionId(result.execution_id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 轮询状态
|
|
||||||
useEffect(() => {
|
|
||||||
if (debugState === 'running') {
|
|
||||||
const interval = setInterval(fetchState, 500);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [debugState]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. useWorkflowStore
|
|
||||||
|
|
||||||
Zustand 状态管理:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface WorkflowState {
|
|
||||||
nodes: WorkflowNode[];
|
|
||||||
edges: WorkflowEdge[];
|
|
||||||
selectedNodeId: string | null;
|
|
||||||
history: HistoryEntry[];
|
|
||||||
historyIndex: number;
|
|
||||||
isDirty: boolean;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
addNode: (type: string, position: XYPosition) => void;
|
|
||||||
updateNodeData: (nodeId: string, data: Partial<NodeData>) => void;
|
|
||||||
deleteNode: (nodeId: string) => void;
|
|
||||||
undo: () => void;
|
|
||||||
redo: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
|
||||||
// ... state and actions
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 数据库表结构
|
|
||||||
|
|
||||||
### workflows 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE workflows (
|
|
||||||
uuid VARCHAR(255) PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
emoji VARCHAR(10) DEFAULT '🔄',
|
|
||||||
version INTEGER DEFAULT 1,
|
|
||||||
is_enabled BOOLEAN DEFAULT TRUE,
|
|
||||||
definition JSON NOT NULL, -- 节点和边定义
|
|
||||||
global_config JSON DEFAULT '{}', -- 全局配置
|
|
||||||
extensions_preferences JSON, -- 插件和 MCP 配置
|
|
||||||
created_at TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### workflow_versions 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE workflow_versions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
workflow_uuid VARCHAR(255) NOT NULL,
|
|
||||||
version INTEGER NOT NULL,
|
|
||||||
definition JSON NOT NULL,
|
|
||||||
global_config JSON DEFAULT '{}',
|
|
||||||
created_at TIMESTAMP,
|
|
||||||
created_by VARCHAR(255),
|
|
||||||
UNIQUE(workflow_uuid, version)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### workflow_executions 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE workflow_executions (
|
|
||||||
uuid VARCHAR(255) PRIMARY KEY,
|
|
||||||
workflow_uuid VARCHAR(255) NOT NULL,
|
|
||||||
workflow_version INTEGER NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL, -- pending/running/completed/failed/cancelled
|
|
||||||
trigger_type VARCHAR(50),
|
|
||||||
trigger_data JSON,
|
|
||||||
variables JSON,
|
|
||||||
start_time TIMESTAMP,
|
|
||||||
end_time TIMESTAMP,
|
|
||||||
error TEXT,
|
|
||||||
created_at TIMESTAMP
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### workflow_node_executions 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE workflow_node_executions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
execution_uuid VARCHAR(255) NOT NULL,
|
|
||||||
node_id VARCHAR(100) NOT NULL,
|
|
||||||
node_type VARCHAR(50) NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL,
|
|
||||||
inputs JSON,
|
|
||||||
outputs JSON,
|
|
||||||
start_time TIMESTAMP,
|
|
||||||
end_time TIMESTAMP,
|
|
||||||
error TEXT,
|
|
||||||
retry_count INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### workflow_triggers 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE workflow_triggers (
|
|
||||||
uuid VARCHAR(255) PRIMARY KEY,
|
|
||||||
workflow_uuid VARCHAR(255) NOT NULL,
|
|
||||||
type VARCHAR(50) NOT NULL, -- message/cron/event/webhook
|
|
||||||
config JSON NOT NULL,
|
|
||||||
is_enabled BOOLEAN DEFAULT TRUE,
|
|
||||||
priority INTEGER DEFAULT 0,
|
|
||||||
created_at TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API 接口文档
|
|
||||||
|
|
||||||
### Workflow CRUD
|
|
||||||
|
|
||||||
| 方法 | 路径 | 描述 |
|
|
||||||
|-----|------|------|
|
|
||||||
| GET | `/api/v1/workflows` | 获取工作流列表 |
|
|
||||||
| POST | `/api/v1/workflows` | 创建工作流 |
|
|
||||||
| GET | `/api/v1/workflows/:uuid` | 获取单个工作流 |
|
|
||||||
| PUT | `/api/v1/workflows/:uuid` | 更新工作流 |
|
|
||||||
| DELETE | `/api/v1/workflows/:uuid` | 删除工作流 |
|
|
||||||
| POST | `/api/v1/workflows/:uuid/copy` | 复制工作流 |
|
|
||||||
|
|
||||||
### 执行相关
|
|
||||||
|
|
||||||
| 方法 | 路径 | 描述 |
|
|
||||||
|-----|------|------|
|
|
||||||
| POST | `/api/v1/workflows/:uuid/execute` | 手动执行工作流 |
|
|
||||||
| GET | `/api/v1/workflows/:uuid/executions` | 获取执行记录 |
|
|
||||||
|
|
||||||
### 版本管理
|
|
||||||
|
|
||||||
| 方法 | 路径 | 描述 |
|
|
||||||
|-----|------|------|
|
|
||||||
| GET | `/api/v1/workflows/:uuid/versions` | 获取版本列表 |
|
|
||||||
| POST | `/api/v1/workflows/:uuid/rollback/:version` | 回滚到指定版本 |
|
|
||||||
|
|
||||||
### 调试 API
|
|
||||||
|
|
||||||
| 方法 | 路径 | 描述 |
|
|
||||||
|-----|------|------|
|
|
||||||
| POST | `/api/v1/workflows/:uuid/debug/start` | 启动调试 |
|
|
||||||
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/pause` | 暂停执行 |
|
|
||||||
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/resume` | 继续执行 |
|
|
||||||
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/stop` | 停止执行 |
|
|
||||||
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/step` | 单步执行 |
|
|
||||||
| GET | `/api/v1/workflows/:uuid/debug/:exec_id/state` | 获取调试状态 |
|
|
||||||
|
|
||||||
### 节点类型
|
|
||||||
|
|
||||||
| 方法 | 路径 | 描述 |
|
|
||||||
|-----|------|------|
|
|
||||||
| GET | `/api/v1/workflows/_/node-types` | 获取所有节点类型 |
|
|
||||||
| GET | `/api/v1/workflows/_/node-types/categories` | 按类别获取节点类型 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 如何添加新节点类型
|
|
||||||
|
|
||||||
### 步骤 1:创建节点类
|
|
||||||
|
|
||||||
在 `LangBot/src/langbot/pkg/workflow/nodes/` 下创建或修改文件:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from ..node import WorkflowNode, NodePort, NodeConfig, workflow_node
|
|
||||||
from ..entities import ExecutionContext
|
|
||||||
|
|
||||||
@workflow_node('my_custom_node')
|
|
||||||
class MyCustomNode(WorkflowNode):
|
|
||||||
"""自定义节点"""
|
|
||||||
|
|
||||||
# 元数据
|
|
||||||
type_name = 'my_custom_node'
|
|
||||||
name = '我的自定义节点'
|
|
||||||
description = '这是一个自定义节点'
|
|
||||||
category = 'process' # trigger/process/control/action/integration
|
|
||||||
icon = '🔧'
|
|
||||||
|
|
||||||
# 输入端口
|
|
||||||
inputs = [
|
|
||||||
NodePort(name='input', type='string', description='输入数据', required=True),
|
|
||||||
]
|
|
||||||
|
|
||||||
# 输出端口
|
|
||||||
outputs = [
|
|
||||||
NodePort(name='output', type='string', description='输出数据'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# 配置字段
|
|
||||||
config_schema = [
|
|
||||||
NodeConfig(
|
|
||||||
name='option',
|
|
||||||
type='select',
|
|
||||||
required=True,
|
|
||||||
options=['选项A', '选项B'],
|
|
||||||
description='选择一个选项'
|
|
||||||
),
|
|
||||||
NodeConfig(
|
|
||||||
name='value',
|
|
||||||
type='string',
|
|
||||||
required=False,
|
|
||||||
default='默认值',
|
|
||||||
description='配置值'
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
inputs: dict[str, Any],
|
|
||||||
context: ExecutionContext
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""执行节点逻辑"""
|
|
||||||
input_data = inputs.get('input', '')
|
|
||||||
option = self.get_config('option')
|
|
||||||
value = self.get_config('value', '')
|
|
||||||
|
|
||||||
# 处理逻辑
|
|
||||||
result = f"处理: {input_data} with {option} and {value}"
|
|
||||||
|
|
||||||
return {'output': result}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 2:注册节点
|
|
||||||
|
|
||||||
在 `LangBot/src/langbot/pkg/workflow/nodes/__init__.py` 中导入:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from .process import (
|
|
||||||
CodeExecutorNode,
|
|
||||||
HttpRequestNode,
|
|
||||||
DataTransformNode,
|
|
||||||
MyCustomNode, # 添加新节点
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 3:添加前端配置
|
|
||||||
|
|
||||||
在 `LangBot/web/src/app/home/workflows/components/workflow-editor/node-configs/` 目录下添加配置:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// process-configs.ts
|
|
||||||
export const processNodeConfigs: NodeConfigMap = {
|
|
||||||
// ... 其他配置
|
|
||||||
|
|
||||||
my_custom_node: {
|
|
||||||
type: 'my_custom_node',
|
|
||||||
label: 'workflows.nodes.myCustomNode',
|
|
||||||
description: 'workflows.nodes.myCustomNodeDesc',
|
|
||||||
icon: 'Wrench',
|
|
||||||
category: 'process',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'option',
|
|
||||||
type: 'select',
|
|
||||||
label: 'workflows.fields.option',
|
|
||||||
required: true,
|
|
||||||
options: [
|
|
||||||
{ value: '选项A', label: '选项 A' },
|
|
||||||
{ value: '选项B', label: '选项 B' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'value',
|
|
||||||
type: 'string',
|
|
||||||
label: 'workflows.fields.value',
|
|
||||||
required: false,
|
|
||||||
defaultValue: '默认值',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 4:添加国际化
|
|
||||||
|
|
||||||
在 `LangBot/web/src/i18n/locales/` 中添加翻译:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// zh-Hans.ts
|
|
||||||
workflows: {
|
|
||||||
nodes: {
|
|
||||||
myCustomNode: '我的自定义节点',
|
|
||||||
myCustomNodeDesc: '这是一个自定义节点',
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
option: '选项',
|
|
||||||
value: '值',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 调试功能实现
|
|
||||||
|
|
||||||
### 后端调试状态管理
|
|
||||||
|
|
||||||
```python
|
|
||||||
class DebugExecutionState:
|
|
||||||
"""调试执行状态"""
|
|
||||||
|
|
||||||
def __init__(self, execution_id: str, breakpoints: list[str] = None):
|
|
||||||
self.execution_id = execution_id
|
|
||||||
self.status: str = 'running'
|
|
||||||
self.is_paused: bool = False
|
|
||||||
self.is_stopped: bool = False
|
|
||||||
self.breakpoints: set[str] = set(breakpoints or [])
|
|
||||||
self.logs: list[ExecutionLog] = []
|
|
||||||
self._pause_event = asyncio.Event()
|
|
||||||
|
|
||||||
def pause(self):
|
|
||||||
"""暂停执行"""
|
|
||||||
self.is_paused = True
|
|
||||||
self._pause_event.clear()
|
|
||||||
|
|
||||||
def resume(self):
|
|
||||||
"""继续执行"""
|
|
||||||
self.is_paused = False
|
|
||||||
self._pause_event.set()
|
|
||||||
|
|
||||||
async def wait_if_paused(self):
|
|
||||||
"""如果暂停则等待"""
|
|
||||||
if self.is_paused:
|
|
||||||
await self._pause_event.wait()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端调试流程
|
|
||||||
|
|
||||||
1. **设置断点**:点击节点设置断点
|
|
||||||
2. **启动调试**:调用 `/debug/start` 启动调试执行
|
|
||||||
3. **轮询状态**:定期调用 `/debug/:id/state` 获取状态
|
|
||||||
4. **控制执行**:调用 pause/resume/step/stop 控制执行
|
|
||||||
5. **查看日志**:实时显示执行日志和节点状态
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 调试状态轮询
|
|
||||||
const fetchDebugState = async () => {
|
|
||||||
const state = await backendClient.get(
|
|
||||||
`/api/v1/workflows/${workflowUuid}/debug/${executionId}/state`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 更新节点状态
|
|
||||||
setNodeStates(state.node_states);
|
|
||||||
|
|
||||||
// 追加新日志
|
|
||||||
if (state.new_logs.length > 0) {
|
|
||||||
setLogs(prev => [...prev, ...state.new_logs]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查完成状态
|
|
||||||
if (state.status === 'completed' || state.status === 'error') {
|
|
||||||
setDebugState('idle');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 扩展阅读
|
|
||||||
|
|
||||||
- [Workflow 功能设计文档](../../../plans/langbot-workflow-design.md)
|
|
||||||
- [用户使用指南](../user-guide/workflow-guide.md)
|
|
||||||
- [API 认证文档](../API_KEY_AUTH.md)
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
# Workflow 用户指南
|
|
||||||
|
|
||||||
本文档帮助您了解和使用 LangBot 的 Workflow(工作流)功能,通过可视化方式构建自动化的对话处理流程。
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
- [功能介绍](#功能介绍)
|
|
||||||
- [快速入门](#快速入门)
|
|
||||||
- [节点类型说明](#节点类型说明)
|
|
||||||
- [编辑器使用指南](#编辑器使用指南)
|
|
||||||
- [调试功能](#调试功能)
|
|
||||||
- [常见问题解答](#常见问题解答)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 功能介绍
|
|
||||||
|
|
||||||
### 什么是 Workflow?
|
|
||||||
|
|
||||||
Workflow(工作流)是 LangBot 提供的可视化自动化编排系统。通过拖拽节点、连接边的方式,您可以:
|
|
||||||
|
|
||||||
- 📝 **构建复杂的对话流程**:使用条件分支、循环等控制节点
|
|
||||||
- 🤖 **调用 AI 能力**:集成 LLM、知识库检索、参数提取
|
|
||||||
- 🔗 **连接外部服务**:集成 Dify、n8n、Coze 等平台
|
|
||||||
- ⚡ **自动化任务执行**:消息触发、定时触发、Webhook 触发
|
|
||||||
|
|
||||||
### Workflow vs Pipeline
|
|
||||||
|
|
||||||
| 对比项 | Pipeline | Workflow |
|
|
||||||
|-------|----------|----------|
|
|
||||||
| 配置方式 | 表单配置 | 可视化拖拽 |
|
|
||||||
| 流程控制 | 线性执行 | 支持分支、循环、并行 |
|
|
||||||
| 适用场景 | 简单对话 | 复杂流程 |
|
|
||||||
| 学习曲线 | 低 | 中等 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 快速入门
|
|
||||||
|
|
||||||
### 第一步:创建 Workflow
|
|
||||||
|
|
||||||
1. 在侧边栏点击 **Workflow** 进入工作流列表
|
|
||||||
2. 点击右上角 **创建工作流** 按钮
|
|
||||||
3. 填写基本信息:
|
|
||||||
- **名称**:给工作流起一个描述性的名字
|
|
||||||
- **描述**:可选,说明工作流的用途
|
|
||||||
- **图标**:选择一个 emoji 作为标识
|
|
||||||
|
|
||||||
### 第二步:添加节点
|
|
||||||
|
|
||||||
进入编辑器后,左侧是节点面板,中间是画布区域,右侧是属性面板。
|
|
||||||
|
|
||||||
1. **添加触发节点**:从左侧面板拖拽一个"消息触发"节点到画布
|
|
||||||
2. **添加 AI 节点**:拖拽一个"LLM 调用"节点
|
|
||||||
3. **添加回复节点**:拖拽一个"回复消息"节点
|
|
||||||
|
|
||||||
### 第三步:连接节点
|
|
||||||
|
|
||||||
1. 将鼠标悬停在触发节点的输出端口(右侧小圆点)
|
|
||||||
2. 按住鼠标拖拽到 LLM 节点的输入端口(左侧小圆点)
|
|
||||||
3. 同样方式连接 LLM 节点和回复节点
|
|
||||||
|
|
||||||
```
|
|
||||||
[消息触发] ──▶ [LLM 调用] ──▶ [回复消息]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第四步:配置节点
|
|
||||||
|
|
||||||
点击 LLM 调用节点,在右侧属性面板配置:
|
|
||||||
|
|
||||||
- **运行方式**:选择"本地 Agent"
|
|
||||||
- **系统提示词**:描述 AI 的角色和行为
|
|
||||||
- **模型**:选择要使用的 LLM 模型
|
|
||||||
|
|
||||||
点击回复消息节点配置:
|
|
||||||
|
|
||||||
- **消息内容**:设置为 `{{nodes.llm_call.outputs.response}}`(引用 LLM 输出)
|
|
||||||
|
|
||||||
### 第五步:保存并绑定
|
|
||||||
|
|
||||||
1. 点击工具栏的 **保存** 按钮
|
|
||||||
2. 返回 Bot 配置页面
|
|
||||||
3. 在 Bot 的绑定设置中选择 **Workflow**,然后选择刚创建的工作流
|
|
||||||
|
|
||||||
恭喜!您已经创建了第一个 Workflow。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 节点类型说明
|
|
||||||
|
|
||||||
### 触发节点 (Trigger)
|
|
||||||
|
|
||||||
触发节点是工作流的入口,定义何时启动执行。
|
|
||||||
|
|
||||||
| 节点 | 说明 | 输出 |
|
|
||||||
|-----|------|------|
|
|
||||||
| 消息触发 | 收到消息时触发 | message, sender_id, platform |
|
|
||||||
| 定时触发 | 按 Cron 表达式定时触发 | timestamp |
|
|
||||||
| Webhook 触发 | 收到 HTTP 请求时触发 | request_body, headers |
|
|
||||||
| 事件触发 | 系统事件触发 | event_type, event_data |
|
|
||||||
|
|
||||||
**消息触发配置示例**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
触发条件:
|
|
||||||
- 关键词匹配: ["帮助", "help"]
|
|
||||||
- 平台: ["wechat", "qq"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### AI 节点
|
|
||||||
|
|
||||||
AI 节点用于调用各种 AI 能力。
|
|
||||||
|
|
||||||
| 节点 | 说明 | 典型用途 |
|
|
||||||
|-----|------|---------|
|
|
||||||
| LLM 调用 | 调用大语言模型 | 生成回复、理解意图 |
|
|
||||||
| 问题分类器 | 对用户问题分类 | 路由到不同处理分支 |
|
|
||||||
| 参数提取器 | 从文本提取结构化数据 | 提取订单号、日期等 |
|
|
||||||
| 知识库检索 | 查询知识库 | RAG 增强回复 |
|
|
||||||
|
|
||||||
**LLM 调用配置示例**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
运行方式: 本地 Agent
|
|
||||||
模型: gpt-4
|
|
||||||
系统提示词: |
|
|
||||||
你是一个友好的客服助手。
|
|
||||||
请根据用户的问题提供帮助。
|
|
||||||
温度: 0.7
|
|
||||||
最大 Token 数: 2000
|
|
||||||
```
|
|
||||||
|
|
||||||
### 处理节点 (Process)
|
|
||||||
|
|
||||||
处理节点用于数据处理和外部调用。
|
|
||||||
|
|
||||||
| 节点 | 说明 | 典型用途 |
|
|
||||||
|-----|------|---------|
|
|
||||||
| 代码执行 | 执行 Python/JavaScript 代码 | 数据处理、格式转换 |
|
|
||||||
| HTTP 请求 | 发送 HTTP 请求 | 调用外部 API |
|
|
||||||
| 数据转换 | JSON/模板转换 | 数据格式化 |
|
|
||||||
|
|
||||||
**HTTP 请求配置示例**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
URL: https://api.example.com/data
|
|
||||||
方法: POST
|
|
||||||
请求头:
|
|
||||||
Content-Type: application/json
|
|
||||||
Authorization: Bearer {{variables.api_key}}
|
|
||||||
请求体: |
|
|
||||||
{"query": "{{message.content}}"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 控制节点 (Control)
|
|
||||||
|
|
||||||
控制节点用于流程控制。
|
|
||||||
|
|
||||||
| 节点 | 说明 | 用途 |
|
|
||||||
|-----|------|------|
|
|
||||||
| 条件分支 | 二选一分支 | if-else 逻辑 |
|
|
||||||
| 多路分支 | 多选一分支 | switch-case 逻辑 |
|
|
||||||
| 循环 | 遍历数组 | 批量处理 |
|
|
||||||
| 并行 | 同时执行多分支 | 并发处理 |
|
|
||||||
| 等待 | 暂停执行 | 延时处理 |
|
|
||||||
| 合并 | 合并多个分支 | 汇总结果 |
|
|
||||||
|
|
||||||
**条件分支配置示例**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
条件表达式: "{{nodes.classifier.outputs.category}}" == "complaint"
|
|
||||||
真分支: 投诉处理
|
|
||||||
假分支: 普通咨询
|
|
||||||
```
|
|
||||||
|
|
||||||
### 动作节点 (Action)
|
|
||||||
|
|
||||||
动作节点执行具体操作。
|
|
||||||
|
|
||||||
| 节点 | 说明 | 用途 |
|
|
||||||
|-----|------|------|
|
|
||||||
| 发送消息 | 主动发送消息 | 通知、推送 |
|
|
||||||
| 回复消息 | 回复当前消息 | 对话回复 |
|
|
||||||
| 存储数据 | 保存数据到存储 | 持久化 |
|
|
||||||
| 调用 Pipeline | 调用现有 Pipeline | 复用现有流程 |
|
|
||||||
|
|
||||||
**回复消息配置示例**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
消息内容: |
|
|
||||||
感谢您的咨询!
|
|
||||||
|
|
||||||
{{nodes.llm_call.outputs.response}}
|
|
||||||
|
|
||||||
如有其他问题,随时联系我。
|
|
||||||
```
|
|
||||||
|
|
||||||
### 集成节点 (Integration)
|
|
||||||
|
|
||||||
集成节点连接外部平台。
|
|
||||||
|
|
||||||
| 节点 | 说明 | 平台 |
|
|
||||||
|-----|------|------|
|
|
||||||
| Dify 工作流 | 调用 Dify 应用 | Dify |
|
|
||||||
| Dify 知识库 | 查询 Dify 知识库 | Dify |
|
|
||||||
| n8n 工作流 | 调用 n8n 流程 | n8n |
|
|
||||||
| Langflow | 调用 Langflow 流程 | Langflow |
|
|
||||||
| Coze Bot | 调用扣子 Bot | Coze |
|
|
||||||
|
|
||||||
**Dify 工作流配置示例**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
API 地址: https://api.dify.ai/v1
|
|
||||||
API Key: sk-xxxxx
|
|
||||||
应用类型: workflow
|
|
||||||
同步对话历史: true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 编辑器使用指南
|
|
||||||
|
|
||||||
### 画布操作
|
|
||||||
|
|
||||||
| 操作 | 方式 |
|
|
||||||
|-----|------|
|
|
||||||
| 平移画布 | 按住鼠标中键/空格+左键 拖拽 |
|
|
||||||
| 缩放画布 | 鼠标滚轮 / 工具栏按钮 |
|
|
||||||
| 框选多个节点 | 按住 Shift + 拖拽框选 |
|
|
||||||
| 适应视图 | 点击工具栏"适应"按钮 |
|
|
||||||
|
|
||||||
### 节点操作
|
|
||||||
|
|
||||||
| 操作 | 方式 |
|
|
||||||
|-----|------|
|
|
||||||
| 添加节点 | 从左侧面板拖拽到画布 |
|
|
||||||
| 移动节点 | 点击节点拖拽 |
|
|
||||||
| 删除节点 | 选中后按 Delete / 点击工具栏删除 |
|
|
||||||
| 复制节点 | 选中后 Ctrl+C / 工具栏复制 |
|
|
||||||
| 粘贴节点 | Ctrl+V / 工具栏粘贴 |
|
|
||||||
|
|
||||||
### 连接操作
|
|
||||||
|
|
||||||
| 操作 | 方式 |
|
|
||||||
|-----|------|
|
|
||||||
| 创建连接 | 从输出端口拖拽到输入端口 |
|
|
||||||
| 删除连接 | 点击连接线后按 Delete |
|
|
||||||
| 选中连接 | 点击连接线 |
|
|
||||||
|
|
||||||
### 快捷键
|
|
||||||
|
|
||||||
| 快捷键 | 功能 |
|
|
||||||
|-------|------|
|
|
||||||
| Ctrl + Z | 撤销 |
|
|
||||||
| Ctrl + Shift + Z | 重做 |
|
|
||||||
| Ctrl + C | 复制 |
|
|
||||||
| Ctrl + V | 粘贴 |
|
|
||||||
| Delete | 删除选中 |
|
|
||||||
| Ctrl + S | 保存 |
|
|
||||||
|
|
||||||
### 工具栏功能
|
|
||||||
|
|
||||||
```
|
|
||||||
[撤销] [重做] | [放大] [缩小] [适应] | [复制] [粘贴] [删除] | [保存] [调试]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 调试功能
|
|
||||||
|
|
||||||
### 启动调试
|
|
||||||
|
|
||||||
1. 点击工具栏的 **调试** 按钮
|
|
||||||
2. 在调试面板中配置初始数据:
|
|
||||||
- **输入消息**:模拟用户发送的消息
|
|
||||||
- **会话 ID**:可选,用于测试会话变量
|
|
||||||
- **变量**:设置初始变量值
|
|
||||||
|
|
||||||
3. 点击 **开始调试** 按钮
|
|
||||||
|
|
||||||
### 调试控制
|
|
||||||
|
|
||||||
| 按钮 | 功能 |
|
|
||||||
|-----|------|
|
|
||||||
| ▶️ 开始/继续 | 开始或继续执行 |
|
|
||||||
| ⏸️ 暂停 | 暂停执行 |
|
|
||||||
| ⏹️ 停止 | 停止执行 |
|
|
||||||
| ⏭️ 单步 | 执行下一个节点 |
|
|
||||||
|
|
||||||
### 断点
|
|
||||||
|
|
||||||
- **设置断点**:点击节点上的断点图标
|
|
||||||
- **断点触发**:执行到断点时自动暂停
|
|
||||||
- **查看状态**:在暂停时查看节点的输入输出
|
|
||||||
|
|
||||||
### 执行日志
|
|
||||||
|
|
||||||
调试面板下方显示实时日志:
|
|
||||||
|
|
||||||
```
|
|
||||||
[INFO] 2024-01-15 10:30:00 - Starting debug execution
|
|
||||||
[INFO] 2024-01-15 10:30:00 - Executing node: message_trigger
|
|
||||||
[DEBUG] 2024-01-15 10:30:00 - Node inputs: {"message": "你好"}
|
|
||||||
[INFO] 2024-01-15 10:30:01 - Node completed in 50ms
|
|
||||||
[INFO] 2024-01-15 10:30:01 - Executing node: llm_call
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 节点状态颜色
|
|
||||||
|
|
||||||
| 颜色 | 状态 |
|
|
||||||
|-----|------|
|
|
||||||
| 灰色 | 待执行 |
|
|
||||||
| 蓝色 | 执行中 |
|
|
||||||
| 绿色 | 已完成 |
|
|
||||||
| 红色 | 失败 |
|
|
||||||
| 黄色 | 已跳过 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题解答
|
|
||||||
|
|
||||||
### Q1:如何在节点间传递数据?
|
|
||||||
|
|
||||||
使用表达式语法引用其他节点的输出:
|
|
||||||
|
|
||||||
```
|
|
||||||
{{nodes.节点ID.outputs.输出名称}}
|
|
||||||
```
|
|
||||||
|
|
||||||
例如:
|
|
||||||
- `{{nodes.llm_call.outputs.response}}` - 引用 LLM 节点的响应
|
|
||||||
- `{{nodes.http_request.outputs.body}}` - 引用 HTTP 请求的响应体
|
|
||||||
|
|
||||||
### Q2:如何使用变量?
|
|
||||||
|
|
||||||
Workflow 支持三种变量类型:
|
|
||||||
|
|
||||||
1. **工作流变量**:`{{variables.变量名}}`
|
|
||||||
2. **会话变量**:`{{conversation_variables.变量名}}`
|
|
||||||
3. **消息上下文**:`{{message.content}}`、`{{message.sender_id}}`
|
|
||||||
|
|
||||||
### Q3:条件分支如何写条件表达式?
|
|
||||||
|
|
||||||
支持以下运算符:
|
|
||||||
|
|
||||||
- 比较:`==`, `!=`, `>`, `<`, `>=`, `<=`
|
|
||||||
- 逻辑:`and`, `or`, `not`
|
|
||||||
- 包含:`in`
|
|
||||||
|
|
||||||
示例:
|
|
||||||
```python
|
|
||||||
# 字符串比较
|
|
||||||
"{{nodes.classifier.outputs.intent}}" == "purchase"
|
|
||||||
|
|
||||||
# 数值比较
|
|
||||||
{{nodes.extractor.outputs.amount}} > 1000
|
|
||||||
|
|
||||||
# 包含检查
|
|
||||||
"退款" in "{{message.content}}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q4:如何处理错误?
|
|
||||||
|
|
||||||
1. **节点级重试**:在节点配置中设置重试次数
|
|
||||||
2. **全局错误处理**:在 Workflow 设置中配置错误处理策略
|
|
||||||
3. **条件分支**:使用条件节点检查上一节点的状态
|
|
||||||
|
|
||||||
### Q5:如何查看执行历史?
|
|
||||||
|
|
||||||
1. 进入 Workflow 详情页
|
|
||||||
2. 点击 **执行历史** 标签
|
|
||||||
3. 查看每次执行的状态、耗时、输入输出
|
|
||||||
|
|
||||||
### Q6:Workflow 可以被多个 Bot 使用吗?
|
|
||||||
|
|
||||||
是的。一个 Workflow 可以被多个 Bot 绑定使用,但每个 Bot 只能绑定一个处理单元(Pipeline 或 Workflow)。
|
|
||||||
|
|
||||||
### Q7:如何复制现有的 Workflow?
|
|
||||||
|
|
||||||
在 Workflow 列表页,点击工作流卡片右上角的菜单,选择"复制"即可创建副本。
|
|
||||||
|
|
||||||
### Q8:支持版本回滚吗?
|
|
||||||
|
|
||||||
支持。每次保存都会创建新版本。在 Workflow 详情页可以查看版本历史并回滚到指定版本。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
### 1. 合理命名
|
|
||||||
|
|
||||||
- 为节点和 Workflow 使用描述性名称
|
|
||||||
- 使用统一的命名规范
|
|
||||||
|
|
||||||
### 2. 模块化设计
|
|
||||||
|
|
||||||
- 将复杂流程拆分为多个小 Workflow
|
|
||||||
- 使用"调用 Pipeline"节点复用现有流程
|
|
||||||
|
|
||||||
### 3. 错误处理
|
|
||||||
|
|
||||||
- 为关键节点设置重试机制
|
|
||||||
- 使用条件分支处理异常情况
|
|
||||||
- 添加日志记录便于排查问题
|
|
||||||
|
|
||||||
### 4. 测试先行
|
|
||||||
|
|
||||||
- 使用调试功能充分测试
|
|
||||||
- 准备多种测试场景
|
|
||||||
- 检查边界情况
|
|
||||||
|
|
||||||
### 5. 性能优化
|
|
||||||
|
|
||||||
- 避免不必要的节点
|
|
||||||
- 使用并行节点提高效率
|
|
||||||
- 合理设置超时时间
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 更多资源
|
|
||||||
|
|
||||||
- [开发者文档](../development/workflow-system.md)
|
|
||||||
- [设计文档](../../../plans/langbot-workflow-design.md)
|
|
||||||
- [API 文档](../service-api-openapi.json)
|
|
||||||
1468
node_comparison.json
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.7"
|
version = "4.9.6"
|
||||||
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"]
|
||||||
@@ -22,7 +22,7 @@ dependencies = [
|
|||||||
"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.5.5",
|
"lark-oapi>=1.4.15",
|
||||||
"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",
|
||||||
@@ -35,7 +35,6 @@ dependencies = [
|
|||||||
"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.32.3",
|
||||||
@@ -70,10 +69,9 @@ dependencies = [
|
|||||||
"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 @ file:///home/typer/Desktop/langbot-plugin-sdk",
|
"langbot-plugin==0.3.8",
|
||||||
"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",
|
||||||
@@ -122,7 +120,6 @@ package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"moto>=5.2.1",
|
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.0.0",
|
"pytest-asyncio>=1.0.0",
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ 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
|
||||||
|
|
||||||
@@ -25,9 +22,7 @@ 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]
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/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 ==="
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#!/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.7'
|
__version__ = '4.9.6'
|
||||||
|
|||||||
@@ -481,12 +481,6 @@ 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
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
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, Optional
|
from typing import Callable, Dict, Any
|
||||||
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
|
||||||
@@ -34,8 +32,6 @@ 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是否存在"""
|
||||||
@@ -54,18 +50,18 @@ class QQOfficialClient:
|
|||||||
headers = {
|
headers = {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
}
|
}
|
||||||
response = await client.post(url, json=params, headers=headers)
|
try:
|
||||||
if response.status_code != 200:
|
response = await client.post(url, json=params, headers=headers)
|
||||||
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
|
if response.status_code == 200:
|
||||||
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
|
||||||
await self.logger.info(f'access_token obtained, expires_in={expires_in}s')
|
except Exception as e:
|
||||||
else:
|
await self.logger.error(f'获取access_token失败: {response_data}')
|
||||||
raise Exception('Failed to get access_token: no access_token in response')
|
raise Exception(f'获取access_token失败: {e}')
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""处理回调请求(独立端口模式,使用全局 request)"""
|
"""处理回调请求(独立端口模式,使用全局 request)"""
|
||||||
@@ -91,10 +87,10 @@ class QQOfficialClient:
|
|||||||
try:
|
try:
|
||||||
body = await req.get_data()
|
body = await req.get_data()
|
||||||
|
|
||||||
await self.logger.info(f'Received request, body length: {len(body)}')
|
print(f'[QQ Official] Received request, body length: {len(body)}')
|
||||||
|
|
||||||
if not body or len(body) == 0:
|
if not body or len(body) == 0:
|
||||||
await self.logger.info('Received empty body, might be health check or GET request')
|
print('[QQ Official] 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)
|
||||||
@@ -115,6 +111,7 @@ 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
|
||||||
|
|
||||||
@@ -142,24 +139,21 @@ 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': d.get('author', {}).get('user_openid', {}),
|
'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),
|
||||||
'timestamp': d.get('timestamp', {}),
|
'timestamp': msg.get('d', {}).get('timestamp', {}),
|
||||||
'd_author_id': d.get('author', {}).get('id', {}),
|
'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),
|
||||||
'content': d.get('content', {}),
|
'content': msg.get('d', {}).get('content', {}),
|
||||||
'd_id': d.get('id', {}),
|
'd_id': msg.get('d', {}).get('id', {}),
|
||||||
'id': msg.get('id', {}),
|
'id': msg.get('id', {}),
|
||||||
'channel_id': d.get('channel_id', {}),
|
'channel_id': msg.get('d', {}).get('channel_id', {}),
|
||||||
'username': d.get('author', {}).get('username', {}),
|
'username': msg.get('d', {}).get('author', {}).get('username', {}),
|
||||||
'guild_id': d.get('guild_id', {}),
|
'guild_id': msg.get('d', {}).get('guild_id', {}),
|
||||||
'member_openid': d.get('author', {}).get('openid', {}),
|
'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),
|
||||||
'group_openid': d.get('group_openid', {}),
|
'group_openid': msg.get('d', {}).get('group_openid', {}),
|
||||||
}
|
}
|
||||||
attachments = d.get('attachments', [])
|
attachments = msg.get('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)
|
||||||
@@ -198,7 +192,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'Failed to send private message: {response_data}')
|
await self.logger.error(f'发送私聊消息失败: {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):
|
||||||
@@ -221,7 +215,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'Failed to send group message: {response.json()}')
|
await self.logger.error(f'发送群聊消息失败:{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):
|
||||||
@@ -244,7 +238,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'Failed to send channel group message: {response.json()}')
|
await self.logger.error(f'发送频道群聊消息失败: {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):
|
||||||
@@ -267,224 +261,9 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'Failed to send channel private message: {response.json()}')
|
await self.logger.error(f'发送频道私聊消息失败: {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:
|
||||||
@@ -513,325 +292,3 @@ 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)
|
|
||||||
|
|||||||
@@ -1,384 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -43,9 +43,6 @@ 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
|
||||||
|
|
||||||
# Find the owning bot for this pipeline (e.g. a web_page_bot)
|
|
||||||
owner_bot = self._find_owner_bot(pipeline_uuid)
|
|
||||||
|
|
||||||
# 注册连接
|
# 注册连接
|
||||||
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(),
|
||||||
@@ -73,7 +70,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 创建接收和发送任务
|
# 创建接收和发送任务
|
||||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot))
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
||||||
send_task = asyncio.create_task(self._handle_send(connection))
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
# 等待任务完成
|
# 等待任务完成
|
||||||
@@ -181,14 +178,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
def _find_owner_bot(self, pipeline_uuid: str):
|
async def _handle_receive(self, connection, websocket_adapter):
|
||||||
"""Find a user-created bot (e.g. web_page_bot) that owns this pipeline."""
|
|
||||||
for bot in self.ap.platform_mgr.bots:
|
|
||||||
if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid:
|
|
||||||
return bot
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _handle_receive(self, connection, websocket_adapter, owner_bot=None):
|
|
||||||
"""处理接收消息的任务"""
|
"""处理接收消息的任务"""
|
||||||
try:
|
try:
|
||||||
while connection.is_active:
|
while connection.is_active:
|
||||||
@@ -213,7 +203,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||||
|
|
||||||
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
await websocket_adapter.handle_websocket_message(connection, data)
|
||||||
|
|
||||||
elif message_type == 'disconnect':
|
elif message_type == 'disconnect':
|
||||||
# 客户端主动断开
|
# 客户端主动断开
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -36,640 +35,3 @@ 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
|
|
||||||
import io
|
|
||||||
import base64
|
|
||||||
|
|
||||||
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:
|
|
||||||
import qrcode as qr_lib
|
|
||||||
|
|
||||||
for _attempt in range(3):
|
|
||||||
qr_resp = await client.fetch_qrcode()
|
|
||||||
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
|
||||||
raise Exception('Failed to get QR code from server')
|
|
||||||
|
|
||||||
# Generate QR code image locally
|
|
||||||
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L)
|
|
||||||
qr.add_data(qr_resp.qrcode_img_content)
|
|
||||||
qr.make(fit=True)
|
|
||||||
img = qr.make_image(fill_color='black', back_color='white')
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format='PNG')
|
|
||||||
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
|
||||||
data_url = f'data:image/png;base64,{b64}'
|
|
||||||
|
|
||||||
def _update_qr():
|
|
||||||
session['qr_data_url'] = data_url
|
|
||||||
session['expire_at'] = time.time() + 480 # 8 minutes
|
|
||||||
session['status'] = 'waiting'
|
|
||||||
|
|
||||||
loop.call_soon_threadsafe(_update_qr)
|
|
||||||
|
|
||||||
# Poll for scan status
|
|
||||||
deadline = loop.time() + 180
|
|
||||||
while loop.time() < deadline:
|
|
||||||
try:
|
|
||||||
status_resp = await client.poll_qrcode_status(qr_resp.qrcode)
|
|
||||||
except Exception:
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if status_resp.status == 'confirmed' and status_resp.bot_token:
|
|
||||||
session['status'] = 'success'
|
|
||||||
session['token'] = status_resp.bot_token
|
|
||||||
session['base_url'] = status_resp.baseurl or client.base_url
|
|
||||||
session['account_id'] = status_resp.ilink_bot_id or ''
|
|
||||||
return
|
|
||||||
|
|
||||||
if status_resp.status == 'expired':
|
|
||||||
break # retry with new QR code
|
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
else:
|
|
||||||
pass # timeout, retry
|
|
||||||
|
|
||||||
# All retries exhausted
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'QR code login failed: max retries exceeded'
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = str(e)
|
|
||||||
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']}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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={})
|
|
||||||
|
|||||||
@@ -6,50 +6,11 @@ import re
|
|||||||
import httpx
|
import httpx
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
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):
|
||||||
@@ -66,15 +27,6 @@ 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()
|
||||||
@@ -150,15 +102,7 @@ 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':
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
return self.success(data={'config': plugin['plugin_config']})
|
||||||
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
|
||||||
|
|
||||||
@@ -191,62 +135,15 @@ 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/<path:filepath>',
|
'/<author>/<plugin_name>/assets/<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_path = _normalize_plugin_asset_path(filepath)
|
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, 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']
|
||||||
resp = quart.Response(asset_bytes, mimetype=mime_type)
|
return 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:
|
||||||
|
|||||||
@@ -97,51 +97,3 @@ 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,7 +15,6 @@ 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
|
||||||
@@ -33,7 +32,6 @@ 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
|
||||||
|
|||||||
@@ -136,9 +136,16 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data=task.to_dict())
|
return self.success(data=task.to_dict())
|
||||||
|
|
||||||
@self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
|
if not constants.debug_mode:
|
||||||
|
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,7 +146,6 @@ 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()
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# Workflow router group
|
|
||||||
from .workflows import WorkflowsRouterGroup, ExecutionsRouterGroup
|
|
||||||
from .websocket_chat import WorkflowWebSocketChatRouterGroup
|
|
||||||
|
|
||||||
__all__ = ['WorkflowsRouterGroup', 'ExecutionsRouterGroup', 'WorkflowWebSocketChatRouterGroup']
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
"""Workflow WebSocket聊天路由 - 支持工作流调试的双向实时通信"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import quart
|
|
||||||
|
|
||||||
from ... import group
|
|
||||||
from ......platform.sources.websocket_manager import ws_connection_manager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('workflow_websocket_chat', '/api/v1/workflows/<workflow_uuid>/ws')
|
|
||||||
class WorkflowWebSocketChatRouterGroup(group.RouterGroup):
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
@self.quart_app.websocket(self.path + '/connect')
|
|
||||||
async def workflow_websocket_connect(workflow_uuid: str):
|
|
||||||
"""
|
|
||||||
建立工作流WebSocket连接
|
|
||||||
|
|
||||||
URL参数:
|
|
||||||
- workflow_uuid: 工作流UUID
|
|
||||||
- session_type: 会话类型 (person/group)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
session_type = quart.websocket.args.get('session_type', 'person')
|
|
||||||
logger.info(
|
|
||||||
'Workflow WebSocket connect request received',
|
|
||||||
extra={
|
|
||||||
'workflow_uuid': workflow_uuid,
|
|
||||||
'session_type': session_type,
|
|
||||||
'path': quart.websocket.path,
|
|
||||||
'query_string': quart.websocket.query_string.decode('utf-8', errors='ignore'),
|
|
||||||
'remote_addr': getattr(quart.websocket, 'remote_addr', None),
|
|
||||||
'user_agent': quart.websocket.headers.get('User-Agent', ''),
|
|
||||||
'host': quart.websocket.headers.get('Host', ''),
|
|
||||||
'origin': quart.websocket.headers.get('Origin', ''),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
|
||||||
logger.warning(
|
|
||||||
'Workflow WebSocket adapter missing',
|
|
||||||
extra={
|
|
||||||
'workflow_uuid': workflow_uuid,
|
|
||||||
'session_type': session_type,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
|
||||||
return
|
|
||||||
|
|
||||||
connection = await ws_connection_manager.add_connection(
|
|
||||||
websocket=quart.websocket._get_current_object(),
|
|
||||||
pipeline_uuid=workflow_uuid,
|
|
||||||
session_type=session_type,
|
|
||||||
metadata={'user_agent': quart.websocket.headers.get('User-Agent', ''), 'is_workflow': True},
|
|
||||||
)
|
|
||||||
|
|
||||||
await quart.websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
'type': 'connected',
|
|
||||||
'connection_id': connection.connection_id,
|
|
||||||
'workflow_uuid': workflow_uuid,
|
|
||||||
'session_type': session_type,
|
|
||||||
'timestamp': connection.created_at.isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f'Workflow WebSocket connection established: {connection.connection_id} '
|
|
||||||
f'(workflow={workflow_uuid}, session_type={session_type})'
|
|
||||||
)
|
|
||||||
|
|
||||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
|
||||||
send_task = asyncio.create_task(self._handle_send(connection))
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.gather(receive_task, send_task)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Workflow WebSocket task execution error: {e}')
|
|
||||||
finally:
|
|
||||||
await ws_connection_manager.remove_connection(connection.connection_id)
|
|
||||||
logger.debug(f'Workflow WebSocket connection cleaned: {connection.connection_id}')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
'Workflow WebSocket connection error',
|
|
||||||
exc_info=True,
|
|
||||||
extra={
|
|
||||||
'workflow_uuid': workflow_uuid,
|
|
||||||
'session_type': quart.websocket.args.get('session_type', 'person'),
|
|
||||||
'path': quart.websocket.path,
|
|
||||||
'query_string': quart.websocket.query_string.decode('utf-8', errors='ignore'),
|
|
||||||
'remote_addr': getattr(quart.websocket, 'remote_addr', None),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)}))
|
|
||||||
except Exception as send_error:
|
|
||||||
logger.debug(
|
|
||||||
'Failed to send error message to workflow websocket client',
|
|
||||||
exc_info=True,
|
|
||||||
extra={
|
|
||||||
'workflow_uuid': workflow_uuid,
|
|
||||||
'send_error': str(send_error),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.route('/messages/<session_type>', methods=['GET'])
|
|
||||||
async def get_messages(workflow_uuid: str, session_type: str) -> str:
|
|
||||||
"""获取工作流消息历史"""
|
|
||||||
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(workflow_uuid, session_type)
|
|
||||||
|
|
||||||
return self.success(data={'messages': messages})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
|
||||||
|
|
||||||
@self.route('/reset/<session_type>', methods=['POST'])
|
|
||||||
async def reset_session(workflow_uuid: str, session_type: str) -> str:
|
|
||||||
"""重置工作流会话"""
|
|
||||||
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(workflow_uuid, session_type)
|
|
||||||
|
|
||||||
return self.success(data={'message': 'Session reset successfully'})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
|
||||||
|
|
||||||
@self.route('/connections', methods=['GET'])
|
|
||||||
async def get_connections(workflow_uuid: str) -> str:
|
|
||||||
"""获取当前工作流连接统计"""
|
|
||||||
try:
|
|
||||||
stats = ws_connection_manager.get_stats()
|
|
||||||
connections = await ws_connection_manager.get_connections_by_pipeline(workflow_uuid)
|
|
||||||
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'stats': stats,
|
|
||||||
'connections': [
|
|
||||||
{
|
|
||||||
'connection_id': conn.connection_id,
|
|
||||||
'session_type': conn.session_type,
|
|
||||||
'created_at': conn.created_at.isoformat(),
|
|
||||||
'last_active': conn.last_active.isoformat(),
|
|
||||||
'is_active': conn.is_active,
|
|
||||||
}
|
|
||||||
for conn in connections
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
|
||||||
|
|
||||||
@self.route('/broadcast', methods=['POST'])
|
|
||||||
async def broadcast_message(workflow_uuid: str) -> str:
|
|
||||||
"""向所有工作流连接广播消息"""
|
|
||||||
try:
|
|
||||||
data = await quart.request.get_json()
|
|
||||||
message = data.get('message')
|
|
||||||
|
|
||||||
if not message:
|
|
||||||
return self.http_status(400, -1, 'message is required')
|
|
||||||
|
|
||||||
broadcast_data = {
|
|
||||||
'type': 'broadcast',
|
|
||||||
'message': message,
|
|
||||||
'timestamp': datetime.datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
await ws_connection_manager.broadcast_to_pipeline(workflow_uuid, broadcast_data)
|
|
||||||
|
|
||||||
return self.success(data={'message': 'Broadcast sent successfully'})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
|
||||||
|
|
||||||
async def _handle_receive(self, connection, websocket_adapter):
|
|
||||||
"""处理接收消息的任务"""
|
|
||||||
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':
|
|
||||||
logger.debug(f'收到工作流消息: {data} from {connection.connection_id}')
|
|
||||||
await websocket_adapter.handle_websocket_message(connection, data)
|
|
||||||
|
|
||||||
elif message_type == 'disconnect':
|
|
||||||
logger.debug(f'Client disconnected: {connection.connection_id}')
|
|
||||||
break
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning(f'Unknown message type: {message_type}')
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.error(f'Invalid JSON message: {message}')
|
|
||||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Receive message 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'Send message error: {e}', exc_info=True)
|
|
||||||
finally:
|
|
||||||
connection.is_active = False
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import quart
|
|
||||||
|
|
||||||
from ... import group
|
|
||||||
from ....service.workflow import WorkflowExecutionFailedError
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('workflows', '/api/v1/workflows')
|
|
||||||
class WorkflowsRouterGroup(group.RouterGroup):
|
|
||||||
"""Workflow API router group"""
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
# Workflow CRUD
|
|
||||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _() -> str:
|
|
||||||
if quart.request.method == 'GET':
|
|
||||||
sort_by = quart.request.args.get('sort_by', 'created_at')
|
|
||||||
sort_order = quart.request.args.get('sort_order', 'DESC')
|
|
||||||
enabled_only = quart.request.args.get('enabled_only', 'false').lower() == 'true'
|
|
||||||
return self.success(
|
|
||||||
data={'workflows': await self.ap.workflow_service.get_workflows(sort_by, sort_order, enabled_only)}
|
|
||||||
)
|
|
||||||
elif quart.request.method == 'POST':
|
|
||||||
json_data = await quart.request.json
|
|
||||||
workflow_uuid = await self.ap.workflow_service.create_workflow(json_data)
|
|
||||||
return self.success(data={'uuid': workflow_uuid})
|
|
||||||
|
|
||||||
# Get node types (available nodes for the editor)
|
|
||||||
@self.route('/_/node-types', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _() -> str:
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'node_types': await self.ap.workflow_service.get_node_types(),
|
|
||||||
'categories': await self.ap.workflow_service.get_node_types_by_category_meta(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get node types by category
|
|
||||||
@self.route('/_/node-types/categories', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _() -> str:
|
|
||||||
return self.success(data={'categories': await self.ap.workflow_service.get_node_types_by_category()})
|
|
||||||
|
|
||||||
# Single workflow operations
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str) -> str:
|
|
||||||
if quart.request.method == 'GET':
|
|
||||||
workflow = await self.ap.workflow_service.get_workflow(workflow_uuid)
|
|
||||||
if workflow is None:
|
|
||||||
return self.http_status(404, -1, 'workflow not found')
|
|
||||||
return self.success(data={'workflow': workflow})
|
|
||||||
elif quart.request.method == 'PUT':
|
|
||||||
json_data = await quart.request.json
|
|
||||||
try:
|
|
||||||
await self.ap.workflow_service.update_workflow(workflow_uuid, json_data)
|
|
||||||
return self.success()
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
elif quart.request.method == 'DELETE':
|
|
||||||
await self.ap.workflow_service.delete_workflow(workflow_uuid)
|
|
||||||
return self.success()
|
|
||||||
|
|
||||||
# Publish workflow (enable)
|
|
||||||
@self.route('/<workflow_uuid>/publish', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(workflow_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
await self.ap.workflow_service.publish_workflow(workflow_uuid)
|
|
||||||
return self.success()
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Unpublish workflow (disable)
|
|
||||||
@self.route('/<workflow_uuid>/unpublish', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(workflow_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
await self.ap.workflow_service.unpublish_workflow(workflow_uuid)
|
|
||||||
return self.success()
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Copy workflow
|
|
||||||
@self.route('/<workflow_uuid>/copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(workflow_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
new_uuid = await self.ap.workflow_service.copy_workflow(workflow_uuid)
|
|
||||||
return self.success(data={'uuid': new_uuid})
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Execute workflow manually
|
|
||||||
@self.route('/<workflow_uuid>/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(workflow_uuid: str) -> str:
|
|
||||||
json_data = await quart.request.json or {}
|
|
||||||
trigger_data = json_data.get('trigger_data', {})
|
|
||||||
session_id = json_data.get('session_id')
|
|
||||||
user_id = json_data.get('user_id')
|
|
||||||
bot_id = json_data.get('bot_id')
|
|
||||||
|
|
||||||
try:
|
|
||||||
execution_id = await self.ap.workflow_service.execute_workflow(
|
|
||||||
workflow_uuid,
|
|
||||||
trigger_type='manual',
|
|
||||||
trigger_data=trigger_data,
|
|
||||||
session_id=session_id,
|
|
||||||
user_id=user_id,
|
|
||||||
bot_id=bot_id,
|
|
||||||
)
|
|
||||||
return self.success(data={'execution_id': execution_id})
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
except WorkflowExecutionFailedError as e:
|
|
||||||
return self.http_status(500, -1, e.message)
|
|
||||||
|
|
||||||
# Get workflow executions
|
|
||||||
@self.route('/<workflow_uuid>/executions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(workflow_uuid: str) -> str:
|
|
||||||
limit = int(quart.request.args.get('limit', 50))
|
|
||||||
offset = int(quart.request.args.get('offset', 0))
|
|
||||||
executions = await self.ap.workflow_service.get_executions(
|
|
||||||
workflow_uuid=workflow_uuid, limit=limit, offset=offset
|
|
||||||
)
|
|
||||||
return self.success(data=executions)
|
|
||||||
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>/executions/<execution_uuid>',
|
|
||||||
methods=['GET'],
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
|
||||||
execution = await self.ap.workflow_service.get_execution(execution_uuid)
|
|
||||||
if execution is None:
|
|
||||||
return self.http_status(404, -1, 'execution not found')
|
|
||||||
if execution.get('workflow_uuid') != workflow_uuid:
|
|
||||||
return self.http_status(404, -1, 'execution not found in workflow')
|
|
||||||
return self.success(data={'execution': execution})
|
|
||||||
|
|
||||||
# Get workflow versions
|
|
||||||
@self.route('/<workflow_uuid>/versions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(workflow_uuid: str) -> str:
|
|
||||||
versions = await self.ap.workflow_service.get_versions(workflow_uuid)
|
|
||||||
return self.success(data={'versions': versions})
|
|
||||||
|
|
||||||
# Rollback to a specific version
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>/rollback/<int:version>', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str, version: int) -> str:
|
|
||||||
try:
|
|
||||||
await self.ap.workflow_service.rollback_to_version(workflow_uuid, version)
|
|
||||||
return self.success()
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Workflow extensions (plugins and MCP servers)
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str) -> str:
|
|
||||||
if quart.request.method == 'GET':
|
|
||||||
workflow = await self.ap.workflow_service.get_workflow(workflow_uuid)
|
|
||||||
if workflow is None:
|
|
||||||
return self.http_status(404, -1, 'workflow not found')
|
|
||||||
|
|
||||||
# Get available plugins and MCP servers
|
|
||||||
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
|
|
||||||
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)
|
|
||||||
|
|
||||||
extensions_prefs = workflow.get('extensions_preferences', {})
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
|
||||||
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
|
||||||
'bound_plugins': extensions_prefs.get('plugins', []),
|
|
||||||
'available_plugins': plugins,
|
|
||||||
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
|
||||||
'available_mcp_servers': mcp_servers,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif quart.request.method == 'PUT':
|
|
||||||
json_data = await quart.request.json
|
|
||||||
enable_all_plugins = json_data.get('enable_all_plugins', True)
|
|
||||||
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
|
|
||||||
bound_plugins = json_data.get('bound_plugins', [])
|
|
||||||
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.ap.workflow_service.update_workflow_extensions(
|
|
||||||
workflow_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers
|
|
||||||
)
|
|
||||||
return self.success()
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Debug API - Start debug execution
|
|
||||||
@self.route('/<workflow_uuid>/debug/start', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(workflow_uuid: str) -> str:
|
|
||||||
json_data = await quart.request.json or {}
|
|
||||||
context = json_data.get('context', {})
|
|
||||||
variables = json_data.get('variables', {})
|
|
||||||
breakpoints = json_data.get('breakpoints', [])
|
|
||||||
|
|
||||||
try:
|
|
||||||
execution_id = await self.ap.workflow_service.start_debug_execution(
|
|
||||||
workflow_uuid, context=context, variables=variables, breakpoints=breakpoints
|
|
||||||
)
|
|
||||||
return self.success(data={'execution_id': execution_id})
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Debug API - Pause execution
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>/debug/<execution_uuid>/pause',
|
|
||||||
methods=['POST'],
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
await self.ap.workflow_service.pause_debug_execution(workflow_uuid, execution_uuid)
|
|
||||||
return self.success()
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Debug API - Resume execution
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>/debug/<execution_uuid>/resume',
|
|
||||||
methods=['POST'],
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
await self.ap.workflow_service.resume_debug_execution(workflow_uuid, execution_uuid)
|
|
||||||
return self.success()
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Debug API - Step execution
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>/debug/<execution_uuid>/step',
|
|
||||||
methods=['POST'],
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
result = await self.ap.workflow_service.step_debug_execution(workflow_uuid, execution_uuid)
|
|
||||||
return self.success(data=result)
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Debug API - Stop execution
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>/debug/<execution_uuid>/stop',
|
|
||||||
methods=['POST'],
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
await self.ap.workflow_service.stop_debug_execution(workflow_uuid, execution_uuid)
|
|
||||||
return self.success()
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Debug API - Get debug state
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>/debug/<execution_uuid>/state',
|
|
||||||
methods=['GET'],
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
state = await self.ap.workflow_service.get_debug_state(workflow_uuid, execution_uuid)
|
|
||||||
return self.success(data=state)
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Get execution logs
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>/executions/<execution_uuid>/logs',
|
|
||||||
methods=['GET'],
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
|
||||||
limit = int(quart.request.args.get('limit', 100))
|
|
||||||
offset = int(quart.request.args.get('offset', 0))
|
|
||||||
try:
|
|
||||||
result = await self.ap.workflow_service.get_execution_logs(workflow_uuid, execution_uuid, limit, offset)
|
|
||||||
return self.success(data=result)
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Rerun execution
|
|
||||||
@self.route(
|
|
||||||
'/<workflow_uuid>/executions/<execution_uuid>/rerun',
|
|
||||||
methods=['POST'],
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
)
|
|
||||||
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
new_execution_id = await self.ap.workflow_service.rerun_execution(workflow_uuid, execution_uuid)
|
|
||||||
return self.success(data={'execution_uuid': new_execution_id})
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# Get workflow statistics
|
|
||||||
@self.route('/<workflow_uuid>/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(workflow_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
stats = await self.ap.workflow_service.get_workflow_stats(workflow_uuid)
|
|
||||||
return self.success(data=stats)
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
|
|
||||||
# LLM Node Performance Test Endpoint
|
|
||||||
# Tests each step of LLM node execution with detailed timing
|
|
||||||
@self.route('/_/test/llm-node', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _() -> str:
|
|
||||||
"""Test LLM node performance with detailed step-by-step timing.
|
|
||||||
|
|
||||||
Request body:
|
|
||||||
{
|
|
||||||
"model_uuid": "uuid-of-model",
|
|
||||||
"system_prompt": "optional system prompt",
|
|
||||||
"user_prompt": "test message",
|
|
||||||
"temperature": 0.7,
|
|
||||||
"max_tokens": 100
|
|
||||||
}
|
|
||||||
|
|
||||||
Response includes timing for each step:
|
|
||||||
- model_fetch: Time to get model from model_mgr
|
|
||||||
- prompt_build: Time to build messages
|
|
||||||
- llm_call: Time for actual LLM invocation
|
|
||||||
- total: Total time
|
|
||||||
- usage: Token usage information
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
json_data = await quart.request.json
|
|
||||||
if not json_data:
|
|
||||||
return self.http_status(400, -1, 'Request body is required')
|
|
||||||
|
|
||||||
model_uuid = json_data.get('model_uuid', '')
|
|
||||||
if not model_uuid:
|
|
||||||
return self.http_status(400, -1, 'model_uuid is required')
|
|
||||||
|
|
||||||
user_prompt = json_data.get('user_prompt', 'test')
|
|
||||||
system_prompt = json_data.get('system_prompt', '')
|
|
||||||
temperature = json_data.get('temperature')
|
|
||||||
max_tokens = json_data.get('max_tokens', 0)
|
|
||||||
|
|
||||||
timings = {}
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
# Step 1: Model fetch
|
|
||||||
t_start = time.perf_counter()
|
|
||||||
try:
|
|
||||||
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
|
||||||
timings['model_fetch_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
|
||||||
timings['model_found'] = True
|
|
||||||
timings['model_name'] = runtime_model.model_entity.name if runtime_model else None
|
|
||||||
except Exception as e:
|
|
||||||
timings['model_fetch_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
|
||||||
timings['model_found'] = False
|
|
||||||
errors.append(f'Model fetch failed: {str(e)}')
|
|
||||||
return self.http_status(400, -1, {
|
|
||||||
'error': errors[0],
|
|
||||||
'timings': timings,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Step 2: Build messages
|
|
||||||
t_start = time.perf_counter()
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
||||||
messages = []
|
|
||||||
if system_prompt:
|
|
||||||
messages.append(provider_message.Message(role='system', content=system_prompt))
|
|
||||||
messages.append(provider_message.Message(role='user', content=user_prompt))
|
|
||||||
timings['prompt_build_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
|
||||||
|
|
||||||
# Step 3: Build extra args
|
|
||||||
extra_args = {}
|
|
||||||
if temperature is not None:
|
|
||||||
extra_args['temperature'] = float(temperature)
|
|
||||||
if max_tokens and int(max_tokens) > 0:
|
|
||||||
extra_args['max_tokens'] = int(max_tokens)
|
|
||||||
|
|
||||||
# Step 4: LLM call
|
|
||||||
t_start = time.perf_counter()
|
|
||||||
try:
|
|
||||||
result_message = await runtime_model.provider.invoke_llm(
|
|
||||||
query=None,
|
|
||||||
model=runtime_model,
|
|
||||||
messages=messages,
|
|
||||||
funcs=None,
|
|
||||||
extra_args=extra_args,
|
|
||||||
)
|
|
||||||
timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
|
||||||
timings['llm_call_success'] = True
|
|
||||||
|
|
||||||
# Extract response text
|
|
||||||
response_text = ''
|
|
||||||
if isinstance(result_message.content, str):
|
|
||||||
response_text = result_message.content
|
|
||||||
elif isinstance(result_message.content, list):
|
|
||||||
for elem in result_message.content:
|
|
||||||
if hasattr(elem, 'text') and elem.text:
|
|
||||||
response_text += elem.text
|
|
||||||
elif isinstance(elem, str):
|
|
||||||
response_text += elem
|
|
||||||
|
|
||||||
timings['response_length'] = len(response_text)
|
|
||||||
timings['response_preview'] = response_text[:200]
|
|
||||||
|
|
||||||
# Extract usage
|
|
||||||
usage = {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}
|
|
||||||
if hasattr(result_message, 'usage') and result_message.usage:
|
|
||||||
u = result_message.usage
|
|
||||||
usage = {
|
|
||||||
'prompt_tokens': getattr(u, 'prompt_tokens', 0) or 0,
|
|
||||||
'completion_tokens': getattr(u, 'completion_tokens', 0) or 0,
|
|
||||||
'total_tokens': getattr(u, 'total_tokens', 0) or 0,
|
|
||||||
}
|
|
||||||
timings['usage'] = usage
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
|
||||||
timings['llm_call_success'] = False
|
|
||||||
errors.append(f'LLM call failed: {str(e)}')
|
|
||||||
|
|
||||||
# Calculate total
|
|
||||||
timings['total_ms'] = round(sum([
|
|
||||||
timings.get('model_fetch_ms', 0),
|
|
||||||
timings.get('prompt_build_ms', 0),
|
|
||||||
timings.get('llm_call_ms', 0),
|
|
||||||
]), 2)
|
|
||||||
|
|
||||||
# Add breakdown percentage
|
|
||||||
if timings['total_ms'] > 0:
|
|
||||||
timings['breakdown'] = {
|
|
||||||
'model_fetch_pct': round(timings.get('model_fetch_ms', 0) / timings['total_ms'] * 100, 1),
|
|
||||||
'prompt_build_pct': round(timings.get('prompt_build_ms', 0) / timings['total_ms'] * 100, 1),
|
|
||||||
'llm_call_pct': round(timings.get('llm_call_ms', 0) / timings['total_ms'] * 100, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
timings['errors'] = errors
|
|
||||||
|
|
||||||
return self.success(data={'test_result': timings})
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('executions', '/api/v1/executions')
|
|
||||||
class ExecutionsRouterGroup(group.RouterGroup):
|
|
||||||
"""Workflow execution API router group"""
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
# Get all executions (across all workflows)
|
|
||||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _() -> str:
|
|
||||||
limit = int(quart.request.args.get('limit', 50))
|
|
||||||
offset = int(quart.request.args.get('offset', 0))
|
|
||||||
status = quart.request.args.get('status')
|
|
||||||
executions = await self.ap.workflow_service.get_executions(limit=limit, offset=offset, status=status)
|
|
||||||
return self.success(data=executions)
|
|
||||||
|
|
||||||
# Get single execution
|
|
||||||
@self.route('/<execution_uuid>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(execution_uuid: str) -> str:
|
|
||||||
execution = await self.ap.workflow_service.get_execution(execution_uuid)
|
|
||||||
if execution is None:
|
|
||||||
return self.http_status(404, -1, 'execution not found')
|
|
||||||
return self.success(data={'execution': execution})
|
|
||||||
|
|
||||||
# Cancel execution
|
|
||||||
@self.route('/<execution_uuid>/cancel', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(execution_uuid: str) -> str:
|
|
||||||
try:
|
|
||||||
await self.ap.workflow_service.cancel_execution(execution_uuid)
|
|
||||||
return self.success()
|
|
||||||
except ValueError as e:
|
|
||||||
return self.http_status(404, -1, str(e))
|
|
||||||
except RuntimeError as e:
|
|
||||||
return self.http_status(400, -1, str(e))
|
|
||||||
@@ -17,7 +17,6 @@ from .groups import platform as groups_platform
|
|||||||
from .groups import pipelines as groups_pipelines
|
from .groups import pipelines as groups_pipelines
|
||||||
from .groups import knowledge as groups_knowledge
|
from .groups import knowledge as groups_knowledge
|
||||||
from .groups import resources as groups_resources
|
from .groups import resources as groups_resources
|
||||||
from .groups import workflows as groups_workflows
|
|
||||||
|
|
||||||
importutil.import_modules_in_pkg(groups)
|
importutil.import_modules_in_pkg(groups)
|
||||||
importutil.import_modules_in_pkg(groups_provider)
|
importutil.import_modules_in_pkg(groups_provider)
|
||||||
@@ -25,7 +24,6 @@ importutil.import_modules_in_pkg(groups_platform)
|
|||||||
importutil.import_modules_in_pkg(groups_pipelines)
|
importutil.import_modules_in_pkg(groups_pipelines)
|
||||||
importutil.import_modules_in_pkg(groups_knowledge)
|
importutil.import_modules_in_pkg(groups_knowledge)
|
||||||
importutil.import_modules_in_pkg(groups_resources)
|
importutil.import_modules_in_pkg(groups_resources)
|
||||||
importutil.import_modules_in_pkg(groups_workflows)
|
|
||||||
|
|
||||||
|
|
||||||
class HTTPController:
|
class HTTPController:
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ 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,7 @@ class BotService:
|
|||||||
# TODO: 检查配置信息格式
|
# TODO: 检查配置信息格式
|
||||||
bot_data['uuid'] = str(uuid.uuid4())
|
bot_data['uuid'] = str(uuid.uuid4())
|
||||||
|
|
||||||
# Set default binding_type if not provided
|
# checkout the default pipeline
|
||||||
if 'binding_type' not in bot_data:
|
|
||||||
bot_data['binding_type'] = 'pipeline'
|
|
||||||
|
|
||||||
# checkout the default pipeline (for backward compatibility)
|
|
||||||
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.is_default == True
|
persistence_pipeline.LegacyPipeline.is_default == True
|
||||||
@@ -113,9 +109,6 @@ class BotService:
|
|||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
bot_data['use_pipeline_uuid'] = pipeline.uuid
|
bot_data['use_pipeline_uuid'] = pipeline.uuid
|
||||||
bot_data['use_pipeline_name'] = pipeline.name
|
bot_data['use_pipeline_name'] = pipeline.name
|
||||||
# Also set binding_uuid for new unified binding model
|
|
||||||
if 'binding_uuid' not in bot_data:
|
|
||||||
bot_data['binding_uuid'] = pipeline.uuid
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
|
||||||
|
|
||||||
@@ -130,11 +123,7 @@ class BotService:
|
|||||||
if 'uuid' in bot_data:
|
if 'uuid' in bot_data:
|
||||||
del bot_data['uuid']
|
del bot_data['uuid']
|
||||||
|
|
||||||
# Handle binding_type and binding_uuid for the new unified binding model
|
# set use_pipeline_name
|
||||||
# If binding_type is explicitly set to 'workflow', skip pipeline validation
|
|
||||||
binding_type = bot_data.get('binding_type')
|
|
||||||
|
|
||||||
# set use_pipeline_name (for backward compatibility with 'pipeline' binding_type)
|
|
||||||
if 'use_pipeline_uuid' in bot_data:
|
if 'use_pipeline_uuid' in bot_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(
|
||||||
@@ -144,19 +133,9 @@ class BotService:
|
|||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
bot_data['use_pipeline_name'] = pipeline.name
|
bot_data['use_pipeline_name'] = pipeline.name
|
||||||
# Also sync to binding_uuid if binding_type is 'pipeline' or not set
|
|
||||||
if binding_type is None or binding_type == 'pipeline':
|
|
||||||
bot_data['binding_uuid'] = bot_data['use_pipeline_uuid']
|
|
||||||
bot_data['binding_type'] = 'pipeline'
|
|
||||||
else:
|
else:
|
||||||
raise Exception('Pipeline not found')
|
raise Exception('Pipeline not found')
|
||||||
|
|
||||||
# If binding_uuid is set directly (for workflow), sync use_pipeline_uuid for backward compatibility
|
|
||||||
if 'binding_uuid' in bot_data and binding_type == 'workflow':
|
|
||||||
# For workflow binding, we don't sync to use_pipeline_uuid
|
|
||||||
# but we ensure binding_type is correctly set
|
|
||||||
bot_data['binding_type'] = 'workflow'
|
|
||||||
|
|
||||||
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(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,126 +31,15 @@ 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=creation_settings,
|
creation_settings=kb_data.get('creation_settings', {}),
|
||||||
retrieval_settings=retrieval_settings,
|
retrieval_settings=kb_data.get('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
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
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,17 +23,6 @@ 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
|
||||||
|
|
||||||
@@ -184,7 +173,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(**_runtime_model_data(model_uuid, model_data)),
|
persistence_model.LLMModel(uuid=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)
|
||||||
@@ -345,7 +334,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(**_runtime_model_data(model_uuid, model_data)),
|
persistence_model.EmbeddingModel(uuid=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)
|
||||||
@@ -378,162 +367,3 @@ 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.',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -18,119 +18,55 @@ class MonitoringService:
|
|||||||
|
|
||||||
# ========== Cleanup Methods ==========
|
# ========== Cleanup Methods ==========
|
||||||
|
|
||||||
async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]:
|
async def cleanup_expired_records(self, retention_days: int) -> dict[str, int]:
|
||||||
"""Delete monitoring records older than the specified retention period.
|
"""Delete monitoring records older than the specified retention period.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
retention_days: Number of days to retain records.
|
retention_days: Number of days to retain records.
|
||||||
batch_size: Maximum rows to delete per table batch.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dict mapping table name to the number of deleted rows.
|
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(
|
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
||||||
days=retention_days
|
days=retention_days
|
||||||
)
|
)
|
||||||
|
|
||||||
tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [
|
tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [
|
||||||
(
|
(
|
||||||
'monitoring_messages',
|
'monitoring_messages',
|
||||||
persistence_monitoring.MonitoringMessage,
|
persistence_monitoring.MonitoringMessage,
|
||||||
persistence_monitoring.MonitoringMessage.timestamp,
|
persistence_monitoring.MonitoringMessage.timestamp,
|
||||||
persistence_monitoring.MonitoringMessage.id,
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_llm_calls',
|
'monitoring_llm_calls',
|
||||||
persistence_monitoring.MonitoringLLMCall,
|
persistence_monitoring.MonitoringLLMCall,
|
||||||
persistence_monitoring.MonitoringLLMCall.timestamp,
|
persistence_monitoring.MonitoringLLMCall.timestamp,
|
||||||
persistence_monitoring.MonitoringLLMCall.id,
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_embedding_calls',
|
'monitoring_embedding_calls',
|
||||||
persistence_monitoring.MonitoringEmbeddingCall,
|
persistence_monitoring.MonitoringEmbeddingCall,
|
||||||
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
||||||
persistence_monitoring.MonitoringEmbeddingCall.id,
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_errors',
|
'monitoring_errors',
|
||||||
persistence_monitoring.MonitoringError,
|
persistence_monitoring.MonitoringError,
|
||||||
persistence_monitoring.MonitoringError.timestamp,
|
persistence_monitoring.MonitoringError.timestamp,
|
||||||
persistence_monitoring.MonitoringError.id,
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'monitoring_sessions',
|
'monitoring_sessions',
|
||||||
persistence_monitoring.MonitoringSession,
|
persistence_monitoring.MonitoringSession,
|
||||||
persistence_monitoring.MonitoringSession.last_activity,
|
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] = {}
|
deleted_counts: dict[str, int] = {}
|
||||||
|
|
||||||
for table_name, model_cls, ts_column, pk_column in tables_and_columns:
|
for table_name, model_cls, ts_column in tables_and_columns:
|
||||||
deleted_counts[table_name] = await self._delete_expired_in_batches(
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff))
|
||||||
model_cls=model_cls,
|
deleted_counts[table_name] = result.rowcount
|
||||||
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
|
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(
|
||||||
|
|||||||
@@ -73,20 +73,6 @@ class PipelineService:
|
|||||||
|
|
||||||
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||||
|
|
||||||
async def get_pipeline_by_name(self, pipeline_name: str) -> dict | None:
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
|
||||||
persistence_pipeline.LegacyPipeline.name == pipeline_name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline = result.first()
|
|
||||||
|
|
||||||
if pipeline is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
|
||||||
|
|
||||||
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
||||||
from ....utils import paths as path_utils
|
from ....utils import paths as path_utils
|
||||||
|
|
||||||
@@ -127,9 +113,14 @@ 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:
|
||||||
pipeline_data = pipeline_data.copy()
|
if 'uuid' in pipeline_data:
|
||||||
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
|
del pipeline_data['uuid']
|
||||||
pipeline_data.pop(protected_field, None)
|
if 'for_version' in pipeline_data:
|
||||||
|
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)
|
||||||
|
|||||||
@@ -17,24 +17,6 @@ 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))
|
||||||
@@ -77,7 +59,6 @@ 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)
|
||||||
)
|
)
|
||||||
@@ -91,8 +72,6 @@ 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)
|
||||||
@@ -119,14 +98,6 @@ 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
|
||||||
@@ -151,19 +122,10 @@ class ModelProviderService:
|
|||||||
)
|
)
|
||||||
embedding_count = embedding_result.scalar() or 0
|
embedding_count = embedding_result.scalar() or 0
|
||||||
|
|
||||||
rerank_result = await self.ap.persistence_mgr.execute_async(
|
return {'llm_count': llm_count, 'embedding_count': embedding_count}
|
||||||
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(
|
||||||
@@ -191,7 +153,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,
|
'api_keys': api_keys or [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -200,7 +162,7 @@ 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=self._normalize_api_keys(api_key))
|
.values(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')
|
||||||
|
|
||||||
|
|||||||
@@ -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', params={'page_size': 100}) as response:
|
async with session.get(f'{space_url}/api/v1/models') 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()
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ 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 workflow as workflow_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
|
||||||
@@ -135,8 +133,6 @@ 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
|
||||||
@@ -151,16 +147,12 @@ class Application:
|
|||||||
|
|
||||||
webhook_service: webhook_service.WebhookService = None
|
webhook_service: webhook_service.WebhookService = None
|
||||||
|
|
||||||
workflow_service: workflow_service.WorkflowService = None
|
|
||||||
|
|
||||||
telemetry: telemetry_module.TelemetryManager = None
|
telemetry: telemetry_module.TelemetryManager = None
|
||||||
|
|
||||||
survey: survey_module.SurveyManager = None
|
survey: survey_module.SurveyManager = None
|
||||||
|
|
||||||
monitoring_service: monitoring_service.MonitoringService = None
|
monitoring_service: monitoring_service.MonitoringService = None
|
||||||
|
|
||||||
maintenance_service: maintenance_service.MaintenanceService = None
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -200,30 +192,14 @@ class Application:
|
|||||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||||
if auto_cleanup_cfg.get('enabled', True):
|
if auto_cleanup_cfg.get('enabled', True):
|
||||||
retention_days = self._get_positive_int_config(
|
retention_days = auto_cleanup_cfg.get('retention_days', 30)
|
||||||
auto_cleanup_cfg.get('retention_days', 30),
|
check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
|
||||||
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():
|
async def monitoring_cleanup_loop():
|
||||||
check_interval_seconds = check_interval_hours * 3600
|
check_interval_seconds = check_interval_hours * 3600
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
deleted = await self.monitoring_service.cleanup_expired_records(
|
deleted = await self.monitoring_service.cleanup_expired_records(retention_days)
|
||||||
retention_days,
|
|
||||||
batch_size=delete_batch_size,
|
|
||||||
)
|
|
||||||
total_deleted = sum(deleted.values())
|
total_deleted = sum(deleted.values())
|
||||||
if total_deleted > 0:
|
if total_deleted > 0:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
@@ -240,49 +216,6 @@ class Application:
|
|||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def workflow_execution_cleanup_loop():
|
|
||||||
check_interval_seconds = 60
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
cancelled = await self.workflow_service.cleanup_stale_executions()
|
|
||||||
if cancelled > 0:
|
|
||||||
self.logger.info(f'Workflow execution auto-cleanup: cancelled {cancelled} stale executions')
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f'Workflow execution auto-cleanup error: {e}')
|
|
||||||
await asyncio.sleep(check_interval_seconds)
|
|
||||||
|
|
||||||
self.task_mgr.create_task(
|
|
||||||
workflow_execution_cleanup_loop(),
|
|
||||||
name='workflow-execution-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',
|
||||||
@@ -297,28 +230,6 @@ 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):
|
||||||
self.plugin_connector.dispose()
|
self.plugin_connector.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -46,14 +46,12 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ 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 workflow as workflow_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
|
||||||
@@ -63,9 +61,6 @@ 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
|
||||||
|
|
||||||
@@ -87,9 +82,6 @@ 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
|
||||||
|
|
||||||
workflow_service_inst = workflow_service.WorkflowService(ap)
|
|
||||||
ap.workflow_service = workflow_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
|
||||||
@@ -172,9 +164,6 @@ 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()
|
||||||
|
|||||||
@@ -221,34 +221,3 @@ class LoadConfigStage(stage.BootingStage):
|
|||||||
ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml')
|
ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml')
|
||||||
ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml')
|
ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml')
|
||||||
ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml')
|
ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml')
|
||||||
|
|
||||||
# Load workflow node metadata from YAML files. YAML is the source of
|
|
||||||
# truth for workflow editor metadata; Python classes provide execution
|
|
||||||
# logic and are bound through the registry.
|
|
||||||
from langbot.pkg.workflow.metadata import NodeMetadataLoader
|
|
||||||
from langbot.pkg.workflow.registry import NodeTypeRegistry
|
|
||||||
|
|
||||||
workflow_metadata_loader = NodeMetadataLoader()
|
|
||||||
workflow_node_count = await workflow_metadata_loader.load_core_metadata()
|
|
||||||
ap.workflow_node_configs = workflow_metadata_loader.get_all_metadata()
|
|
||||||
ap.workflow_node_metadata_loader = workflow_metadata_loader
|
|
||||||
|
|
||||||
workflow_registry = NodeTypeRegistry.instance()
|
|
||||||
for node_config in ap.workflow_node_configs.values():
|
|
||||||
workflow_registry.register_metadata(node_config, source=node_config.get('_source', 'core'))
|
|
||||||
|
|
||||||
# Auto-discover and register workflow nodes using discovery engine
|
|
||||||
if hasattr(ap, 'discover') and ap.discover is not None:
|
|
||||||
workflow_registry.discover_nodes(ap.discover)
|
|
||||||
|
|
||||||
workflow_load_errors = workflow_metadata_loader.get_load_errors()
|
|
||||||
if workflow_load_errors:
|
|
||||||
print(f'Workflow node metadata load errors: {len(workflow_load_errors)}')
|
|
||||||
for error in workflow_load_errors:
|
|
||||||
print(f" - {error.get('file')}: {error.get('error')}")
|
|
||||||
|
|
||||||
print(
|
|
||||||
f'Loaded {workflow_node_count} workflow node metadata files; '
|
|
||||||
f'registered {workflow_registry.metadata_count()} metadata definitions, '
|
|
||||||
f'{workflow_registry.count()} node types'
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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
|
||||||
@@ -120,7 +119,6 @@ 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:
|
||||||
@@ -156,7 +154,6 @@ 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(),
|
||||||
@@ -196,8 +193,6 @@ 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(
|
||||||
@@ -231,15 +226,6 @@ class AsyncTaskManager:
|
|||||||
'id_index': TaskWrapper._id_index,
|
'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,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_task_by_id(self, id: int) -> TaskWrapper | None:
|
def get_task_by_id(self, id: int) -> TaskWrapper | None:
|
||||||
for t in self.tasks:
|
for t in self.tasks:
|
||||||
if t.id == id:
|
if t.id == id:
|
||||||
@@ -257,27 +243,3 @@ 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]
|
|
||||||
|
|||||||
@@ -304,65 +304,3 @@ class ComponentDiscoveryEngine:
|
|||||||
if component.kind == kind:
|
if component.kind == kind:
|
||||||
result.append(component)
|
result.append(component)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def discover_workflow_nodes(self, nodes_dir: str) -> typing.List[typing.Type]:
|
|
||||||
"""Discover workflow node classes from a directory of Python modules.
|
|
||||||
|
|
||||||
Scans all .py files in the given directory, imports them, and collects
|
|
||||||
classes that are subclasses of WorkflowNode.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nodes_dir: Directory path like 'pkg/workflow/nodes/'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of WorkflowNode subclasses found
|
|
||||||
"""
|
|
||||||
from langbot.pkg.workflow.node import WorkflowNode
|
|
||||||
|
|
||||||
node_classes: typing.List[typing.Type[WorkflowNode]] = []
|
|
||||||
|
|
||||||
# Normalize path
|
|
||||||
if nodes_dir.endswith('/'):
|
|
||||||
nodes_dir = nodes_dir[:-1]
|
|
||||||
|
|
||||||
# Import the nodes package to trigger all module imports
|
|
||||||
module_path = nodes_dir.replace('/', '.').replace('\\', '.')
|
|
||||||
package_path = module_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Import the package __init__ to trigger submodule imports
|
|
||||||
importlib.import_module(f'langbot.{package_path}')
|
|
||||||
except ImportError:
|
|
||||||
self.ap.logger.warning(f'Failed to import workflow nodes package: langbot.{package_path}')
|
|
||||||
|
|
||||||
# Since workflow/__init__.py is empty, explicitly import all .py files in the nodes directory
|
|
||||||
import os
|
|
||||||
# engine.py is in langbot/pkg/discover/, nodes are in langbot/pkg/workflow/nodes/
|
|
||||||
nodes_abs_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'workflow', 'nodes'))
|
|
||||||
if os.path.isdir(nodes_abs_path):
|
|
||||||
for filename in os.listdir(nodes_abs_path):
|
|
||||||
if filename.endswith('.py') and not filename.startswith('_'):
|
|
||||||
module_name = filename[:-3]
|
|
||||||
try:
|
|
||||||
importlib.import_module(f'langbot.{package_path}.{module_name}')
|
|
||||||
except ImportError as e:
|
|
||||||
self.ap.logger.warning(f'Failed to import workflow node module: {module_name}: {e}')
|
|
||||||
|
|
||||||
# Now collect all WorkflowNode subclasses from sys.modules
|
|
||||||
import sys
|
|
||||||
prefix = f'langbot.{package_path}.'
|
|
||||||
for mod_name, mod in sys.modules.items():
|
|
||||||
if mod_name.startswith(prefix) and mod is not None:
|
|
||||||
for attr_name in dir(mod):
|
|
||||||
attr = getattr(mod, attr_name)
|
|
||||||
if (
|
|
||||||
isinstance(attr, type)
|
|
||||||
and issubclass(attr, WorkflowNode)
|
|
||||||
and attr is not WorkflowNode
|
|
||||||
and hasattr(attr, 'type_name')
|
|
||||||
and attr.type_name
|
|
||||||
):
|
|
||||||
if attr not in node_classes:
|
|
||||||
node_classes.append(attr)
|
|
||||||
|
|
||||||
return node_classes
|
|
||||||
|
|||||||
@@ -17,13 +17,6 @@ class Bot(Base):
|
|||||||
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='[]')
|
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||||
|
|
||||||
# New unified binding fields
|
|
||||||
# binding_type: 'pipeline' or 'workflow'
|
|
||||||
binding_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='pipeline')
|
|
||||||
# binding_uuid: UUID of the bound Pipeline or Workflow
|
|
||||||
binding_uuid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
|
||||||
|
|
||||||
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,22 +59,3 @@ 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(),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
"""Workflow persistence entities"""
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Workflow(Base):
|
|
||||||
"""Workflow definition"""
|
|
||||||
|
|
||||||
__tablename__ = 'workflows'
|
|
||||||
|
|
||||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
|
||||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
|
||||||
description = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🔄')
|
|
||||||
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=1)
|
|
||||||
is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
|
|
||||||
|
|
||||||
# Workflow definition stored as JSON
|
|
||||||
# Contains: nodes, edges, variables, settings
|
|
||||||
definition = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
|
||||||
|
|
||||||
# Global config (inherited from Pipeline capabilities)
|
|
||||||
# Contains: safety, output configs
|
|
||||||
global_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
|
||||||
|
|
||||||
# Extensions preferences (same as Pipeline)
|
|
||||||
extensions_preferences = sqlalchemy.Column(
|
|
||||||
sqlalchemy.JSON,
|
|
||||||
nullable=False,
|
|
||||||
default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
|
||||||
updated_at = sqlalchemy.Column(
|
|
||||||
sqlalchemy.DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=sqlalchemy.func.now(),
|
|
||||||
onupdate=sqlalchemy.func.now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowVersion(Base):
|
|
||||||
"""Workflow version history"""
|
|
||||||
|
|
||||||
__tablename__ = 'workflow_versions'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
|
||||||
workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
|
||||||
definition = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
|
||||||
global_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
|
||||||
created_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (sqlalchemy.UniqueConstraint('workflow_uuid', 'version', name='uq_workflow_version'),)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowTrigger(Base):
|
|
||||||
"""Workflow trigger configuration"""
|
|
||||||
|
|
||||||
__tablename__ = 'workflow_triggers'
|
|
||||||
|
|
||||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
|
||||||
workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # message, cron, event, webhook
|
|
||||||
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
|
||||||
is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
|
|
||||||
priority = 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(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowExecution(Base):
|
|
||||||
"""Workflow execution record"""
|
|
||||||
|
|
||||||
__tablename__ = 'workflow_executions'
|
|
||||||
|
|
||||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
|
||||||
workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
workflow_version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
|
||||||
status = sqlalchemy.Column(sqlalchemy.String(20), nullable=False) # pending, running, completed, failed, cancelled
|
|
||||||
trigger_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
|
||||||
trigger_data = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
|
|
||||||
variables = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
|
|
||||||
start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
end_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
error = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowNodeExecution(Base):
|
|
||||||
"""Workflow node execution record"""
|
|
||||||
|
|
||||||
__tablename__ = 'workflow_node_executions'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
|
||||||
execution_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
node_id = sqlalchemy.Column(sqlalchemy.String(100), nullable=False)
|
|
||||||
node_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
|
||||||
status = sqlalchemy.Column(sqlalchemy.String(20), nullable=False) # pending, running, completed, failed, skipped
|
|
||||||
inputs = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
|
|
||||||
outputs = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
|
|
||||||
start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
end_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
error = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
retry_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
|
||||||
|
|
||||||
|
|
||||||
class ScheduledJob(Base):
|
|
||||||
"""Scheduled job for cron triggers"""
|
|
||||||
|
|
||||||
__tablename__ = 'workflow_scheduled_jobs'
|
|
||||||
|
|
||||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
|
||||||
trigger_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
cron_expression = sqlalchemy.Column(sqlalchemy.String(100), nullable=True)
|
|
||||||
next_run_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
last_run_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"""Add workflow tables and update bot binding fields"""
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from .. import migration
|
|
||||||
|
|
||||||
|
|
||||||
@migration.migration_class(26)
|
|
||||||
class DBMigrateWorkflowTables(migration.DBMigration):
|
|
||||||
"""Add workflow tables and update bot binding fields"""
|
|
||||||
|
|
||||||
async def upgrade(self):
|
|
||||||
# Create workflows table
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS workflows (
|
|
||||||
uuid VARCHAR(255) PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
emoji VARCHAR(10) DEFAULT '🔄',
|
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
is_enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
definition JSON NOT NULL DEFAULT '{}',
|
|
||||||
global_config JSON NOT NULL DEFAULT '{}',
|
|
||||||
extensions_preferences JSON NOT NULL DEFAULT '{"enable_all_plugins": true, "enable_all_mcp_servers": true, "plugins": [], "mcp_servers": []}',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create workflow_versions table
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS workflow_versions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
workflow_uuid VARCHAR(255) NOT NULL,
|
|
||||||
version INTEGER NOT NULL,
|
|
||||||
definition JSON NOT NULL,
|
|
||||||
global_config JSON NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by VARCHAR(255),
|
|
||||||
UNIQUE(workflow_uuid, version)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create workflow_triggers table
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS workflow_triggers (
|
|
||||||
uuid VARCHAR(255) PRIMARY KEY,
|
|
||||||
workflow_uuid VARCHAR(255) NOT NULL,
|
|
||||||
type VARCHAR(50) NOT NULL,
|
|
||||||
config JSON NOT NULL DEFAULT '{}',
|
|
||||||
is_enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
priority INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create workflow_executions table
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS workflow_executions (
|
|
||||||
uuid VARCHAR(255) PRIMARY KEY,
|
|
||||||
workflow_uuid VARCHAR(255) NOT NULL,
|
|
||||||
workflow_version INTEGER NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL,
|
|
||||||
trigger_type VARCHAR(50),
|
|
||||||
trigger_data JSON,
|
|
||||||
variables JSON,
|
|
||||||
start_time TIMESTAMP,
|
|
||||||
end_time TIMESTAMP,
|
|
||||||
error TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create workflow_node_executions table
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS workflow_node_executions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
execution_uuid VARCHAR(255) NOT NULL,
|
|
||||||
node_id VARCHAR(100) NOT NULL,
|
|
||||||
node_type VARCHAR(50) NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL,
|
|
||||||
inputs JSON,
|
|
||||||
outputs JSON,
|
|
||||||
start_time TIMESTAMP,
|
|
||||||
end_time TIMESTAMP,
|
|
||||||
error TEXT,
|
|
||||||
retry_count INTEGER NOT NULL DEFAULT 0
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create workflow_scheduled_jobs table
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS workflow_scheduled_jobs (
|
|
||||||
uuid VARCHAR(255) PRIMARY KEY,
|
|
||||||
trigger_uuid VARCHAR(255) NOT NULL,
|
|
||||||
cron_expression VARCHAR(100),
|
|
||||||
next_run_time TIMESTAMP,
|
|
||||||
last_run_time TIMESTAMP,
|
|
||||||
is_enabled BOOLEAN NOT NULL DEFAULT 1
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('CREATE INDEX IF NOT EXISTS idx_workflow_versions_uuid ON workflow_versions(workflow_uuid)')
|
|
||||||
)
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('CREATE INDEX IF NOT EXISTS idx_workflow_triggers_uuid ON workflow_triggers(workflow_uuid)')
|
|
||||||
)
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_workflow_executions_uuid ON workflow_executions(workflow_uuid)'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_workflow_node_executions_uuid ON workflow_node_executions(execution_uuid)'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_workflow_scheduled_jobs_trigger ON workflow_scheduled_jobs(trigger_uuid)'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update bots table: add binding_type column (default to 'pipeline' for backward compatibility)
|
|
||||||
# Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns)
|
|
||||||
try:
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT binding_type FROM bots LIMIT 1'))
|
|
||||||
except Exception:
|
|
||||||
# Column doesn't exist, add it
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("ALTER TABLE bots ADD COLUMN binding_type VARCHAR(20) NOT NULL DEFAULT 'pipeline'")
|
|
||||||
)
|
|
||||||
|
|
||||||
async def downgrade(self):
|
|
||||||
# Drop tables in reverse order
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_scheduled_jobs'))
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_node_executions'))
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_executions'))
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_triggers'))
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_versions'))
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflows'))
|
|
||||||
|
|
||||||
# Remove binding_type column from bots (SQLite doesn't support DROP COLUMN directly)
|
|
||||||
# This would need a table recreation in SQLite, so we'll skip it in downgrade
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Add binding_uuid field to bots table and migrate data"""
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from .. import migration
|
|
||||||
|
|
||||||
|
|
||||||
@migration.migration_class(27)
|
|
||||||
class DBMigrateBotBindingFields(migration.DBMigration):
|
|
||||||
"""Add binding_uuid field to bots table and migrate existing data"""
|
|
||||||
|
|
||||||
async def upgrade(self):
|
|
||||||
# Add binding_uuid column to bots table
|
|
||||||
# Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns)
|
|
||||||
try:
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT binding_uuid FROM bots LIMIT 1'))
|
|
||||||
except Exception:
|
|
||||||
# Column doesn't exist, add it
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('ALTER TABLE bots ADD COLUMN binding_uuid VARCHAR(64)')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Migrate existing data: copy use_pipeline_uuid to binding_uuid for records
|
|
||||||
# that have a pipeline bound and binding_uuid is not set yet
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("""
|
|
||||||
UPDATE bots
|
|
||||||
SET binding_uuid = use_pipeline_uuid
|
|
||||||
WHERE use_pipeline_uuid IS NOT NULL
|
|
||||||
AND use_pipeline_uuid != ''
|
|
||||||
AND (binding_uuid IS NULL OR binding_uuid = '')
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure binding_type is 'pipeline' for records that were migrated
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text("""
|
|
||||||
UPDATE bots
|
|
||||||
SET binding_type = 'pipeline'
|
|
||||||
WHERE binding_uuid IS NOT NULL
|
|
||||||
AND binding_uuid != ''
|
|
||||||
AND (binding_type IS NULL OR binding_type = '')
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
|
|
||||||
async def downgrade(self):
|
|
||||||
# SQLite doesn't support DROP COLUMN directly
|
|
||||||
# This would need a table recreation in SQLite, so we'll skip it in downgrade
|
|
||||||
# The column will remain but won't be used
|
|
||||||
pass
|
|
||||||
@@ -275,7 +275,6 @@ class MessageAggregator:
|
|||||||
message_chain=merged_chain,
|
message_chain=merged_chain,
|
||||||
adapter=base_msg.adapter,
|
adapter=base_msg.adapter,
|
||||||
pipeline_uuid=base_msg.pipeline_uuid,
|
pipeline_uuid=base_msg.pipeline_uuid,
|
||||||
routed_by_rule=any(msg.routed_by_rule for msg in messages),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def flush_all(self) -> None:
|
async def flush_all(self) -> None:
|
||||||
|
|||||||
@@ -76,10 +76,6 @@ class LongTextProcessStage(stage.PipelineStage):
|
|||||||
self.ap.logger.debug('Long message processing strategy is not set, skip long message processing.')
|
self.ap.logger.debug('Long message processing strategy is not set, skip long message processing.')
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
if not query.resp_message_chain:
|
|
||||||
self.ap.logger.debug('Response message chain is empty, skip long message processing.')
|
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
|
||||||
|
|
||||||
# 检查是否包含非 Plain 组件
|
# 检查是否包含非 Plain 组件
|
||||||
contains_non_plain = False
|
contains_non_plain = False
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
import langbot_plugin.api.entities.events as events
|
import langbot_plugin.api.entities.events as events
|
||||||
from ..utils import importutil
|
from ..utils import importutil
|
||||||
from .config import coerce_pipeline_config
|
from .config_coercion import coerce_pipeline_config
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
@@ -284,9 +284,9 @@ class RuntimePipeline:
|
|||||||
# Record query start and store message_id
|
# Record query start and store message_id
|
||||||
message_id = ''
|
message_id = ''
|
||||||
try:
|
try:
|
||||||
from . import monitor
|
from . import monitoring_helper
|
||||||
|
|
||||||
message_id = await monitor.MonitoringHelper.record_query_start(
|
message_id = await monitoring_helper.MonitoringHelper.record_query_start(
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
query=query,
|
query=query,
|
||||||
bot_id=query.bot_uuid or 'unknown',
|
bot_id=query.bot_uuid or 'unknown',
|
||||||
@@ -338,7 +338,7 @@ class RuntimePipeline:
|
|||||||
# Record query success only if no error occurred during processing
|
# Record query success only if no error occurred during processing
|
||||||
if not query.variables.get('_monitoring_has_error', False):
|
if not query.variables.get('_monitoring_has_error', False):
|
||||||
try:
|
try:
|
||||||
await monitor.MonitoringHelper.record_query_success(
|
await monitoring_helper.MonitoringHelper.record_query_success(
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
query=query,
|
query=query,
|
||||||
@@ -348,7 +348,7 @@ class RuntimePipeline:
|
|||||||
|
|
||||||
# Record bot response message
|
# Record bot response message
|
||||||
try:
|
try:
|
||||||
await monitor.MonitoringHelper.record_query_response(
|
await monitoring_helper.MonitoringHelper.record_query_response(
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
query=query,
|
query=query,
|
||||||
bot_id=query.bot_uuid or 'unknown',
|
bot_id=query.bot_uuid or 'unknown',
|
||||||
@@ -367,9 +367,9 @@ class RuntimePipeline:
|
|||||||
|
|
||||||
# Record query error
|
# Record query error
|
||||||
try:
|
try:
|
||||||
from . import monitor
|
from . import monitoring_helper
|
||||||
|
|
||||||
await monitor.MonitoringHelper.record_query_error(
|
await monitoring_helper.MonitoringHelper.record_query_error(
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
query=query,
|
query=query,
|
||||||
bot_id=query.bot_uuid or 'unknown',
|
bot_id=query.bot_uuid or 'unknown',
|
||||||
@@ -384,8 +384,7 @@ class RuntimePipeline:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
self.ap.logger.debug(f'Query {query.query_id} processed')
|
self.ap.logger.debug(f'Query {query.query_id} processed')
|
||||||
# Use pop with default to avoid KeyError if query was never cached
|
del self.ap.query_pool.cached_queries[query.query_id]
|
||||||
self.ap.query_pool.cached_queries.pop(query.query_id, None)
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineManager:
|
class PipelineManager:
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ class QueryPool:
|
|||||||
self.cached_queries[query_id] = query
|
self.cached_queries[query_id] = query
|
||||||
self.query_id_counter += 1
|
self.query_id_counter += 1
|
||||||
self.condition.notify_all()
|
self.condition.notify_all()
|
||||||
return query
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
await self.pool_lock.acquire()
|
await self.pool_lock.acquire()
|
||||||
|
|||||||
@@ -75,27 +75,6 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.bot_uuid,
|
query.bot_uuid,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expire externally managed conversation ids after the conversation has
|
|
||||||
# been idle for longer than the configured conversation expire time.
|
|
||||||
# The idle window is measured from the last preprocess/update time, not
|
|
||||||
# from the conversation creation time.
|
|
||||||
conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None)
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
if conversation_expire_time is not None and conversation_expire_time > 0:
|
|
||||||
last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None)
|
|
||||||
if last_update_time is not None:
|
|
||||||
conversation_idle_time = now.timestamp() - last_update_time.timestamp()
|
|
||||||
if conversation_idle_time > conversation_expire_time:
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'Conversation({query.query_id}) is expired (idle: {conversation_idle_time}s), create new conversation'
|
|
||||||
)
|
|
||||||
conversation.uuid = None
|
|
||||||
|
|
||||||
# Treat every preprocess pass as a conversation activity update. This
|
|
||||||
# makes future expiry checks use the latest incoming message/preprocess
|
|
||||||
# time instead of the first message/creation time.
|
|
||||||
conversation.update_time = now
|
|
||||||
|
|
||||||
# 设置query
|
# 设置query
|
||||||
query.session = session
|
query.session = session
|
||||||
query.prompt = conversation.prompt.copy()
|
query.prompt = conversation.prompt.copy()
|
||||||
@@ -181,10 +160,7 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
elif me.url:
|
elif me.url:
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
||||||
elif isinstance(me, platform_message.File):
|
elif isinstance(me, platform_message.File):
|
||||||
if me.base64:
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||||
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, me.name))
|
|
||||||
elif me.url:
|
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
|
||||||
elif isinstance(me, platform_message.Quote) and quote_msg:
|
elif isinstance(me, platform_message.Quote) and quote_msg:
|
||||||
for msg in me.origin:
|
for msg in me.origin:
|
||||||
if isinstance(msg, platform_message.Plain):
|
if isinstance(msg, platform_message.Plain):
|
||||||
@@ -196,10 +172,7 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
if msg.base64 is not None:
|
if msg.base64 is not None:
|
||||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
||||||
elif isinstance(msg, platform_message.File):
|
elif isinstance(msg, platform_message.File):
|
||||||
if msg.base64:
|
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
|
||||||
content_list.append(provider_message.ContentElement.from_file_base64(msg.base64, msg.name))
|
|
||||||
elif msg.url:
|
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
|
|
||||||
elif isinstance(msg, platform_message.Voice):
|
elif isinstance(msg, platform_message.Voice):
|
||||||
if msg.base64:
|
if msg.base64:
|
||||||
content_list.append(
|
content_list.append(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -53,24 +54,29 @@ class RuntimeBot:
|
|||||||
self.task_context = taskmgr.TaskContext()
|
self.task_context = taskmgr.TaskContext()
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match_operator(actual: str, operator: str, expected: str) -> bool:
|
||||||
|
"""Evaluate a single operator condition."""
|
||||||
|
if operator == 'eq':
|
||||||
|
return actual == expected
|
||||||
|
elif operator == 'neq':
|
||||||
|
return actual != expected
|
||||||
|
elif operator == 'contains':
|
||||||
|
return expected in actual
|
||||||
|
elif operator == 'not_contains':
|
||||||
|
return expected not in actual
|
||||||
|
elif operator == 'starts_with':
|
||||||
|
return actual.startswith(expected)
|
||||||
|
elif operator == 'regex':
|
||||||
|
try:
|
||||||
|
return bool(re.search(expected, actual))
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
PIPELINE_DISCARD = '__discard__'
|
PIPELINE_DISCARD = '__discard__'
|
||||||
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
||||||
|
|
||||||
def get_binding_info(self) -> tuple[str, str | None]:
|
|
||||||
"""Get the binding type and UUID for this bot.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: (binding_type, binding_uuid) where binding_type is 'pipeline' or 'workflow'
|
|
||||||
"""
|
|
||||||
binding_type = getattr(self.bot_entity, 'binding_type', 'pipeline') or 'pipeline'
|
|
||||||
binding_uuid = getattr(self.bot_entity, 'binding_uuid', None)
|
|
||||||
|
|
||||||
# Fallback to use_pipeline_uuid for backward compatibility
|
|
||||||
if not binding_uuid and binding_type == 'pipeline':
|
|
||||||
binding_uuid = self.bot_entity.use_pipeline_uuid
|
|
||||||
|
|
||||||
return binding_type, binding_uuid
|
|
||||||
|
|
||||||
def resolve_pipeline_uuid(
|
def resolve_pipeline_uuid(
|
||||||
self,
|
self,
|
||||||
launcher_type: str,
|
launcher_type: str,
|
||||||
@@ -78,26 +84,56 @@ class RuntimeBot:
|
|||||||
message_text: str,
|
message_text: str,
|
||||||
message_element_types: list[str] | None = None,
|
message_element_types: list[str] | None = None,
|
||||||
) -> tuple[str | None, bool]:
|
) -> tuple[str | None, bool]:
|
||||||
"""Resolve pipeline UUID for message processing.
|
"""Resolve pipeline UUID based on routing rules.
|
||||||
|
|
||||||
NOTE: Routing rules have been removed. Bot now directly binds to a
|
Rules are evaluated in order; first match wins.
|
||||||
Pipeline or Workflow. This method is kept for backward compatibility
|
Falls back to use_pipeline_uuid if no rule matches.
|
||||||
but only returns the direct binding.
|
|
||||||
|
Rule types:
|
||||||
|
- launcher_type: session type ("person" / "group")
|
||||||
|
- launcher_id: session / group id
|
||||||
|
- message_content: message text content
|
||||||
|
- message_has_element: message contains element of given type
|
||||||
|
(Image, Voice, File, Forward, Face, At, AtAll, Quote)
|
||||||
|
Operators: eq (has), neq (doesn't have)
|
||||||
|
|
||||||
|
Operators: eq, neq, contains, not_contains, starts_with, regex
|
||||||
|
|
||||||
|
When pipeline_uuid is ``__discard__``, the message should be
|
||||||
|
silently dropped by the caller.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is always False
|
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
|
||||||
as routing rules are no longer used.
|
when a routing rule matched, False when falling back to default.
|
||||||
"""
|
"""
|
||||||
binding_type, binding_uuid = self.get_binding_info()
|
rules = self.bot_entity.pipeline_routing_rules or []
|
||||||
|
element_type_set = set(message_element_types or [])
|
||||||
|
|
||||||
# If bound to workflow, return None for pipeline_uuid
|
for rule in rules:
|
||||||
# The caller should check binding_type and handle accordingly
|
rule_type = rule.get('type')
|
||||||
if binding_type == 'workflow':
|
operator = rule.get('operator', 'eq')
|
||||||
# For workflow binding, we still need to return something
|
rule_value = rule.get('value', '')
|
||||||
# The actual workflow handling should be done by the caller
|
target_uuid = rule.get('pipeline_uuid')
|
||||||
return None, False
|
if not rule_type or not target_uuid:
|
||||||
|
continue
|
||||||
|
|
||||||
return binding_uuid, False
|
if rule_type == 'launcher_type':
|
||||||
|
if self._match_operator(launcher_type, operator, rule_value):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'launcher_id':
|
||||||
|
if self._match_operator(str(launcher_id), operator, str(rule_value)):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'message_content':
|
||||||
|
if self._match_operator(message_text, operator, rule_value):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'message_has_element':
|
||||||
|
has_element = rule_value in element_type_set
|
||||||
|
if operator == 'eq' and has_element:
|
||||||
|
return target_uuid, True
|
||||||
|
elif operator == 'neq' and not has_element:
|
||||||
|
return target_uuid, True
|
||||||
|
|
||||||
|
return self.bot_entity.use_pipeline_uuid, False
|
||||||
|
|
||||||
async def _record_discarded_message(
|
async def _record_discarded_message(
|
||||||
self,
|
self,
|
||||||
@@ -487,7 +523,7 @@ class PlatformManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def remove_bot(self, bot_uuid: str):
|
async def remove_bot(self, bot_uuid: str):
|
||||||
for bot in self.bots[:]:
|
for bot in self.bots:
|
||||||
if bot.bot_entity.uuid == bot_uuid:
|
if bot.bot_entity.uuid == bot_uuid:
|
||||||
if bot.enable:
|
if bot.enable:
|
||||||
await bot.shutdown()
|
await bot.shutdown()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import typing
|
|||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
|
||||||
|
|
||||||
import aiocqhttp
|
import aiocqhttp
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -294,29 +293,6 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
elif msg.type == 'dice':
|
elif msg.type == 'dice':
|
||||||
face_id = msg.data['result']
|
face_id = msg.data['result']
|
||||||
yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子'))
|
yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子'))
|
||||||
elif msg.type == 'json':
|
|
||||||
try:
|
|
||||||
raw = msg.data.get('data', {})
|
|
||||||
if isinstance(raw, str):
|
|
||||||
raw = json.loads(raw)
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
_meta = raw.get('meta', {}) or {}
|
|
||||||
if isinstance(_meta, dict):
|
|
||||||
_detail = _meta.get('detail_1') or _meta.get('music') or _meta.get('news') or {}
|
|
||||||
else:
|
|
||||||
_detail = {}
|
|
||||||
if isinstance(_detail, dict):
|
|
||||||
preview = _detail.get('preview', '')
|
|
||||||
title = _detail.get('desc', '') or _detail.get('title', '')
|
|
||||||
url = _detail.get('qqdocurl', '') or _detail.get('jumpUrl', '')
|
|
||||||
else:
|
|
||||||
preview = title = url = ''
|
|
||||||
text = ' '.join([f'[{raw.get("app", "")}]', preview, title, url]).strip()
|
|
||||||
yiri_msg_list.append(platform_message.Plain(text=text or '[收到一张JSON卡片]'))
|
|
||||||
else:
|
|
||||||
yiri_msg_list.append(platform_message.Plain(text=str(raw)))
|
|
||||||
except Exception:
|
|
||||||
yiri_msg_list.append(platform_message.Plain(text='[收到一张JSON卡片]'))
|
|
||||||
|
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|
||||||
|
|||||||
@@ -19,18 +19,6 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/dingtalk
|
en: https://link.langbot.app/en/platforms/dingtalk
|
||||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||||
config:
|
config:
|
||||||
- name: one-click-create
|
|
||||||
label:
|
|
||||||
en_US: One-Click Create App
|
|
||||||
zh_Hans: 一键创建应用
|
|
||||||
zh_Hant: 一鍵建立應用
|
|
||||||
description:
|
|
||||||
en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually."
|
|
||||||
zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。"
|
|
||||||
zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。"
|
|
||||||
type: qr-code-login
|
|
||||||
login_platform: dingtalk
|
|
||||||
required: false
|
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
@@ -52,10 +40,6 @@ spec:
|
|||||||
en_US: Robot Code
|
en_US: Robot Code
|
||||||
zh_Hans: 机器人代码
|
zh_Hans: 机器人代码
|
||||||
zh_Hant: 機器人代碼
|
zh_Hant: 機器人代碼
|
||||||
description:
|
|
||||||
en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration."
|
|
||||||
zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。"
|
|
||||||
zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。"
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -1025,90 +1025,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return api_client
|
return api_client
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
|
pass
|
||||||
|
|
||||||
# Map standard target_type to Feishu receive_id_type
|
|
||||||
if target_type == 'person':
|
|
||||||
receive_id_type = 'open_id'
|
|
||||||
elif target_type == 'group':
|
|
||||||
receive_id_type = 'chat_id'
|
|
||||||
else:
|
|
||||||
receive_id_type = target_type
|
|
||||||
|
|
||||||
# Send text message if there are text elements
|
|
||||||
if text_elements:
|
|
||||||
needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
|
|
||||||
|
|
||||||
if needs_post:
|
|
||||||
msg_type = 'post'
|
|
||||||
final_content = json.dumps(
|
|
||||||
{
|
|
||||||
'zh_Hans': {
|
|
||||||
'title': '',
|
|
||||||
'content': text_elements,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg_type = 'text'
|
|
||||||
parts = []
|
|
||||||
for paragraph in text_elements:
|
|
||||||
para_text = ''.join(ele.get('text', '') for ele in paragraph)
|
|
||||||
if para_text:
|
|
||||||
parts.append(para_text)
|
|
||||||
final_content = json.dumps({'text': '\n\n'.join(parts)})
|
|
||||||
|
|
||||||
request: CreateMessageRequest = (
|
|
||||||
CreateMessageRequest.builder()
|
|
||||||
.receive_id_type(receive_id_type)
|
|
||||||
.request_body(
|
|
||||||
CreateMessageRequestBody.builder()
|
|
||||||
.receive_id(target_id)
|
|
||||||
.content(final_content)
|
|
||||||
.msg_type(msg_type)
|
|
||||||
.uuid(str(uuid.uuid4()))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
|
|
||||||
app_access_token = self.get_app_access_token()
|
|
||||||
req_opt: RequestOption = (
|
|
||||||
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
|
|
||||||
)
|
|
||||||
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
|
|
||||||
|
|
||||||
if not response.success():
|
|
||||||
raise Exception(
|
|
||||||
f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send media messages separately (image, audio, file, etc.)
|
|
||||||
for media in media_items:
|
|
||||||
request: CreateMessageRequest = (
|
|
||||||
CreateMessageRequest.builder()
|
|
||||||
.receive_id_type(receive_id_type)
|
|
||||||
.request_body(
|
|
||||||
CreateMessageRequestBody.builder()
|
|
||||||
.receive_id(target_id)
|
|
||||||
.content(json.dumps(media['content']))
|
|
||||||
.msg_type(media['msg_type'])
|
|
||||||
.uuid(str(uuid.uuid4()))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
|
|
||||||
app_access_token = self.get_app_access_token()
|
|
||||||
req_opt: RequestOption = (
|
|
||||||
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
|
|
||||||
)
|
|
||||||
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
|
|
||||||
|
|
||||||
if not response.success():
|
|
||||||
raise Exception(
|
|
||||||
f'client.im.v1.message.create ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
|
||||||
)
|
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
is_stream = False
|
is_stream = False
|
||||||
|
|||||||
@@ -23,20 +23,6 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://link.langbot.app/en/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://link.langbot.app/ja/platforms/lark
|
||||||
config:
|
config:
|
||||||
- name: one-click-create
|
|
||||||
label:
|
|
||||||
en_US: One-Click Create App
|
|
||||||
zh_Hans: 一键创建应用
|
|
||||||
zh_Hant: 一鍵建立應用
|
|
||||||
ja_JP: ワンクリックでアプリ作成
|
|
||||||
description:
|
|
||||||
en_US: Scan QR code to automatically create a Feishu app and fill in credentials
|
|
||||||
zh_Hans: 扫码自动创建飞书应用并填写凭据
|
|
||||||
zh_Hant: 掃碼自動建立飛書應用並填寫憑證
|
|
||||||
ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力
|
|
||||||
type: qr-code-login
|
|
||||||
login_platform: feishu
|
|
||||||
required: false
|
|
||||||
- name: app_id
|
- name: app_id
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,693 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import asyncio
|
|
||||||
import traceback
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
|
|
||||||
import nio
|
|
||||||
|
|
||||||
from langbot.pkg.utils import httpclient
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|
||||||
@staticmethod
|
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain, client: nio.AsyncClient) -> list[dict]:
|
|
||||||
components = []
|
|
||||||
for component in message_chain:
|
|
||||||
if isinstance(component, platform_message.Plain):
|
|
||||||
components.append({'type': 'text', 'text': component.text})
|
|
||||||
elif isinstance(component, platform_message.Image):
|
|
||||||
image_bytes = None
|
|
||||||
if component.base64:
|
|
||||||
b64_data = component.base64
|
|
||||||
if ';base64,' in b64_data:
|
|
||||||
b64_data = b64_data.split(';base64,', 1)[1]
|
|
||||||
image_bytes = base64.b64decode(b64_data)
|
|
||||||
elif component.url:
|
|
||||||
session = httpclient.get_session()
|
|
||||||
async with session.get(component.url) as response:
|
|
||||||
image_bytes = await response.read()
|
|
||||||
elif component.path:
|
|
||||||
with open(component.path, 'rb') as f:
|
|
||||||
image_bytes = f.read()
|
|
||||||
if image_bytes:
|
|
||||||
resp = await client.upload(image_bytes, content_type='image/png')
|
|
||||||
if isinstance(resp, nio.UploadResponse):
|
|
||||||
components.append({'type': 'image', 'mxc_url': resp.content_uri})
|
|
||||||
elif isinstance(component, platform_message.File):
|
|
||||||
file_bytes = None
|
|
||||||
if component.base64:
|
|
||||||
b64_data = component.base64
|
|
||||||
if ';base64,' in b64_data:
|
|
||||||
b64_data = b64_data.split(';base64,', 1)[1]
|
|
||||||
file_bytes = base64.b64decode(b64_data)
|
|
||||||
elif component.url:
|
|
||||||
session = httpclient.get_session()
|
|
||||||
async with session.get(component.url) as response:
|
|
||||||
file_bytes = await response.read()
|
|
||||||
elif component.path:
|
|
||||||
with open(component.path, 'rb') as f:
|
|
||||||
file_bytes = f.read()
|
|
||||||
if file_bytes:
|
|
||||||
file_name = getattr(component, 'name', None) or 'file'
|
|
||||||
resp = await client.upload(file_bytes, content_type='application/octet-stream', filename=file_name)
|
|
||||||
if isinstance(resp, nio.UploadResponse):
|
|
||||||
components.append(
|
|
||||||
{
|
|
||||||
'type': 'file',
|
|
||||||
'mxc_url': resp.content_uri,
|
|
||||||
'filename': file_name,
|
|
||||||
'size': len(file_bytes),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif isinstance(component, platform_message.Forward):
|
|
||||||
for node in component.node_list:
|
|
||||||
components.extend(await MatrixMessageConverter.yiri2target(node.message_chain, client))
|
|
||||||
return components
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def target2yiri(event: nio.RoomMessageText | nio.RoomMessageImage, client: nio.AsyncClient, bot_user_id: str):
|
|
||||||
message_components = []
|
|
||||||
|
|
||||||
if isinstance(event, nio.RoomMessageText):
|
|
||||||
text = event.body
|
|
||||||
if bot_user_id and bot_user_id in text:
|
|
||||||
message_components.append(platform_message.At(target=bot_user_id))
|
|
||||||
text = text.replace(bot_user_id, '').strip()
|
|
||||||
message_components.append(platform_message.Plain(text=text))
|
|
||||||
|
|
||||||
elif isinstance(event, nio.RoomMessageImage):
|
|
||||||
mxc_url = event.url
|
|
||||||
if mxc_url:
|
|
||||||
resp = await client.download(mxc_url)
|
|
||||||
if isinstance(resp, nio.DownloadResponse):
|
|
||||||
b64 = base64.b64encode(resp.body).decode('utf-8')
|
|
||||||
content_type = resp.content_type or 'image/png'
|
|
||||||
message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}'))
|
|
||||||
if event.body:
|
|
||||||
message_components.append(platform_message.Plain(text=event.body))
|
|
||||||
|
|
||||||
return platform_message.MessageChain(message_components)
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|
||||||
@staticmethod
|
|
||||||
async def yiri2target(event: platform_events.MessageEvent):
|
|
||||||
return event.source_platform_object
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def target2yiri(
|
|
||||||
event: nio.RoomMessageText | nio.RoomMessageImage,
|
|
||||||
room: nio.MatrixRoom,
|
|
||||||
client: nio.AsyncClient,
|
|
||||||
bot_user_id: str,
|
|
||||||
bridge_user_ids: list[str] | None = None,
|
|
||||||
):
|
|
||||||
lb_message = await MatrixMessageConverter.target2yiri(event, client, bot_user_id)
|
|
||||||
|
|
||||||
# Determine if this is a direct/private chat or a group chat.
|
|
||||||
# Exclude bot itself and bridge bots, count remaining real users.
|
|
||||||
exclude_ids = {bot_user_id}
|
|
||||||
if bridge_user_ids:
|
|
||||||
exclude_ids.update(bridge_user_ids)
|
|
||||||
real_users = [uid for uid in room.users if uid not in exclude_ids]
|
|
||||||
is_direct = len(real_users) <= 1
|
|
||||||
|
|
||||||
if is_direct:
|
|
||||||
return platform_events.FriendMessage(
|
|
||||||
sender=platform_entities.Friend(
|
|
||||||
id=event.sender,
|
|
||||||
nickname=room.user_name(event.sender) or event.sender,
|
|
||||||
remark='',
|
|
||||||
),
|
|
||||||
message_chain=lb_message,
|
|
||||||
time=event.server_timestamp / 1000.0,
|
|
||||||
source_platform_object={'event': event, 'room': room},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return platform_events.GroupMessage(
|
|
||||||
sender=platform_entities.GroupMember(
|
|
||||||
id=event.sender,
|
|
||||||
member_name=room.user_name(event.sender) or event.sender,
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
group=platform_entities.Group(
|
|
||||||
id=room.room_id,
|
|
||||||
name=room.display_name or room.room_id,
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
),
|
|
||||||
special_title='',
|
|
||||||
),
|
|
||||||
message_chain=lb_message,
|
|
||||||
time=event.server_timestamp / 1000.0,
|
|
||||||
source_platform_object={'event': event, 'room': room},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BridgeState:
|
|
||||||
"""Per-bridge runtime state."""
|
|
||||||
|
|
||||||
def __init__(self, user_id: str, login_command: str, logout_command: str, success_keyword: str, check_command: str):
|
|
||||||
self.user_id = user_id
|
|
||||||
self.login_command = login_command
|
|
||||||
self.logout_command = logout_command
|
|
||||||
self.success_keyword = success_keyword
|
|
||||||
self.check_command = check_command or login_command
|
|
||||||
self.logged_in = False
|
|
||||||
self.dm_room_id: str | None = None
|
|
||||||
self.login_task: asyncio.Task | None = None
|
|
||||||
self.check_task: asyncio.Task | None = None
|
|
||||||
self.check_responded = False
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|
||||||
client: typing.Any = None
|
|
||||||
message_converter: MatrixMessageConverter = MatrixMessageConverter()
|
|
||||||
event_converter: MatrixEventConverter = MatrixEventConverter()
|
|
||||||
config: dict
|
|
||||||
listeners: typing.Dict[typing.Type[platform_events.Event], typing.Callable] = {}
|
|
||||||
_running: bool = False
|
|
||||||
_initial_sync_done: bool = False
|
|
||||||
_bridges: list = []
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
|
||||||
homeserver_url = config.get('homeserver_url', '')
|
|
||||||
access_token = config.get('access_token', '')
|
|
||||||
user_id = config.get('user_id', '')
|
|
||||||
|
|
||||||
if not homeserver_url or not access_token or not user_id:
|
|
||||||
raise ValueError('Matrix 机器人缺少必要配置项 (homeserver_url, user_id, access_token)')
|
|
||||||
|
|
||||||
client = nio.AsyncClient(homeserver_url, user_id)
|
|
||||||
client.access_token = access_token
|
|
||||||
client.user_id = user_id
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
config=config,
|
|
||||||
logger=logger,
|
|
||||||
bot_account_id=user_id,
|
|
||||||
client=client,
|
|
||||||
listeners={},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse bridges config AFTER super().__init__() to avoid Pydantic resetting _bridges
|
|
||||||
self._bridges = []
|
|
||||||
bridges_raw = config.get('bridges', '')
|
|
||||||
if bridges_raw:
|
|
||||||
if isinstance(bridges_raw, str):
|
|
||||||
try:
|
|
||||||
bridges_list = json.loads(bridges_raw)
|
|
||||||
except (json.JSONDecodeError, TypeError) as e:
|
|
||||||
raise ValueError(f'bridges 配置 JSON 解析失败: {e}\n原始值: {bridges_raw}')
|
|
||||||
else:
|
|
||||||
bridges_list = bridges_raw
|
|
||||||
for b in bridges_list:
|
|
||||||
if isinstance(b, dict) and b.get('user_id', '').strip():
|
|
||||||
self._bridges.append(
|
|
||||||
BridgeState(
|
|
||||||
user_id=b['user_id'].strip(),
|
|
||||||
login_command=b.get('login_command', '').strip(),
|
|
||||||
logout_command=b.get('logout_command', '').strip(),
|
|
||||||
success_keyword=b.get('success_keyword', 'Successfully logged in').strip(),
|
|
||||||
check_command=b.get('check_command', '').strip(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Backward compatibility: old single-bridge config
|
|
||||||
if not self._bridges:
|
|
||||||
old_user_id = config.get('bridge_user_id', '').strip()
|
|
||||||
old_command = config.get('bridge_login_command', '').strip()
|
|
||||||
old_keyword = config.get('bridge_login_success_keyword', 'Successfully logged in').strip()
|
|
||||||
old_check = config.get('bridge_check_command', '').strip()
|
|
||||||
old_logout = config.get('bridge_logout_command', '').strip()
|
|
||||||
if old_user_id:
|
|
||||||
self._bridges.append(
|
|
||||||
BridgeState(
|
|
||||||
user_id=old_user_id,
|
|
||||||
login_command=old_command,
|
|
||||||
logout_command=old_logout,
|
|
||||||
success_keyword=old_keyword,
|
|
||||||
check_command=old_check,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
|
||||||
components = await self.message_converter.yiri2target(message, self.client)
|
|
||||||
for component in components:
|
|
||||||
await self._send_component(target_id, component)
|
|
||||||
|
|
||||||
async def reply_message(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
):
|
|
||||||
source_obj = message_source.source_platform_object
|
|
||||||
room_id = source_obj['room'].room_id
|
|
||||||
components = await self.message_converter.yiri2target(message, self.client)
|
|
||||||
|
|
||||||
for component in components:
|
|
||||||
if quote_origin:
|
|
||||||
original_event = source_obj['event']
|
|
||||||
await self._send_component(room_id, component, reply_to=original_event.event_id)
|
|
||||||
else:
|
|
||||||
await self._send_component(room_id, component)
|
|
||||||
|
|
||||||
async def _send_component(self, room_id: str, component: dict, reply_to: str | None = None):
|
|
||||||
content = {}
|
|
||||||
if component['type'] == 'text':
|
|
||||||
content = {
|
|
||||||
'msgtype': 'm.text',
|
|
||||||
'body': component['text'],
|
|
||||||
}
|
|
||||||
elif component['type'] == 'image':
|
|
||||||
content = {
|
|
||||||
'msgtype': 'm.image',
|
|
||||||
'body': 'image.png',
|
|
||||||
'url': component['mxc_url'],
|
|
||||||
}
|
|
||||||
elif component['type'] == 'file':
|
|
||||||
content = {
|
|
||||||
'msgtype': 'm.file',
|
|
||||||
'body': component.get('filename', 'file'),
|
|
||||||
'url': component['mxc_url'],
|
|
||||||
'info': {'size': component.get('size', 0)},
|
|
||||||
}
|
|
||||||
|
|
||||||
if reply_to and content:
|
|
||||||
content['m.relates_to'] = {
|
|
||||||
'm.in_reply_to': {'event_id': reply_to},
|
|
||||||
}
|
|
||||||
|
|
||||||
if content:
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_listener(
|
|
||||||
self,
|
|
||||||
event_type: typing.Type[platform_events.Event],
|
|
||||||
callback: typing.Callable[
|
|
||||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
|
||||||
],
|
|
||||||
):
|
|
||||||
self.listeners[event_type] = callback
|
|
||||||
|
|
||||||
async def run_async(self):
|
|
||||||
self._running = True
|
|
||||||
await self.logger.info('Matrix adapter starting...')
|
|
||||||
|
|
||||||
# Debug: log bridge parsing result
|
|
||||||
bridges_raw = self.config.get('bridges', '')
|
|
||||||
await self.logger.debug(f'bridges config raw: type={type(bridges_raw).__name__}, repr={repr(bridges_raw)}')
|
|
||||||
await self.logger.debug(
|
|
||||||
f'parsed _bridges count: {len(self._bridges)}, ids: {[b.user_id for b in self._bridges]}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Collect all bridge bot user IDs for filtering
|
|
||||||
_bridge_user_ids = [b.user_id for b in self._bridges]
|
|
||||||
_bridge_user_id_set = set(_bridge_user_ids)
|
|
||||||
|
|
||||||
# Auto-join invited rooms
|
|
||||||
async def on_invite(room: nio.MatrixRoom, event: nio.InviteMemberEvent):
|
|
||||||
if event.membership == 'invite' and event.state_key == self.client.user_id:
|
|
||||||
await self.client.join(room.room_id)
|
|
||||||
await self.logger.debug(f'Auto-joined room: {room.display_name or room.room_id}')
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_invite, nio.InviteMemberEvent)
|
|
||||||
|
|
||||||
# Handle text messages
|
|
||||||
async def on_message(room: nio.MatrixRoom, event: nio.RoomMessageText):
|
|
||||||
if not self._initial_sync_done:
|
|
||||||
return
|
|
||||||
if event.sender == self.client.user_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Admin commands (from any non-bridge user)
|
|
||||||
if event.sender not in _bridge_user_id_set:
|
|
||||||
body = (event.body or '').strip()
|
|
||||||
if body == '!relogin':
|
|
||||||
await self._handle_relogin_command(room.room_id)
|
|
||||||
return
|
|
||||||
if body == '!status':
|
|
||||||
await self._handle_status_command(room.room_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
if event.sender in _bridge_user_id_set:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
lb_event = await self.event_converter.target2yiri(
|
|
||||||
event, room, self.client, self.bot_account_id, _bridge_user_ids
|
|
||||||
)
|
|
||||||
if type(lb_event) in self.listeners:
|
|
||||||
result = self.listeners[type(lb_event)](lb_event, self)
|
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
await result
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Error handling Matrix message: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_message, nio.RoomMessageText)
|
|
||||||
|
|
||||||
# Handle image messages
|
|
||||||
async def on_image(room: nio.MatrixRoom, event: nio.RoomMessageImage):
|
|
||||||
if not self._initial_sync_done:
|
|
||||||
return
|
|
||||||
if event.sender == self.client.user_id:
|
|
||||||
return
|
|
||||||
if event.sender in _bridge_user_id_set:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
lb_event = await self.event_converter.target2yiri(
|
|
||||||
event, room, self.client, self.bot_account_id, _bridge_user_ids
|
|
||||||
)
|
|
||||||
if type(lb_event) in self.listeners:
|
|
||||||
result = self.listeners[type(lb_event)](lb_event, self)
|
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
await result
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Error handling Matrix image: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_image, nio.RoomMessageImage)
|
|
||||||
|
|
||||||
# Set up bridge-specific callbacks for each bridge
|
|
||||||
_disconnect_keywords = ['disconnected', 'logged out', 'connection lost', 'session expired', 'token expired']
|
|
||||||
|
|
||||||
for bridge in self._bridges:
|
|
||||||
# Login success detection (notice)
|
|
||||||
async def on_bridge_notice(room: nio.MatrixRoom, event: nio.RoomMessageNotice, _b=bridge):
|
|
||||||
if not self._initial_sync_done:
|
|
||||||
return
|
|
||||||
if event.sender != _b.user_id:
|
|
||||||
return
|
|
||||||
_b.check_responded = True
|
|
||||||
if _b.success_keyword in (event.body or ''):
|
|
||||||
_b.logged_in = True
|
|
||||||
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
|
|
||||||
# Disconnect detection
|
|
||||||
body_lower = (event.body or '').lower()
|
|
||||||
for kw in _disconnect_keywords:
|
|
||||||
if kw in body_lower and _b.logged_in:
|
|
||||||
_b.logged_in = False
|
|
||||||
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
|
|
||||||
self._restart_bridge_login(_b)
|
|
||||||
break
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_bridge_notice, nio.RoomMessageNotice)
|
|
||||||
|
|
||||||
# Login success + disconnect detection (text)
|
|
||||||
async def on_bridge_text(room: nio.MatrixRoom, event: nio.RoomMessageText, _b=bridge):
|
|
||||||
if not self._initial_sync_done:
|
|
||||||
return
|
|
||||||
if event.sender != _b.user_id:
|
|
||||||
return
|
|
||||||
_b.check_responded = True
|
|
||||||
if _b.success_keyword in (event.body or ''):
|
|
||||||
_b.logged_in = True
|
|
||||||
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
|
|
||||||
body_lower = (event.body or '').lower()
|
|
||||||
for kw in _disconnect_keywords:
|
|
||||||
if kw in body_lower and _b.logged_in:
|
|
||||||
_b.logged_in = False
|
|
||||||
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
|
|
||||||
self._restart_bridge_login(_b)
|
|
||||||
break
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_bridge_text, nio.RoomMessageText)
|
|
||||||
|
|
||||||
# QR code image forwarding
|
|
||||||
async def on_bridge_image(room: nio.MatrixRoom, event: nio.RoomMessageImage, _b=bridge):
|
|
||||||
if not self._initial_sync_done:
|
|
||||||
return
|
|
||||||
if event.sender != _b.user_id:
|
|
||||||
return
|
|
||||||
mxc_url = event.url
|
|
||||||
if not mxc_url:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
resp = await self.client.download(mxc_url)
|
|
||||||
if isinstance(resp, nio.DownloadResponse):
|
|
||||||
b64 = base64.b64encode(resp.body).decode('utf-8')
|
|
||||||
content_type = resp.content_type or 'image/png'
|
|
||||||
await self.logger.info(
|
|
||||||
f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:',
|
|
||||||
images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(
|
|
||||||
f'[{_b.user_id}] Failed to download bridge QR image: {traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_bridge_image, nio.RoomMessageImage)
|
|
||||||
|
|
||||||
await self.logger.debug('Matrix adapter running, starting sync...')
|
|
||||||
|
|
||||||
# Initial sync to skip old messages
|
|
||||||
resp = await self.client.sync(timeout=10000)
|
|
||||||
if isinstance(resp, nio.SyncResponse):
|
|
||||||
await self.logger.debug(f'Matrix initial sync done, next_batch: {resp.next_batch}')
|
|
||||||
self._initial_sync_done = True
|
|
||||||
|
|
||||||
# Display account info
|
|
||||||
display_name = self.client.user_id
|
|
||||||
try:
|
|
||||||
profile_resp = await self.client.get_displayname(self.client.user_id)
|
|
||||||
if isinstance(profile_resp, nio.ProfileGetDisplayNameResponse) and profile_resp.displayname:
|
|
||||||
display_name = profile_resp.displayname
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
joined_rooms = len(self.client.rooms)
|
|
||||||
homeserver = self.config.get('homeserver_url', '')
|
|
||||||
bridge_info = ''
|
|
||||||
if self._bridges:
|
|
||||||
bridge_names = ', '.join(b.user_id for b in self._bridges)
|
|
||||||
bridge_info = f' | 桥接: [{bridge_names}]'
|
|
||||||
await self.logger.info(
|
|
||||||
f'Matrix 账号: {display_name} ({self.client.user_id}) | '
|
|
||||||
f'服务器: {homeserver} | 已加入 {joined_rooms} 个房间{bridge_info}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start bridge login and status check tasks for each bridge
|
|
||||||
for bridge in self._bridges:
|
|
||||||
if bridge.login_command:
|
|
||||||
await self.logger.info(
|
|
||||||
f'[{bridge.user_id}] Bridge login enabled (命令: "{bridge.login_command}", '
|
|
||||||
f'关键词: "{bridge.success_keyword}")'
|
|
||||||
)
|
|
||||||
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
|
|
||||||
bridge.check_task = asyncio.create_task(self._periodic_bridge_check(bridge))
|
|
||||||
else:
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Bridge login not configured (no login_command)')
|
|
||||||
|
|
||||||
# Main sync loop
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
await self.client.sync(timeout=30000)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Matrix sync error: {traceback.format_exc()}')
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
async def _periodic_bridge_login(self, bridge: BridgeState):
|
|
||||||
"""Periodically send login command to a bridge bot until login succeeds."""
|
|
||||||
try:
|
|
||||||
await self.logger.info(f'[{bridge.user_id}] Bridge login task started, looking for DM room...')
|
|
||||||
dm_room_id = None
|
|
||||||
for room_id, room in self.client.rooms.items():
|
|
||||||
if room.member_count == 2 and bridge.user_id in [m for m in room.users]:
|
|
||||||
dm_room_id = room_id
|
|
||||||
break
|
|
||||||
|
|
||||||
if not dm_room_id:
|
|
||||||
resp = await self.client.room_create(
|
|
||||||
is_direct=True,
|
|
||||||
invite=[bridge.user_id],
|
|
||||||
)
|
|
||||||
if isinstance(resp, nio.RoomCreateResponse):
|
|
||||||
dm_room_id = resp.room_id
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Created DM room: {dm_room_id}')
|
|
||||||
else:
|
|
||||||
await self.logger.error(f'[{bridge.user_id}] Failed to create DM room: {resp}')
|
|
||||||
return
|
|
||||||
|
|
||||||
bridge.dm_room_id = dm_room_id
|
|
||||||
|
|
||||||
# Force logout first on every adapter start
|
|
||||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
|
||||||
await self.logger.info(f'[{bridge.user_id}] 强制登出: "{logout_cmd}"')
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=dm_room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': logout_cmd},
|
|
||||||
)
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
|
|
||||||
while self._running and not bridge.logged_in:
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Sending "{bridge.login_command}" in room {dm_room_id}')
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=dm_room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': bridge.login_command},
|
|
||||||
)
|
|
||||||
for _ in range(60):
|
|
||||||
if not self._running or bridge.logged_in:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
if bridge.logged_in:
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Bridge login confirmed, periodic login stopped.')
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'[{bridge.user_id}] Bridge periodic login error: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
def _restart_bridge_login(self, bridge: BridgeState):
|
|
||||||
"""Cancel existing login task and start a new one."""
|
|
||||||
if bridge.login_task and not bridge.login_task.done():
|
|
||||||
bridge.login_task.cancel()
|
|
||||||
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
|
|
||||||
|
|
||||||
async def _periodic_bridge_check(self, bridge: BridgeState):
|
|
||||||
"""Periodically check a bridge's login status."""
|
|
||||||
try:
|
|
||||||
while self._running and not bridge.logged_in:
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
check_interval = 300 # 5 minutes
|
|
||||||
response_timeout = 30
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Bridge status check started (interval: {check_interval}s)')
|
|
||||||
|
|
||||||
while self._running:
|
|
||||||
for _ in range(check_interval):
|
|
||||||
if not self._running:
|
|
||||||
return
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
if not bridge.logged_in or not bridge.dm_room_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
bridge.check_responded = False
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=bridge.dm_room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': bridge.check_command},
|
|
||||||
)
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: sent "{bridge.check_command}"')
|
|
||||||
|
|
||||||
for _ in range(response_timeout):
|
|
||||||
if bridge.check_responded or not self._running:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
if bridge.check_responded:
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: OK')
|
|
||||||
else:
|
|
||||||
await self.logger.info(
|
|
||||||
f'[{bridge.user_id}] Bridge status check: 无响应, 可能已掉线, 尝试重新登录...'
|
|
||||||
)
|
|
||||||
bridge.logged_in = False
|
|
||||||
self._restart_bridge_login(bridge)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'[{bridge.user_id}] Bridge status check error: {traceback.format_exc()}')
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'[{bridge.user_id}] Bridge status check fatal error: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
async def _handle_relogin_command(self, room_id: str):
|
|
||||||
"""Handle !relogin command: logout then re-login all bridges."""
|
|
||||||
if not self._bridges:
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = ['开始重新登录所有桥...']
|
|
||||||
for bridge in self._bridges:
|
|
||||||
if not bridge.login_command or not bridge.dm_room_id:
|
|
||||||
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Use configured logout command, fallback to deriving from login command
|
|
||||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
|
||||||
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
|
||||||
|
|
||||||
# Cancel existing tasks
|
|
||||||
if bridge.login_task and not bridge.login_task.done():
|
|
||||||
bridge.login_task.cancel()
|
|
||||||
if bridge.check_task and not bridge.check_task.done():
|
|
||||||
bridge.check_task.cancel()
|
|
||||||
|
|
||||||
# Send logout
|
|
||||||
try:
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=bridge.dm_room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': logout_cmd},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
lines.append(f'[{bridge.user_id}] logout 发送失败: {e}')
|
|
||||||
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
# Reset state and restart login
|
|
||||||
bridge.logged_in = False
|
|
||||||
self._restart_bridge_login(bridge)
|
|
||||||
lines.append(f'[{bridge.user_id}] 已触发重新登录')
|
|
||||||
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_status_command(self, room_id: str):
|
|
||||||
"""Handle !status command: show bridge states."""
|
|
||||||
if not self._bridges:
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = ['桥状态:']
|
|
||||||
for bridge in self._bridges:
|
|
||||||
status = '已登录 ✓' if bridge.logged_in else '未登录 ✗'
|
|
||||||
dm = bridge.dm_room_id or '无'
|
|
||||||
lines.append(f'• {bridge.user_id}: {status} (DM: {dm})')
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
|
||||||
self._running = False
|
|
||||||
for bridge in self._bridges:
|
|
||||||
if bridge.login_task and not bridge.login_task.done():
|
|
||||||
bridge.login_task.cancel()
|
|
||||||
if bridge.check_task and not bridge.check_task.done():
|
|
||||||
bridge.check_task.cancel()
|
|
||||||
if self.client:
|
|
||||||
await self.client.close()
|
|
||||||
await self.logger.debug('Matrix adapter stopped')
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def unregister_listener(
|
|
||||||
self,
|
|
||||||
event_type: typing.Type[platform_events.Event],
|
|
||||||
callback: typing.Callable[
|
|
||||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
|
||||||
],
|
|
||||||
):
|
|
||||||
if event_type in self.listeners:
|
|
||||||
del self.listeners[event_type]
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: MessagePlatformAdapter
|
|
||||||
metadata:
|
|
||||||
name: matrix
|
|
||||||
label:
|
|
||||||
en_US: Matrix
|
|
||||||
zh_Hans: Matrix
|
|
||||||
zh_Hant: Matrix
|
|
||||||
ja_JP: Matrix
|
|
||||||
th_TH: Matrix
|
|
||||||
vi_VN: Matrix
|
|
||||||
es_ES: Matrix
|
|
||||||
description:
|
|
||||||
en_US: Matrix protocol adapter, supports self-hosted Synapse servers and any Matrix-compatible homeserver
|
|
||||||
zh_Hans: Matrix 协议适配器,支持自建 Synapse 服务器及任何 Matrix 兼容的 Homeserver
|
|
||||||
zh_Hant: Matrix 協議適配器,支持自建 Synapse 伺服器及任何 Matrix 相容的 Homeserver
|
|
||||||
ja_JP: Matrix プロトコルアダプター、セルフホストの Synapse サーバーおよび Matrix 互換のホームサーバーをサポート
|
|
||||||
th_TH: อะแดปเตอร์โปรโตคอล Matrix รองรับเซิร์ฟเวอร์ Synapse ที่โฮสต์เองและ Homeserver ที่เข้ากันได้กับ Matrix
|
|
||||||
vi_VN: Bộ điều hợp giao thức Matrix, hỗ trợ máy chủ Synapse tự lưu trữ và bất kỳ Homeserver tương thích Matrix nào
|
|
||||||
es_ES: Adaptador del protocolo Matrix, compatible con servidores Synapse autoalojados y cualquier Homeserver compatible con Matrix
|
|
||||||
icon: matrix.png
|
|
||||||
spec:
|
|
||||||
categories:
|
|
||||||
- global
|
|
||||||
- protocol
|
|
||||||
config:
|
|
||||||
- name: homeserver_url
|
|
||||||
label:
|
|
||||||
en_US: Homeserver URL
|
|
||||||
zh_Hans: Homeserver 地址
|
|
||||||
zh_Hant: Homeserver 地址
|
|
||||||
ja_JP: Homeserver URL
|
|
||||||
th_TH: URL ของ Homeserver
|
|
||||||
vi_VN: URL Homeserver
|
|
||||||
es_ES: URL del Homeserver
|
|
||||||
description:
|
|
||||||
en_US: "The URL of the Matrix homeserver, e.g. http://localhost:8008"
|
|
||||||
zh_Hans: "Matrix Homeserver 的地址,例如 http://localhost:8008"
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: "http://localhost:8008"
|
|
||||||
- name: user_id
|
|
||||||
label:
|
|
||||||
en_US: Bot User ID
|
|
||||||
zh_Hans: 机器人用户 ID
|
|
||||||
zh_Hant: 機器人用戶 ID
|
|
||||||
ja_JP: ボットユーザー ID
|
|
||||||
th_TH: ID ผู้ใช้บอท
|
|
||||||
vi_VN: ID người dùng bot
|
|
||||||
es_ES: ID de usuario del bot
|
|
||||||
description:
|
|
||||||
en_US: "The full Matrix user ID, e.g. @bot:localhost"
|
|
||||||
zh_Hans: "完整的 Matrix 用户 ID,例如 @bot:localhost"
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: "@langbot:localhost"
|
|
||||||
- name: access_token
|
|
||||||
label:
|
|
||||||
en_US: Access Token
|
|
||||||
zh_Hans: 访问令牌
|
|
||||||
zh_Hant: 訪問令牌
|
|
||||||
ja_JP: アクセストークン
|
|
||||||
th_TH: โทเค็นการเข้าถึง
|
|
||||||
vi_VN: Mã truy cập
|
|
||||||
es_ES: Token de acceso
|
|
||||||
description:
|
|
||||||
en_US: "Access token obtained by logging in via the Matrix client API"
|
|
||||||
zh_Hans: "通过 Matrix Client API 登录获取的访问令牌"
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: ""
|
|
||||||
- name: bridge_user_id
|
|
||||||
label:
|
|
||||||
en_US: Bridge Bot User ID (single bridge, legacy)
|
|
||||||
zh_Hans: 桥机器人用户 ID(单桥兼容)
|
|
||||||
description:
|
|
||||||
en_US: "Single bridge bot user ID (legacy). Prefer 'bridges' for multi-bridge. e.g. @discordbot:localhost"
|
|
||||||
zh_Hans: "单桥机器人用户 ID(旧格式兼容)。推荐使用 bridges 配置多桥。例如 @discordbot:localhost"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: bridge_login_command
|
|
||||||
label:
|
|
||||||
en_US: Bridge Login Command (single bridge, legacy)
|
|
||||||
zh_Hans: 桥登录命令(单桥兼容)
|
|
||||||
description:
|
|
||||||
en_US: "Login command for single bridge (legacy). e.g. !discord login"
|
|
||||||
zh_Hans: "单桥登录命令(旧格式兼容)。例如 !discord login"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: bridge_login_success_keyword
|
|
||||||
label:
|
|
||||||
en_US: Bridge Login Success Keyword (single bridge, legacy)
|
|
||||||
zh_Hans: 桥登录成功关键词(单桥兼容)
|
|
||||||
description:
|
|
||||||
en_US: "Success keyword for single bridge (legacy). e.g. Successfully logged in"
|
|
||||||
zh_Hans: "单桥登录成功关键词(旧格式兼容)。例如 Successfully logged in"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: "Successfully logged in"
|
|
||||||
- name: bridges
|
|
||||||
label:
|
|
||||||
en_US: Bridges Config (Multi-bridge)
|
|
||||||
zh_Hans: 桥配置(多桥)
|
|
||||||
description:
|
|
||||||
en_US: >
|
|
||||||
JSON array of bridge configs. Each bridge: {"user_id": "@bot:host", "login_command": "!xx login",
|
|
||||||
"success_keyword": "logged in", "check_command": "!xx ping"}.
|
|
||||||
Example: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
|
|
||||||
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
|
|
||||||
zh_Hans: >
|
|
||||||
JSON 数组格式的多桥配置。每个桥: {"user_id": "@bot:host", "login_command": "!xx login",
|
|
||||||
"success_keyword": "logged in", "check_command": "!xx ping"}。
|
|
||||||
示例: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
|
|
||||||
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
execution:
|
|
||||||
python:
|
|
||||||
path: ./matrix.py
|
|
||||||
attr: MatrixAdapter
|
|
||||||
@@ -32,20 +32,6 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://ilinkai.weixin.qq.com"
|
default: "https://ilinkai.weixin.qq.com"
|
||||||
- name: qr-login
|
|
||||||
label:
|
|
||||||
en_US: Scan QR Login
|
|
||||||
zh_Hans: 扫码登录
|
|
||||||
zh_Hant: 掃碼登入
|
|
||||||
ja_JP: QRコードでログイン
|
|
||||||
description:
|
|
||||||
en_US: Scan QR code with WeChat to authorize and automatically fill in the token
|
|
||||||
zh_Hans: 使用微信扫码授权,自动填写令牌
|
|
||||||
zh_Hant: 使用微信掃碼授權,自動填寫令牌
|
|
||||||
ja_JP: WeChatでQRコードをスキャンし、トークンを自動入力
|
|
||||||
type: qr-code-login
|
|
||||||
login_platform: weixin
|
|
||||||
required: false
|
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import typing
|
import typing
|
||||||
import re
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
@@ -17,25 +15,11 @@ from ...utils import image
|
|||||||
from ..logger import EventLogger
|
from ..logger import EventLogger
|
||||||
|
|
||||||
|
|
||||||
def _is_base64_data(value: str) -> bool:
|
|
||||||
"""Check if a string contains base64-encoded data rather than a URL."""
|
|
||||||
if not value:
|
|
||||||
return False
|
|
||||||
# data: URI scheme (e.g. data:image/png;base64,xxx)
|
|
||||||
if value.startswith('data:'):
|
|
||||||
return True
|
|
||||||
# Only treat as base64 if it doesn't look like a URL/path and has valid base64 chars
|
|
||||||
if value.startswith(('http://', 'https://', '/', './', '../')):
|
|
||||||
return False
|
|
||||||
# Check if it looks like base64 (only valid chars, reasonable length)
|
|
||||||
return bool(re.fullmatch(r'[A-Za-z0-9+/=\s]{20,}', value))
|
|
||||||
|
|
||||||
|
|
||||||
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||||
"""将 LangBot 消息链转换为 QQ Official 消息格式列表。"""
|
|
||||||
content_list = []
|
content_list = []
|
||||||
|
# 只实现了发文字
|
||||||
for msg in message_chain:
|
for msg in message_chain:
|
||||||
if type(msg) is platform_message.Plain:
|
if type(msg) is platform_message.Plain:
|
||||||
content_list.append(
|
content_list.append(
|
||||||
@@ -44,49 +28,6 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
|
|||||||
'content': msg.text,
|
'content': msg.text,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif type(msg) is platform_message.Image:
|
|
||||||
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
|
||||||
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
|
||||||
# Some plugins (e.g. MimoTTS) store base64 data in the url field
|
|
||||||
if url and not b64 and _is_base64_data(url):
|
|
||||||
b64 = url
|
|
||||||
url = None
|
|
||||||
content_list.append(
|
|
||||||
{
|
|
||||||
'type': 'image',
|
|
||||||
'url': url,
|
|
||||||
'base64': b64,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif type(msg) is platform_message.Voice:
|
|
||||||
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
|
||||||
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
|
||||||
# Some plugins (e.g. MimoTTS) store base64 data in the url field
|
|
||||||
if url and not b64 and _is_base64_data(url):
|
|
||||||
b64 = url
|
|
||||||
url = None
|
|
||||||
content_list.append(
|
|
||||||
{
|
|
||||||
'type': 'voice',
|
|
||||||
'url': url,
|
|
||||||
'base64': b64,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif type(msg) is platform_message.File:
|
|
||||||
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
|
||||||
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
|
||||||
# Some plugins store base64 data in the url field
|
|
||||||
if url and not b64 and _is_base64_data(url):
|
|
||||||
b64 = url
|
|
||||||
url = None
|
|
||||||
content_list.append(
|
|
||||||
{
|
|
||||||
'type': 'file',
|
|
||||||
'url': url,
|
|
||||||
'base64': b64,
|
|
||||||
'name': msg.name if hasattr(msg, 'name') else 'file',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return content_list
|
return content_list
|
||||||
|
|
||||||
@@ -188,19 +129,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
config: dict
|
config: dict
|
||||||
bot_account_id: str
|
bot_account_id: str
|
||||||
bot_uuid: str = None
|
bot_uuid: str = None
|
||||||
enable_webhook: bool = False
|
|
||||||
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
||||||
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
enable_webhook = config.get('enable-webhook', False)
|
|
||||||
|
|
||||||
bot = QQOfficialClient(
|
bot = QQOfficialClient(
|
||||||
app_id=config['appid'],
|
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
|
||||||
secret=config['secret'],
|
|
||||||
token=config['token'],
|
|
||||||
logger=logger,
|
|
||||||
unified_mode=enable_webhook,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -210,13 +144,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
bot_account_id=config['appid'],
|
bot_account_id=config['appid'],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.enable_webhook = enable_webhook
|
|
||||||
self._ws_task: asyncio.Task = None
|
|
||||||
self._stream_ctx: dict = {}
|
|
||||||
self._stream_ctx_ts: dict[str, float] = {}
|
|
||||||
self._fallback_text: dict[str, str] = {}
|
|
||||||
self._fallback_text_ts: dict[str, float] = {}
|
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -229,18 +156,28 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
|
|
||||||
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
||||||
|
|
||||||
# 确定 target_type 和 target_id
|
# 私聊消息
|
||||||
target_type = None
|
|
||||||
target_id = None
|
|
||||||
|
|
||||||
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
|
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
|
||||||
target_type = 'c2c'
|
for content in content_list:
|
||||||
target_id = qq_official_event.user_openid
|
if content['type'] == 'text':
|
||||||
elif qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
|
await self.bot.send_private_text_msg(
|
||||||
target_type = 'group'
|
qq_official_event.user_openid,
|
||||||
target_id = qq_official_event.group_openid
|
content['content'],
|
||||||
elif qq_official_event.t == 'AT_MESSAGE_CREATE':
|
qq_official_event.d_id,
|
||||||
# 频道群聊使用频道 API,暂不支持富媒体
|
)
|
||||||
|
|
||||||
|
# 群聊消息
|
||||||
|
if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
|
||||||
|
for content in content_list:
|
||||||
|
if content['type'] == 'text':
|
||||||
|
await self.bot.send_group_text_msg(
|
||||||
|
qq_official_event.group_openid,
|
||||||
|
content['content'],
|
||||||
|
qq_official_event.d_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 频道群聊
|
||||||
|
if qq_official_event.t == 'AT_MESSAGE_CREATE':
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_channle_group_text_msg(
|
await self.bot.send_channle_group_text_msg(
|
||||||
@@ -248,9 +185,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
qq_official_event.d_id,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
elif qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
|
# 频道私聊
|
||||||
# 频道私聊使用频道 API,暂不支持富媒体
|
if qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_channle_private_text_msg(
|
await self.bot.send_channle_private_text_msg(
|
||||||
@@ -258,63 +195,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
qq_official_event.d_id,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
# C2C 和群聊:支持文字 + 富媒体
|
|
||||||
for content in content_list:
|
|
||||||
content_type = content.get('type', 'text')
|
|
||||||
|
|
||||||
if content_type == 'text':
|
|
||||||
if target_type == 'c2c':
|
|
||||||
await self.bot.send_private_text_msg(
|
|
||||||
target_id,
|
|
||||||
content['content'],
|
|
||||||
qq_official_event.d_id,
|
|
||||||
)
|
|
||||||
elif target_type == 'group':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
target_id,
|
|
||||||
content['content'],
|
|
||||||
qq_official_event.d_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif content_type == 'image':
|
|
||||||
file_url = content.get('url')
|
|
||||||
file_data = content.get('base64')
|
|
||||||
if file_url or file_data:
|
|
||||||
await self.bot.send_image_msg(
|
|
||||||
target_type,
|
|
||||||
target_id,
|
|
||||||
file_url=file_url,
|
|
||||||
file_data=file_data,
|
|
||||||
msg_id=qq_official_event.d_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif content_type == 'voice':
|
|
||||||
file_url = content.get('url')
|
|
||||||
file_data = content.get('base64')
|
|
||||||
if file_url or file_data:
|
|
||||||
await self.bot.send_voice_msg(
|
|
||||||
target_type,
|
|
||||||
target_id,
|
|
||||||
file_url=file_url,
|
|
||||||
file_data=file_data,
|
|
||||||
msg_id=qq_official_event.d_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif content_type == 'file':
|
|
||||||
file_url = content.get('url')
|
|
||||||
file_data = content.get('base64')
|
|
||||||
file_name = content.get('name', 'file')
|
|
||||||
if file_url or file_data:
|
|
||||||
await self.bot.send_file_msg(
|
|
||||||
target_type,
|
|
||||||
target_id,
|
|
||||||
file_url=file_url,
|
|
||||||
file_data=file_data,
|
|
||||||
file_name=file_name,
|
|
||||||
msg_id=qq_official_event.d_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
pass
|
||||||
@@ -358,196 +238,17 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
return await self.bot.handle_unified_webhook(request)
|
return await self.bot.handle_unified_webhook(request)
|
||||||
|
|
||||||
async def run_async(self):
|
async def run_async(self):
|
||||||
if not self.enable_webhook:
|
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||||
await self._run_websocket()
|
# 保持运行但不启动独立端口
|
||||||
else:
|
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
|
||||||
async def keep_alive():
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
await keep_alive()
|
async def keep_alive():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
async def _run_websocket(self):
|
await keep_alive()
|
||||||
"""以 WebSocket 模式运行网关连接"""
|
|
||||||
await self.logger.info('QQ Official adapter starting in WebSocket mode')
|
|
||||||
|
|
||||||
async def on_ready():
|
|
||||||
await self.logger.info('QQ Official WebSocket connected and ready')
|
|
||||||
|
|
||||||
async def on_event(event_type: str, event_data: dict):
|
|
||||||
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
|
|
||||||
message_event_types = {
|
|
||||||
'C2C_MESSAGE_CREATE',
|
|
||||||
'DIRECT_MESSAGE_CREATE',
|
|
||||||
'GROUP_AT_MESSAGE_CREATE',
|
|
||||||
'AT_MESSAGE_CREATE',
|
|
||||||
}
|
|
||||||
if event_type not in message_event_types:
|
|
||||||
return
|
|
||||||
if not isinstance(event_data, dict):
|
|
||||||
await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}')
|
|
||||||
return
|
|
||||||
await self.logger.info(f'Processing message event: {event_type}')
|
|
||||||
# 构造与 webhook 模式相同的 payload 结构
|
|
||||||
payload = {'t': event_type, 'd': event_data}
|
|
||||||
message_data = await self.bot.get_message(payload)
|
|
||||||
if message_data:
|
|
||||||
event = QQOfficialEvent.from_payload(message_data)
|
|
||||||
await self.bot._handle_message(event)
|
|
||||||
|
|
||||||
async def on_error(error: Exception):
|
|
||||||
await self.logger.error(f'WebSocket error: {error}')
|
|
||||||
await self.logger.error(f'QQ Official WebSocket error: {error}')
|
|
||||||
|
|
||||||
self._ws_task = asyncio.create_task(self.bot.connect_gateway_loop(on_event, on_ready, on_error))
|
|
||||||
try:
|
|
||||||
await self._ws_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
if self._ws_task:
|
return False
|
||||||
self._ws_task.cancel()
|
|
||||||
try:
|
|
||||||
await self._ws_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
self._ws_task = None
|
|
||||||
return True
|
|
||||||
|
|
||||||
# --------------- 流式输出 ---------------
|
|
||||||
|
|
||||||
_STREAM_CTX_TTL = 300 # seconds
|
|
||||||
|
|
||||||
async def _cleanup_stale_streams(self):
|
|
||||||
"""Remove stream contexts that have not been updated for more than _STREAM_CTX_TTL seconds."""
|
|
||||||
now = time.time()
|
|
||||||
stale_ids = [mid for mid, ts in self._stream_ctx_ts.items() if now - ts > self._STREAM_CTX_TTL]
|
|
||||||
for mid in stale_ids:
|
|
||||||
self._stream_ctx.pop(mid, None)
|
|
||||||
self._stream_ctx_ts.pop(mid, None)
|
|
||||||
stale_fb = [mid for mid, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL]
|
|
||||||
for mid in stale_fb:
|
|
||||||
self._fallback_text.pop(mid, None)
|
|
||||||
self._fallback_text_ts.pop(mid, None)
|
|
||||||
if stale_ids or stale_fb:
|
|
||||||
await self.logger.debug(f'Cleaned up {len(stale_ids)} stream contexts, {len(stale_fb)} fallback texts')
|
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
|
||||||
return self.config.get('enable-stream-reply', False)
|
|
||||||
|
|
||||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
|
||||||
source = event.source_platform_object
|
|
||||||
# Streaming API only supports C2C private chat
|
|
||||||
if source.t != 'C2C_MESSAGE_CREATE':
|
|
||||||
return False
|
|
||||||
|
|
||||||
ctx = {
|
|
||||||
'user_openid': source.user_openid,
|
|
||||||
'msg_id': source.d_id,
|
|
||||||
'stream_msg_id': None,
|
|
||||||
'msg_seq': 1,
|
|
||||||
'index': 0,
|
|
||||||
'last_update_ts': 0,
|
|
||||||
'accumulated_text': '',
|
|
||||||
'sent_length': 0,
|
|
||||||
'session_started': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
self._stream_ctx[message_id] = ctx
|
|
||||||
self._stream_ctx_ts[message_id] = time.time()
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def reply_message_chunk(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
bot_message: dict,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
is_final: bool = False,
|
|
||||||
):
|
|
||||||
# Periodically clean up stale stream contexts
|
|
||||||
await self._cleanup_stale_streams()
|
|
||||||
# 提取纯文本内容(当前 chunk 的文本)
|
|
||||||
text_parts = []
|
|
||||||
for msg in message:
|
|
||||||
if type(msg) is platform_message.Plain:
|
|
||||||
text_parts.append(msg.text)
|
|
||||||
chunk_text = '\n\n'.join(text_parts)
|
|
||||||
|
|
||||||
message_id = (
|
|
||||||
bot_message.get('resp_message_id')
|
|
||||||
if isinstance(bot_message, dict)
|
|
||||||
else getattr(bot_message, 'resp_message_id', None)
|
|
||||||
)
|
|
||||||
if not message_id or message_id not in self._stream_ctx:
|
|
||||||
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
|
|
||||||
if chunk_text:
|
|
||||||
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
|
|
||||||
self._fallback_text_ts[message_id] = time.time()
|
|
||||||
if is_final:
|
|
||||||
full_text = self._fallback_text.pop(message_id, '')
|
|
||||||
if full_text:
|
|
||||||
fallback_msg = platform_message.MessageChain([platform_message.Plain(text=full_text)])
|
|
||||||
await self.reply_message(message_source, fallback_msg, quote_origin)
|
|
||||||
return
|
|
||||||
|
|
||||||
ctx = self._stream_ctx[message_id]
|
|
||||||
|
|
||||||
# 累积文本
|
|
||||||
if chunk_text:
|
|
||||||
ctx['accumulated_text'] += chunk_text
|
|
||||||
|
|
||||||
# 未启动会话时,等第一个有内容的 chunk 来建立会话
|
|
||||||
if not ctx['session_started']:
|
|
||||||
if not ctx['accumulated_text']:
|
|
||||||
return
|
|
||||||
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
|
||||||
ctx['session_started'] = True
|
|
||||||
|
|
||||||
# 发送内容 = 全量累积文本
|
|
||||||
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
|
||||||
# - 首次:发送全部文本,建立会话
|
|
||||||
# - 后续:只能发送新增部分(append 行为)
|
|
||||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
|
||||||
if not content_to_send and not is_final:
|
|
||||||
return
|
|
||||||
|
|
||||||
input_state = 10 if is_final else 1
|
|
||||||
|
|
||||||
# Rate limiting: skip non-final updates if last update was <0.5s ago
|
|
||||||
now = time.time()
|
|
||||||
if not is_final and (now - ctx['last_update_ts']) < 0.5:
|
|
||||||
return
|
|
||||||
ctx['last_update_ts'] = now
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = await self.bot.send_stream_msg(
|
|
||||||
user_openid=ctx['user_openid'],
|
|
||||||
content=content_to_send,
|
|
||||||
event_id=ctx['msg_id'],
|
|
||||||
msg_id=ctx['msg_id'],
|
|
||||||
msg_seq=ctx['msg_seq'],
|
|
||||||
index=ctx['index'],
|
|
||||||
stream_msg_id=ctx['stream_msg_id'],
|
|
||||||
input_state=input_state,
|
|
||||||
)
|
|
||||||
if resp and isinstance(resp, dict):
|
|
||||||
new_stream_id = resp.get('id')
|
|
||||||
if new_stream_id:
|
|
||||||
ctx['stream_msg_id'] = new_stream_id
|
|
||||||
ctx['sent_length'] = len(ctx['accumulated_text'])
|
|
||||||
ctx['index'] += 1
|
|
||||||
await self.logger.debug(
|
|
||||||
f'[QQ Official] 流式 chunk 已发送, index={ctx["index"]}, '
|
|
||||||
f'sent_len={ctx["sent_length"]}, is_final={is_final}'
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
await self.logger.error(f'Failed to send stream message: {e}')
|
|
||||||
|
|
||||||
if is_final:
|
|
||||||
self._stream_ctx.pop(message_id, None)
|
|
||||||
|
|
||||||
def unregister_listener(
|
def unregister_listener(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ metadata:
|
|||||||
zh_Hans: QQ 官方 API
|
zh_Hans: QQ 官方 API
|
||||||
zh_Hant: QQ 官方 API
|
zh_Hant: QQ 官方 API
|
||||||
description:
|
description:
|
||||||
en_US: QQ Official API (Webhook / WebSocket)
|
en_US: QQ Official API (Webhook)
|
||||||
zh_Hans: QQ 官方 API,支持 Webhook 和 WebSocket 两种连接模式
|
zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||||
zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式
|
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||||
icon: qqofficial.svg
|
icon: qqofficial.svg
|
||||||
spec:
|
spec:
|
||||||
categories:
|
categories:
|
||||||
@@ -19,6 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/qqofficial
|
en: https://link.langbot.app/en/platforms/qqofficial
|
||||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: appid
|
- name: appid
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
@@ -43,46 +55,6 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
- name: enable-webhook
|
|
||||||
label:
|
|
||||||
en_US: Enable Webhook Mode
|
|
||||||
zh_Hans: 启用Webhook模式
|
|
||||||
zh_Hant: 啟用 Webhook 模式
|
|
||||||
description:
|
|
||||||
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WebSocket mode
|
|
||||||
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WebSocket 模式
|
|
||||||
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WebSocket 模式
|
|
||||||
type: boolean
|
|
||||||
required: true
|
|
||||||
default: false
|
|
||||||
- name: enable-stream-reply
|
|
||||||
label:
|
|
||||||
en_US: Enable Stream Reply Mode
|
|
||||||
zh_Hans: 启用流式回复模式
|
|
||||||
zh_Hant: 啟用串流回覆模式
|
|
||||||
description:
|
|
||||||
en_US: If enabled, the bot will use streaming mode to reply messages (C2C only)
|
|
||||||
zh_Hans: 如果启用,机器人将使用流式方式回复消息(仅私聊)
|
|
||||||
zh_Hant: 如果啟用,機器人將使用串流方式回覆訊息(僅私聊)
|
|
||||||
type: boolean
|
|
||||||
required: true
|
|
||||||
default: false
|
|
||||||
- name: webhook_url
|
|
||||||
label:
|
|
||||||
en_US: Webhook Callback URL
|
|
||||||
zh_Hans: Webhook 回调地址
|
|
||||||
zh_Hant: Webhook 回調地址
|
|
||||||
description:
|
|
||||||
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
|
|
||||||
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
|
|
||||||
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
|
|
||||||
type: webhook-url
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
show_if:
|
|
||||||
field: enable-webhook
|
|
||||||
operator: eq
|
|
||||||
value: true
|
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./qqofficial.py
|
path: ./qqofficial.py
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: MessagePlatformAdapter
|
|
||||||
metadata:
|
|
||||||
name: web_page_bot
|
|
||||||
label:
|
|
||||||
en_US: "Page Bot"
|
|
||||||
zh_Hans: "页面机器人"
|
|
||||||
zh_Hant: "頁面機器人"
|
|
||||||
ja_JP: "ページボット"
|
|
||||||
th_TH: "บอทหน้าเว็บ"
|
|
||||||
vi_VN: "Bot trang web"
|
|
||||||
es_ES: "Bot de página"
|
|
||||||
description:
|
|
||||||
en_US: "Embed a chat widget on any website with a simple script tag"
|
|
||||||
zh_Hans: "通过一行脚本标签将聊天组件嵌入到任何网站"
|
|
||||||
zh_Hant: "透過一行腳本標籤將聊天元件嵌入到任何網站"
|
|
||||||
ja_JP: "シンプルなスクリプトタグで任意のウェブサイトにチャットウィジェットを埋め込みます"
|
|
||||||
th_TH: "ฝังวิดเจ็ตแชทในเว็บไซต์ใดก็ได้ด้วยแท็กสคริปต์"
|
|
||||||
vi_VN: "Nhúng widget trò chuyện vào bất kỳ trang web nào bằng thẻ script"
|
|
||||||
es_ES: "Incrusta un widget de chat en cualquier sitio web con una etiqueta de script"
|
|
||||||
icon: "webpage.webp"
|
|
||||||
spec:
|
|
||||||
categories:
|
|
||||||
- popular
|
|
||||||
config:
|
|
||||||
- name: title
|
|
||||||
label:
|
|
||||||
en_US: Widget Title
|
|
||||||
zh_Hans: 组件标题
|
|
||||||
zh_Hant: 元件標題
|
|
||||||
ja_JP: ウィジェットタイトル
|
|
||||||
th_TH: ชื่อวิดเจ็ต
|
|
||||||
vi_VN: Tiêu đề widget
|
|
||||||
es_ES: Título del widget
|
|
||||||
description:
|
|
||||||
en_US: The title displayed in the chat widget header
|
|
||||||
zh_Hans: 显示在聊天组件顶部的标题
|
|
||||||
zh_Hant: 顯示在聊天元件頂部的標題
|
|
||||||
ja_JP: チャットウィジェットのヘッダーに表示されるタイトル
|
|
||||||
th_TH: ชื่อที่แสดงในส่วนหัวของวิดเจ็ตแชท
|
|
||||||
vi_VN: Tiêu đề hiển thị trong đầu widget trò chuyện
|
|
||||||
es_ES: El título que se muestra en el encabezado del widget de chat
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: "LangBot"
|
|
||||||
- name: bubble_icon
|
|
||||||
label:
|
|
||||||
en_US: Bubble Icon
|
|
||||||
zh_Hans: 气泡图标
|
|
||||||
zh_Hant: 氣泡圖示
|
|
||||||
ja_JP: バブルアイコン
|
|
||||||
th_TH: ไอคอนบับเบิล
|
|
||||||
vi_VN: Biểu tượng bong bóng
|
|
||||||
es_ES: Icono de burbuja
|
|
||||||
ru_RU: Иконка пузырька
|
|
||||||
description:
|
|
||||||
en_US: "Icon displayed on the floating chat bubble"
|
|
||||||
zh_Hans: "浮动聊天气泡上显示的图标"
|
|
||||||
type: select
|
|
||||||
required: false
|
|
||||||
default: "logo"
|
|
||||||
options:
|
|
||||||
- name: "logo"
|
|
||||||
label:
|
|
||||||
en_US: "LangBot Logo"
|
|
||||||
zh_Hans: "LangBot 图标"
|
|
||||||
- name: "chat"
|
|
||||||
label:
|
|
||||||
en_US: "Chat Bubble"
|
|
||||||
zh_Hans: "聊天气泡"
|
|
||||||
- name: "robot"
|
|
||||||
label:
|
|
||||||
en_US: "Robot"
|
|
||||||
zh_Hans: "机器人"
|
|
||||||
- name: "headset"
|
|
||||||
label:
|
|
||||||
en_US: "Headset"
|
|
||||||
zh_Hans: "客服耳机"
|
|
||||||
- name: "sparkle"
|
|
||||||
label:
|
|
||||||
en_US: "Sparkle"
|
|
||||||
zh_Hans: "星光"
|
|
||||||
- name: "message"
|
|
||||||
label:
|
|
||||||
en_US: "Message"
|
|
||||||
zh_Hans: "消息"
|
|
||||||
- name: language
|
|
||||||
label:
|
|
||||||
en_US: Widget Language
|
|
||||||
zh_Hans: 组件语言
|
|
||||||
zh_Hant: 元件語言
|
|
||||||
ja_JP: ウィジェット言語
|
|
||||||
th_TH: ภาษาวิดเจ็ต
|
|
||||||
vi_VN: Ngôn ngữ widget
|
|
||||||
es_ES: Idioma del widget
|
|
||||||
ru_RU: Язык виджета
|
|
||||||
description:
|
|
||||||
en_US: "Display language of the chat widget"
|
|
||||||
zh_Hans: "聊天组件的显示语言"
|
|
||||||
zh_Hant: "聊天元件的顯示語言"
|
|
||||||
ja_JP: "チャットウィジェットの表示言語"
|
|
||||||
th_TH: "ภาษาแสดงผลของวิดเจ็ตแชท"
|
|
||||||
vi_VN: "Ngôn ngữ hiển thị của widget trò chuyện"
|
|
||||||
es_ES: "Idioma de visualización del widget de chat"
|
|
||||||
ru_RU: "Язык отображения виджета чата"
|
|
||||||
type: select
|
|
||||||
required: false
|
|
||||||
default: "en_US"
|
|
||||||
options:
|
|
||||||
- name: "en_US"
|
|
||||||
label:
|
|
||||||
en_US: "English"
|
|
||||||
- name: "zh_Hans"
|
|
||||||
label:
|
|
||||||
en_US: "简体中文"
|
|
||||||
- name: "zh_Hant"
|
|
||||||
label:
|
|
||||||
en_US: "繁體中文"
|
|
||||||
- name: "ja_JP"
|
|
||||||
label:
|
|
||||||
en_US: "日本語"
|
|
||||||
- name: "es_ES"
|
|
||||||
label:
|
|
||||||
en_US: "Español"
|
|
||||||
- name: "ru_RU"
|
|
||||||
label:
|
|
||||||
en_US: "Русский"
|
|
||||||
- name: "th_TH"
|
|
||||||
label:
|
|
||||||
en_US: "ไทย"
|
|
||||||
- name: "vi_VN"
|
|
||||||
label:
|
|
||||||
en_US: "Tiếng Việt"
|
|
||||||
- name: embed_code
|
|
||||||
label:
|
|
||||||
en_US: Embed Code
|
|
||||||
zh_Hans: 嵌入代码
|
|
||||||
zh_Hant: 嵌入代碼
|
|
||||||
ja_JP: 埋め込みコード
|
|
||||||
th_TH: โค้ดฝังตัว
|
|
||||||
vi_VN: Mã nhúng
|
|
||||||
es_ES: Código de incrustación
|
|
||||||
description:
|
|
||||||
en_US: "Copy this code and paste it into your website HTML. The code will be generated after saving."
|
|
||||||
zh_Hans: "复制此代码并粘贴到你的网站 HTML 中。保存后将自动生成。"
|
|
||||||
zh_Hant: "複製此代碼並貼到你的網站 HTML 中。儲存後將自動生成。"
|
|
||||||
ja_JP: "このコードをコピーしてウェブサイトのHTMLに貼り付けてください。保存後に自動生成されます。"
|
|
||||||
th_TH: "คัดลอกโค้ดนี้และวางในHTML ของเว็บไซต์ของคุณ จะสร้างอัตโนมัติหลังจากบันทึก"
|
|
||||||
vi_VN: "Sao chép mã này và dán vào HTML trang web của bạn. Mã sẽ được tạo tự động sau khi lưu."
|
|
||||||
es_ES: "Copia este código y pégalo en el HTML de tu sitio web. El código se generará después de guardar."
|
|
||||||
type: embed-code
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: turnstile_site_key
|
|
||||||
label:
|
|
||||||
en_US: Turnstile Site Key
|
|
||||||
zh_Hans: Turnstile 站点密钥
|
|
||||||
description:
|
|
||||||
en_US: "Cloudflare Turnstile site key for bot protection. Get it from the Cloudflare dashboard (Turnstile > Add Site). Leave empty to disable."
|
|
||||||
zh_Hans: "Cloudflare Turnstile 站点密钥,用于防止机器人滥用。在 Cloudflare 控制台(Turnstile > 添加站点)中获取。留空则不启用。"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: turnstile_secret_key
|
|
||||||
label:
|
|
||||||
en_US: Turnstile Secret Key
|
|
||||||
zh_Hans: Turnstile 服务端密钥
|
|
||||||
description:
|
|
||||||
en_US: "Cloudflare Turnstile secret key for server-side token verification. Found alongside the site key in the Cloudflare dashboard. Required if site key is set."
|
|
||||||
zh_Hans: "Cloudflare Turnstile 服务端密钥,用于服务端验证令牌。与站点密钥一起在 Cloudflare 控制台中获取。设置了站点密钥时必填。"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
execution:
|
|
||||||
python:
|
|
||||||
path: "web_page_bot_adapter.py"
|
|
||||||
attr: "WebPageBotAdapter"
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"""Web Page Bot adapter - lightweight adapter for embeddable chat widget"""
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
import pydantic
|
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
|
||||||
|
|
||||||
|
|
||||||
class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|
||||||
"""Lightweight adapter for the embeddable page bot.
|
|
||||||
|
|
||||||
This adapter does not handle messages itself. The actual WebSocket
|
|
||||||
communication is handled by the singleton websocket_proxy_bot.
|
|
||||||
This adapter stores event listeners so that RuntimeBot can register
|
|
||||||
its handlers, which are then called by the websocket adapter when
|
|
||||||
a message arrives for this bot's pipeline.
|
|
||||||
|
|
||||||
Message sending/replying is delegated to the websocket_proxy_bot's
|
|
||||||
adapter so that replies are actually delivered over the WebSocket
|
|
||||||
connection while the dashboard correctly shows this adapter's name.
|
|
||||||
"""
|
|
||||||
|
|
||||||
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
|
|
||||||
_ws_adapter: typing.Any = None
|
|
||||||
|
|
||||||
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
|
||||||
super().__init__(config=config, logger=logger, **kwargs)
|
|
||||||
|
|
||||||
def set_ws_adapter(self, ws_adapter) -> None:
|
|
||||||
"""Set the underlying WebSocket adapter used for actual message delivery."""
|
|
||||||
object.__setattr__(self, '_ws_adapter', ws_adapter)
|
|
||||||
|
|
||||||
async def send_message(
|
|
||||||
self,
|
|
||||||
target_type: str,
|
|
||||||
target_id: str,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
) -> dict:
|
|
||||||
if self._ws_adapter is not None:
|
|
||||||
return await self._ws_adapter.send_message(target_type, target_id, message)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def reply_message(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
if self._ws_adapter is not None:
|
|
||||||
return await self._ws_adapter.reply_message(message_source, message, quote_origin)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def reply_message_chunk(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
bot_message,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
is_final: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
if self._ws_adapter is not None:
|
|
||||||
return await self._ws_adapter.reply_message_chunk(
|
|
||||||
message_source, bot_message, message, quote_origin, is_final
|
|
||||||
)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def register_listener(
|
|
||||||
self,
|
|
||||||
event_type: typing.Type[platform_events.Event],
|
|
||||||
func: typing.Callable,
|
|
||||||
):
|
|
||||||
self.listeners[event_type] = func
|
|
||||||
|
|
||||||
def unregister_listener(
|
|
||||||
self,
|
|
||||||
event_type: typing.Type[platform_events.Event],
|
|
||||||
func: typing.Callable,
|
|
||||||
):
|
|
||||||
self.listeners.pop(event_type, None)
|
|
||||||
|
|
||||||
async def is_muted(self, group_id: int) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def run_async(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def kill(self):
|
|
||||||
pass
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -312,7 +312,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
|
|
||||||
async def _process_image_components(self, message_chain_obj: list):
|
async def _process_image_components(self, message_chain_obj: list):
|
||||||
"""
|
"""
|
||||||
处理消息链中的图片和文件组件,将path转换为base64
|
处理消息链中的图片组件,将path转换为base64
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_chain_obj: 消息链对象列表
|
message_chain_obj: 消息链对象列表
|
||||||
@@ -322,18 +322,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
storage_mgr = self.ap.storage_mgr
|
storage_mgr = self.ap.storage_mgr
|
||||||
|
|
||||||
for component in message_chain_obj:
|
for component in message_chain_obj:
|
||||||
comp_type = component.get('type', '')
|
if component.get('type') == 'Image' and component.get('path'):
|
||||||
comp_path = component.get('path', '')
|
|
||||||
|
|
||||||
if not comp_path:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if comp_type == 'Image':
|
|
||||||
try:
|
try:
|
||||||
file_content = await storage_mgr.storage_provider.load(comp_path)
|
# 从storage读取文件
|
||||||
|
file_content = await storage_mgr.storage_provider.load(component['path'])
|
||||||
|
|
||||||
|
# 转换为base64
|
||||||
base64_str = base64.b64encode(file_content).decode('utf-8')
|
base64_str = base64.b64encode(file_content).decode('utf-8')
|
||||||
|
|
||||||
file_key = comp_path
|
# 添加data URI前缀(根据文件扩展名判断MIME类型)
|
||||||
|
file_key = component['path']
|
||||||
if file_key.lower().endswith(('.jpg', '.jpeg')):
|
if file_key.lower().endswith(('.jpg', '.jpeg')):
|
||||||
mime_type = 'image/jpeg'
|
mime_type = 'image/jpeg'
|
||||||
elif file_key.lower().endswith('.png'):
|
elif file_key.lower().endswith('.png'):
|
||||||
@@ -343,19 +341,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
elif file_key.lower().endswith('.webp'):
|
elif file_key.lower().endswith('.webp'):
|
||||||
mime_type = 'image/webp'
|
mime_type = 'image/webp'
|
||||||
else:
|
else:
|
||||||
mime_type = 'image/png'
|
mime_type = 'image/png' # 默认
|
||||||
|
|
||||||
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
||||||
await storage_mgr.storage_provider.delete(comp_path)
|
await storage_mgr.storage_provider.delete(component['path'])
|
||||||
component['path'] = ''
|
component['path'] = ''
|
||||||
|
# 保留path字段用于后端处理,前端使用base64显示
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.logger.error(f'Failed to load image file {comp_path}: {e}')
|
await self.logger.error(f'加载图片文件失败 {component["path"]}: {e}')
|
||||||
|
|
||||||
async def handle_websocket_message(
|
async def handle_websocket_message(
|
||||||
self,
|
self,
|
||||||
connection: WebSocketConnection,
|
connection: WebSocketConnection,
|
||||||
message_data: dict,
|
message_data: dict,
|
||||||
owner_bot=None,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
处理从WebSocket接收的消息
|
处理从WebSocket接收的消息
|
||||||
@@ -368,12 +366,9 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
message_data: 消息数据,包含:
|
message_data: 消息数据,包含:
|
||||||
- message: 消息链
|
- message: 消息链
|
||||||
- stream: 是否启用流式输出 (可选,默认True)
|
- stream: 是否启用流式输出 (可选,默认True)
|
||||||
owner_bot: Optional RuntimeBot that owns this pipeline (e.g. a web_page_bot).
|
|
||||||
When provided, its identity is used for logging and session tracking.
|
|
||||||
"""
|
"""
|
||||||
pipeline_uuid = connection.pipeline_uuid
|
pipeline_uuid = connection.pipeline_uuid
|
||||||
session_type = connection.session_type
|
session_type = connection.session_type
|
||||||
is_workflow = bool(connection.metadata.get('is_workflow'))
|
|
||||||
|
|
||||||
# 获取stream参数,默认为True
|
# 获取stream参数,默认为True
|
||||||
self.stream_enabled = message_data.get('stream', True)
|
self.stream_enabled = message_data.get('stream', True)
|
||||||
@@ -415,60 +410,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
session_type=session_type,
|
session_type=session_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_workflow:
|
|
||||||
# 设置 pipeline_uuid,以便工作流节点发送消息时能正确广播
|
|
||||||
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
|
||||||
|
|
||||||
message_content = str(message_chain)
|
|
||||||
message_context = {
|
|
||||||
'message_id': str(message_id),
|
|
||||||
'message_content': message_content,
|
|
||||||
'sender_id': f'websocket_{connection.connection_id}',
|
|
||||||
'sender_name': 'User',
|
|
||||||
'platform': 'websocket',
|
|
||||||
'conversation_id': connection.connection_id,
|
|
||||||
'is_group': session_type == 'group',
|
|
||||||
'group_id': 'websocketgroup' if session_type == 'group' else None,
|
|
||||||
'mentions': [],
|
|
||||||
'reply_to': None,
|
|
||||||
'raw_message': {
|
|
||||||
'message': message_chain_obj,
|
|
||||||
'connection_id': connection.connection_id,
|
|
||||||
'session_type': session_type,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger_data = {
|
|
||||||
'message': message_content,
|
|
||||||
'message_chain': message_chain_obj,
|
|
||||||
'session_type': session_type,
|
|
||||||
'connection_id': connection.connection_id,
|
|
||||||
'message_context': message_context,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
from ...api.http.service.workflow import WorkflowExecutionFailedError
|
|
||||||
|
|
||||||
# Log workflow execution start (matching pipeline logging)
|
|
||||||
session_id = f'{session_type}_{connection.connection_id}'
|
|
||||||
logger.info(f'Processing request from {session_id} (0): {message_content}')
|
|
||||||
|
|
||||||
execution_id = await self.ap.workflow_service.execute_workflow(
|
|
||||||
pipeline_uuid,
|
|
||||||
trigger_type='message',
|
|
||||||
trigger_data=trigger_data,
|
|
||||||
session_id=session_id,
|
|
||||||
user_id=message_context['sender_id'],
|
|
||||||
bot_id=self.ap.platform_mgr.websocket_proxy_bot.bot_entity.uuid,
|
|
||||||
)
|
|
||||||
# Removed success broadcast - only show error on failure
|
|
||||||
except WorkflowExecutionFailedError as e:
|
|
||||||
await connection.send_queue.put({'type': 'error', 'message': e.message})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Workflow websocket execution error: {e}', exc_info=True)
|
|
||||||
await connection.send_queue.put({'type': 'error', 'message': str(e)})
|
|
||||||
return
|
|
||||||
|
|
||||||
# 添加消息源
|
# 添加消息源
|
||||||
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
||||||
|
|
||||||
@@ -494,26 +435,12 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 设置流水线UUID (proxy bot always needs it for reply_message routing)
|
# 设置流水线UUID
|
||||||
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||||
if owner_bot is not None:
|
|
||||||
owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
|
||||||
|
|
||||||
# 异步触发事件处理
|
# 异步触发事件处理(不等待结果)
|
||||||
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
|
if event.__class__ in self.listeners:
|
||||||
listeners = (
|
asyncio.create_task(self.listeners[event.__class__](event, self))
|
||||||
owner_bot.adapter.listeners
|
|
||||||
if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners)
|
|
||||||
else self.listeners
|
|
||||||
)
|
|
||||||
# Pass owner_bot's adapter so that downstream logging / dashboard
|
|
||||||
# attributes the message to the correct bot adapter name.
|
|
||||||
# Wire the ws adapter into the owner so replies are actually delivered.
|
|
||||||
if owner_bot and hasattr(owner_bot.adapter, 'set_ws_adapter'):
|
|
||||||
owner_bot.adapter.set_ws_adapter(self)
|
|
||||||
callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self
|
|
||||||
if event.__class__ in listeners:
|
|
||||||
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
|
||||||
|
|
||||||
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||||
"""获取消息历史"""
|
"""获取消息历史"""
|
||||||
|
|||||||
@@ -19,18 +19,6 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/wecombot
|
en: https://link.langbot.app/en/platforms/wecombot
|
||||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
ja: https://link.langbot.app/ja/platforms/wecombot
|
||||||
config:
|
config:
|
||||||
- name: one-click-create
|
|
||||||
label:
|
|
||||||
en_US: One-Click Create Bot
|
|
||||||
zh_Hans: 一键创建机器人
|
|
||||||
zh_Hant: 一鍵建立機器人
|
|
||||||
description:
|
|
||||||
en_US: "Scan QR code with WeCom to automatically create a bot and fill in BotId and Secret. Note: Robot Name needs to be filled in manually."
|
|
||||||
zh_Hans: "使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。注意:机器人名称需手动填写。"
|
|
||||||
zh_Hant: "使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。注意:機器人名稱需手動填寫。"
|
|
||||||
type: qr-code-login
|
|
||||||
login_platform: wecombot
|
|
||||||
required: false
|
|
||||||
- name: BotId
|
- name: BotId
|
||||||
label:
|
label:
|
||||||
en_US: BotId
|
en_US: BotId
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import httpx
|
import httpx
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import yaml
|
|
||||||
from async_lru import alru_cache
|
from async_lru import alru_cache
|
||||||
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
||||||
|
|
||||||
@@ -35,10 +34,6 @@ from ..core import taskmgr
|
|||||||
from ..entity.persistence import plugin as persistence_plugin
|
from ..entity.persistence import plugin as persistence_plugin
|
||||||
|
|
||||||
|
|
||||||
class PluginRuntimeNotConnectedError(RuntimeError):
|
|
||||||
"""Raised when plugin runtime operations are requested before connection."""
|
|
||||||
|
|
||||||
|
|
||||||
class PluginRuntimeConnector:
|
class PluginRuntimeConnector:
|
||||||
"""Plugin runtime connector"""
|
"""Plugin runtime connector"""
|
||||||
|
|
||||||
@@ -196,114 +191,44 @@ class PluginRuntimeConnector:
|
|||||||
|
|
||||||
async def ping_plugin_runtime(self):
|
async def ping_plugin_runtime(self):
|
||||||
if not hasattr(self, 'handler'):
|
if not hasattr(self, 'handler'):
|
||||||
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
|
raise Exception('Plugin runtime is not connected')
|
||||||
|
|
||||||
return await self.handler.ping()
|
return await self.handler.ping()
|
||||||
|
|
||||||
def _inspect_plugin_package(
|
def _extract_deps_metadata(
|
||||||
self,
|
self,
|
||||||
file_bytes: bytes,
|
file_bytes: bytes,
|
||||||
task_context: taskmgr.TaskContext | None,
|
task_context: taskmgr.TaskContext | None,
|
||||||
) -> tuple[str | None, str | None]:
|
):
|
||||||
"""Extract plugin identity and dependency metadata from a plugin package."""
|
"""Extract dependency count from requirements.txt inside plugin zip."""
|
||||||
plugin_author = None
|
if task_context is None:
|
||||||
plugin_name = None
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||||
try:
|
for name in zf.namelist():
|
||||||
manifest = yaml.safe_load(zf.read('manifest.yaml').decode('utf-8', errors='ignore')) or {}
|
if name.endswith('requirements.txt'):
|
||||||
metadata = manifest.get('metadata', {})
|
content = zf.read(name).decode('utf-8', errors='ignore')
|
||||||
plugin_author = metadata.get('author')
|
deps = [
|
||||||
plugin_name = metadata.get('name')
|
line.strip()
|
||||||
except Exception:
|
for line in content.splitlines()
|
||||||
pass
|
if line.strip() and not line.strip().startswith('#')
|
||||||
|
]
|
||||||
if task_context is not None:
|
task_context.metadata['deps_total'] = len(deps)
|
||||||
for name in zf.namelist():
|
task_context.metadata['deps_list'] = deps
|
||||||
if name.endswith('requirements.txt'):
|
break
|
||||||
content = zf.read(name).decode('utf-8', errors='ignore')
|
|
||||||
deps = [
|
|
||||||
line.strip()
|
|
||||||
for line in content.splitlines()
|
|
||||||
if line.strip() and not line.strip().startswith('#')
|
|
||||||
]
|
|
||||||
task_context.metadata['deps_total'] = len(deps)
|
|
||||||
task_context.metadata['deps_list'] = deps
|
|
||||||
break
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return plugin_author, plugin_name
|
|
||||||
|
|
||||||
def _build_plugin_startup_failure_message(
|
|
||||||
self,
|
|
||||||
plugin_author: str,
|
|
||||||
plugin_name: str,
|
|
||||||
task_context: taskmgr.TaskContext | None,
|
|
||||||
) -> str:
|
|
||||||
dep_hint = ''
|
|
||||||
if task_context is not None:
|
|
||||||
current_dep = task_context.metadata.get('current_dep')
|
|
||||||
if current_dep:
|
|
||||||
dep_hint = f' Last dependency: {current_dep}.'
|
|
||||||
|
|
||||||
return (
|
|
||||||
f'Plugin {plugin_author}/{plugin_name} failed to start after installation. '
|
|
||||||
f'Dependency installation or plugin initialization may have failed.{dep_hint} '
|
|
||||||
f'Please check the plugin requirements and runtime logs.'
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _wait_for_installed_plugin_ready(
|
|
||||||
self,
|
|
||||||
plugin_author: str | None,
|
|
||||||
plugin_name: str | None,
|
|
||||||
task_context: taskmgr.TaskContext | None,
|
|
||||||
timeout: float = 30,
|
|
||||||
):
|
|
||||||
"""Wait until the installed plugin is registered by the runtime.
|
|
||||||
|
|
||||||
The plugin runtime launches plugins asynchronously. If dependency installation
|
|
||||||
fails, the plugin process exits before registration; without this check the
|
|
||||||
install task can incorrectly finish successfully.
|
|
||||||
"""
|
|
||||||
if not plugin_author or not plugin_name:
|
|
||||||
return
|
|
||||||
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
last_error: Exception | None = None
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
plugin = await self.get_plugin_info(plugin_author, plugin_name)
|
|
||||||
if plugin is not None:
|
|
||||||
status = plugin.get('status')
|
|
||||||
if status == 'initialized':
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
message = self._build_plugin_startup_failure_message(plugin_author, plugin_name, task_context)
|
|
||||||
if last_error is not None:
|
|
||||||
message = f'{message} Last runtime error: {last_error}'
|
|
||||||
raise RuntimeError(message)
|
|
||||||
|
|
||||||
async def install_plugin(
|
async def install_plugin(
|
||||||
self,
|
self,
|
||||||
install_source: PluginInstallSource,
|
install_source: PluginInstallSource,
|
||||||
install_info: dict[str, Any],
|
install_info: dict[str, Any],
|
||||||
task_context: taskmgr.TaskContext | None = None,
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
):
|
):
|
||||||
plugin_author = install_info.get('plugin_author')
|
|
||||||
plugin_name = install_info.get('plugin_name')
|
|
||||||
|
|
||||||
if install_source == PluginInstallSource.LOCAL:
|
if install_source == PluginInstallSource.LOCAL:
|
||||||
# transfer file before install
|
# transfer file before install
|
||||||
file_bytes = install_info['plugin_file']
|
file_bytes = install_info['plugin_file']
|
||||||
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
self._extract_deps_metadata(file_bytes, task_context)
|
||||||
if task_context is not None and plugin_author and plugin_name:
|
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
del install_info['plugin_file']
|
del install_info['plugin_file']
|
||||||
@@ -340,9 +265,7 @@ class PluginRuntimeConnector:
|
|||||||
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
||||||
|
|
||||||
file_bytes = b''.join(chunks)
|
file_bytes = b''.join(chunks)
|
||||||
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
self._extract_deps_metadata(file_bytes, task_context)
|
||||||
if task_context is not None and plugin_author and plugin_name:
|
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
@@ -366,8 +289,6 @@ class PluginRuntimeConnector:
|
|||||||
if metadata is not None and task_context is not None:
|
if metadata is not None and task_context is not None:
|
||||||
task_context.metadata.update(metadata)
|
task_context.metadata.update(metadata)
|
||||||
|
|
||||||
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
|
|
||||||
|
|
||||||
async def upgrade_plugin(
|
async def upgrade_plugin(
|
||||||
self,
|
self,
|
||||||
plugin_author: str,
|
plugin_author: str,
|
||||||
@@ -510,17 +431,6 @@ class PluginRuntimeConnector:
|
|||||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||||
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
||||||
|
|
||||||
async def handle_page_api(
|
|
||||||
self,
|
|
||||||
plugin_author: str,
|
|
||||||
plugin_name: str,
|
|
||||||
page_id: str,
|
|
||||||
endpoint: str,
|
|
||||||
method: str,
|
|
||||||
body: Any = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
|
||||||
|
|
||||||
async def get_debug_info(self) -> dict[str, Any]:
|
async def get_debug_info(self) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""Get debug information including debug key and WS URL"""
|
||||||
if not self.is_enable_plugin:
|
if not self.is_enable_plugin:
|
||||||
@@ -637,12 +547,11 @@ class PluginRuntimeConnector:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If plugin_id is not in the expected 'author/name' format.
|
ValueError: If plugin_id is not in the expected 'author/name' format.
|
||||||
"""
|
"""
|
||||||
segments = plugin_id.split('/')
|
if '/' not in plugin_id:
|
||||||
if len(segments) != 2 or not all(segments):
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid plugin_id format: '{plugin_id}'. Expected 'author/name' format (e.g. 'langbot/rag-engine')."
|
f"Invalid plugin_id format: '{plugin_id}'. Expected 'author/name' format (e.g. 'langbot/rag-engine')."
|
||||||
)
|
)
|
||||||
return segments[0], segments[1]
|
return plugin_id.split('/', 1)
|
||||||
|
|
||||||
async def call_rag_ingest(self, plugin_id: str, context_data: dict[str, Any]) -> dict[str, Any]:
|
async def call_rag_ingest(self, plugin_id: str, context_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Call plugin to ingest document.
|
"""Call plugin to ingest document.
|
||||||
|
|||||||
@@ -367,22 +367,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
owner_type = data['owner_type']
|
owner_type = data['owner_type']
|
||||||
owner = data['owner']
|
owner = data['owner']
|
||||||
value = base64.b64decode(data['value_base64'])
|
value = base64.b64decode(data['value_base64'])
|
||||||
max_value_bytes = (
|
|
||||||
self.ap.instance_config.data.get('plugin', {})
|
|
||||||
.get('binary_storage', {})
|
|
||||||
.get(
|
|
||||||
'max_value_bytes',
|
|
||||||
10 * 1024 * 1024,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
max_value_bytes = int(max_value_bytes)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
max_value_bytes = 10 * 1024 * 1024
|
|
||||||
if max_value_bytes >= 0 and len(value) > max_value_bytes:
|
|
||||||
return handler.ActionResponse.error(
|
|
||||||
message=f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)',
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
@@ -955,11 +939,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
timeout=20,
|
timeout=20,
|
||||||
)
|
)
|
||||||
asset_file_key = result['file_file_key']
|
asset_file_key = result['file_file_key']
|
||||||
if not asset_file_key:
|
|
||||||
return {
|
|
||||||
'asset_base64': '',
|
|
||||||
'mime_type': '',
|
|
||||||
}
|
|
||||||
mime_type = result['mime_type']
|
mime_type = result['mime_type']
|
||||||
asset_bytes = await self.read_local_file(asset_file_key)
|
asset_bytes = await self.read_local_file(asset_file_key)
|
||||||
await self.delete_local_file(asset_file_key)
|
await self.delete_local_file(asset_file_key)
|
||||||
@@ -968,30 +947,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
'mime_type': mime_type,
|
'mime_type': mime_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def handle_page_api(
|
|
||||||
self,
|
|
||||||
plugin_author: str,
|
|
||||||
plugin_name: str,
|
|
||||||
page_id: str,
|
|
||||||
endpoint: str,
|
|
||||||
method: str,
|
|
||||||
body: Any = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Forward a page API call to the plugin via runtime."""
|
|
||||||
result = await self.call_action(
|
|
||||||
LangBotToRuntimeAction.PAGE_API,
|
|
||||||
{
|
|
||||||
'plugin_author': plugin_author,
|
|
||||||
'plugin_name': plugin_name,
|
|
||||||
'page_id': page_id,
|
|
||||||
'endpoint': endpoint,
|
|
||||||
'method': method,
|
|
||||||
'body': body,
|
|
||||||
},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
|
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
|
||||||
"""Cleanup plugin settings and binary storage"""
|
"""Cleanup plugin settings and binary storage"""
|
||||||
# Delete plugin settings
|
# Delete plugin settings
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from ...discover import engine
|
|||||||
from . import token
|
from . import token
|
||||||
from ...entity.persistence import model as persistence_model
|
from ...entity.persistence import model as persistence_model
|
||||||
from ...entity.errors import provider as provider_errors
|
from ...entity.errors import provider as provider_errors
|
||||||
|
from async_lru import alru_cache
|
||||||
|
|
||||||
|
|
||||||
class ModelManager:
|
class ModelManager:
|
||||||
@@ -23,8 +24,6 @@ class ModelManager:
|
|||||||
|
|
||||||
embedding_models: list[requester.RuntimeEmbeddingModel]
|
embedding_models: list[requester.RuntimeEmbeddingModel]
|
||||||
|
|
||||||
rerank_models: list[requester.RuntimeRerankModel]
|
|
||||||
|
|
||||||
requester_components: list[engine.Component]
|
requester_components: list[engine.Component]
|
||||||
|
|
||||||
requester_dict: dict[str, type[requester.ProviderAPIRequester]]
|
requester_dict: dict[str, type[requester.ProviderAPIRequester]]
|
||||||
@@ -33,7 +32,6 @@ class ModelManager:
|
|||||||
self.ap = ap
|
self.ap = ap
|
||||||
self.llm_models = []
|
self.llm_models = []
|
||||||
self.embedding_models = []
|
self.embedding_models = []
|
||||||
self.rerank_models = []
|
|
||||||
self.requester_components = []
|
self.requester_components = []
|
||||||
self.requester_dict = {}
|
self.requester_dict = {}
|
||||||
|
|
||||||
@@ -66,7 +64,8 @@ class ModelManager:
|
|||||||
|
|
||||||
self.llm_models = []
|
self.llm_models = []
|
||||||
self.embedding_models = []
|
self.embedding_models = []
|
||||||
self.rerank_models = []
|
|
||||||
|
# Load all providers first
|
||||||
self.provider_dict = {}
|
self.provider_dict = {}
|
||||||
providers_result = await self.ap.persistence_mgr.execute_async(
|
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_model.ModelProvider)
|
sqlalchemy.select(persistence_model.ModelProvider)
|
||||||
@@ -111,22 +110,6 @@ class ModelManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
|
self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
|
||||||
|
|
||||||
# Load rerank models
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
|
|
||||||
rerank_models = result.all()
|
|
||||||
for rerank_model in rerank_models:
|
|
||||||
try:
|
|
||||||
provider = self.provider_dict.get(rerank_model.provider_uuid)
|
|
||||||
if provider is None:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Provider {rerank_model.provider_uuid} not found for model {rerank_model.uuid}'
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
runtime_rerank_model = await self.load_rerank_model_with_provider(rerank_model, provider)
|
|
||||||
self.rerank_models.append(runtime_rerank_model)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.error(f'Failed to load model {rerank_model.uuid}: {e}\n{traceback.format_exc()}')
|
|
||||||
|
|
||||||
async def sync_new_models_from_space(self):
|
async def sync_new_models_from_space(self):
|
||||||
"""Sync models from Space"""
|
"""Sync models from Space"""
|
||||||
space_model_provider = await self.ap.persistence_mgr.execute_async(
|
space_model_provider = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -229,26 +212,6 @@ class ModelManager:
|
|||||||
|
|
||||||
return runtime_embedding_model
|
return runtime_embedding_model
|
||||||
|
|
||||||
async def init_temporary_runtime_rerank_model(
|
|
||||||
self,
|
|
||||||
model_info: dict,
|
|
||||||
) -> requester.RuntimeRerankModel:
|
|
||||||
"""Initialize runtime rerank model from dict (for testing)"""
|
|
||||||
provider_info = model_info.get('provider', {})
|
|
||||||
runtime_provider = await self.load_provider(provider_info)
|
|
||||||
|
|
||||||
runtime_rerank_model = requester.RuntimeRerankModel(
|
|
||||||
model_entity=persistence_model.RerankModel(
|
|
||||||
uuid=model_info.get('uuid', ''),
|
|
||||||
name=model_info.get('name', ''),
|
|
||||||
provider_uuid='',
|
|
||||||
extra_args=model_info.get('extra_args', {}),
|
|
||||||
),
|
|
||||||
provider=runtime_provider,
|
|
||||||
)
|
|
||||||
|
|
||||||
return runtime_rerank_model
|
|
||||||
|
|
||||||
async def load_provider(
|
async def load_provider(
|
||||||
self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict
|
self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict
|
||||||
) -> requester.RuntimeProvider:
|
) -> requester.RuntimeProvider:
|
||||||
@@ -306,9 +269,6 @@ class ModelManager:
|
|||||||
for model in self.embedding_models:
|
for model in self.embedding_models:
|
||||||
if model.provider.provider_entity.uuid == provider_uuid:
|
if model.provider.provider_entity.uuid == provider_uuid:
|
||||||
model.provider = new_runtime_provider
|
model.provider = new_runtime_provider
|
||||||
for model in self.rerank_models:
|
|
||||||
if model.provider.provider_entity.uuid == provider_uuid:
|
|
||||||
model.provider = new_runtime_provider
|
|
||||||
|
|
||||||
# update ref in provider dict
|
# update ref in provider dict
|
||||||
self.provider_dict[provider_uuid] = new_runtime_provider
|
self.provider_dict[provider_uuid] = new_runtime_provider
|
||||||
@@ -345,22 +305,6 @@ class ModelManager:
|
|||||||
|
|
||||||
return runtime_embedding_model
|
return runtime_embedding_model
|
||||||
|
|
||||||
async def load_rerank_model_with_provider(
|
|
||||||
self,
|
|
||||||
model_info: persistence_model.RerankModel | sqlalchemy.Row,
|
|
||||||
provider: requester.RuntimeProvider,
|
|
||||||
) -> requester.RuntimeRerankModel:
|
|
||||||
"""Load rerank model with provider info"""
|
|
||||||
if isinstance(model_info, sqlalchemy.Row):
|
|
||||||
model_info = persistence_model.RerankModel(**model_info._mapping)
|
|
||||||
|
|
||||||
runtime_rerank_model = requester.RuntimeRerankModel(
|
|
||||||
model_entity=model_info,
|
|
||||||
provider=provider,
|
|
||||||
)
|
|
||||||
|
|
||||||
return runtime_rerank_model
|
|
||||||
|
|
||||||
async def load_llm_model(self, model_info: dict):
|
async def load_llm_model(self, model_info: dict):
|
||||||
"""Load LLM model from dict (with provider info)"""
|
"""Load LLM model from dict (with provider info)"""
|
||||||
provider_info = model_info.get('provider', {})
|
provider_info = model_info.get('provider', {})
|
||||||
@@ -408,6 +352,7 @@ class ModelManager:
|
|||||||
|
|
||||||
await self.load_embedding_model_with_provider(model_entity, provider_entity)
|
await self.load_embedding_model_with_provider(model_entity, provider_entity)
|
||||||
|
|
||||||
|
@alru_cache(ttl=60 * 5)
|
||||||
async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
|
async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
|
||||||
"""Get LLM model by uuid"""
|
"""Get LLM model by uuid"""
|
||||||
for model in self.llm_models:
|
for model in self.llm_models:
|
||||||
@@ -415,6 +360,7 @@ class ModelManager:
|
|||||||
return model
|
return model
|
||||||
raise ValueError(f'LLM model {uuid} not found')
|
raise ValueError(f'LLM model {uuid} not found')
|
||||||
|
|
||||||
|
@alru_cache(ttl=60 * 5)
|
||||||
async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
|
async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
|
||||||
"""Get embedding model by uuid"""
|
"""Get embedding model by uuid"""
|
||||||
for model in self.embedding_models:
|
for model in self.embedding_models:
|
||||||
@@ -422,13 +368,6 @@ class ModelManager:
|
|||||||
return model
|
return model
|
||||||
raise ValueError(f'Embedding model {uuid} not found')
|
raise ValueError(f'Embedding model {uuid} not found')
|
||||||
|
|
||||||
async def get_rerank_model_by_uuid(self, uuid: str) -> requester.RuntimeRerankModel:
|
|
||||||
"""Get rerank model by uuid"""
|
|
||||||
for model in self.rerank_models:
|
|
||||||
if model.model_entity.uuid == uuid:
|
|
||||||
return model
|
|
||||||
raise ValueError(f'Rerank model {uuid} not found')
|
|
||||||
|
|
||||||
async def remove_llm_model(self, model_uuid: str):
|
async def remove_llm_model(self, model_uuid: str):
|
||||||
"""Remove LLM model"""
|
"""Remove LLM model"""
|
||||||
for model in self.llm_models:
|
for model in self.llm_models:
|
||||||
@@ -443,13 +382,6 @@ class ModelManager:
|
|||||||
self.embedding_models.remove(model)
|
self.embedding_models.remove(model)
|
||||||
return
|
return
|
||||||
|
|
||||||
async def remove_rerank_model(self, model_uuid: str):
|
|
||||||
"""Remove rerank model"""
|
|
||||||
for model in self.rerank_models:
|
|
||||||
if model.model_entity.uuid == model_uuid:
|
|
||||||
self.rerank_models.remove(model)
|
|
||||||
return
|
|
||||||
|
|
||||||
def get_available_requesters_info(self, model_type: str) -> list[dict]:
|
def get_available_requesters_info(self, model_type: str) -> list[dict]:
|
||||||
"""Get all available requesters"""
|
"""Get all available requesters"""
|
||||||
if model_type != '':
|
if model_type != '':
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class RuntimeProvider:
|
|||||||
|
|
||||||
# Import monitoring helper
|
# Import monitoring helper
|
||||||
try:
|
try:
|
||||||
from ...pipeline import monitor
|
from ...pipeline import monitoring_helper
|
||||||
|
|
||||||
# Get monitoring metadata from query variables
|
# Get monitoring metadata from query variables
|
||||||
if query.variables:
|
if query.variables:
|
||||||
@@ -96,7 +96,7 @@ class RuntimeProvider:
|
|||||||
pipeline_name = 'Unknown'
|
pipeline_name = 'Unknown'
|
||||||
message_id = None
|
message_id = None
|
||||||
|
|
||||||
await monitor.MonitoringHelper.record_llm_call(
|
await monitoring_helper.MonitoringHelper.record_llm_call(
|
||||||
ap=self.requester.ap,
|
ap=self.requester.ap,
|
||||||
query=query,
|
query=query,
|
||||||
bot_id=query.bot_uuid or 'unknown',
|
bot_id=query.bot_uuid or 'unknown',
|
||||||
@@ -154,7 +154,7 @@ class RuntimeProvider:
|
|||||||
|
|
||||||
# Import monitoring helper
|
# Import monitoring helper
|
||||||
try:
|
try:
|
||||||
from ...pipeline import monitor
|
from ...pipeline import monitoring_helper
|
||||||
|
|
||||||
# Get monitoring metadata from query variables
|
# Get monitoring metadata from query variables
|
||||||
if query.variables:
|
if query.variables:
|
||||||
@@ -166,7 +166,7 @@ class RuntimeProvider:
|
|||||||
pipeline_name = 'Unknown'
|
pipeline_name = 'Unknown'
|
||||||
message_id = None
|
message_id = None
|
||||||
|
|
||||||
await monitor.MonitoringHelper.record_llm_call(
|
await monitoring_helper.MonitoringHelper.record_llm_call(
|
||||||
ap=self.requester.ap,
|
ap=self.requester.ap,
|
||||||
query=query,
|
query=query,
|
||||||
bot_id=query.bot_uuid or 'unknown',
|
bot_id=query.bot_uuid or 'unknown',
|
||||||
@@ -247,40 +247,6 @@ class RuntimeProvider:
|
|||||||
except Exception as monitor_err:
|
except Exception as monitor_err:
|
||||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}')
|
self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}')
|
||||||
|
|
||||||
async def invoke_rerank(
|
|
||||||
self,
|
|
||||||
model: RuntimeRerankModel,
|
|
||||||
query: str,
|
|
||||||
documents: typing.List[str],
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
) -> typing.List[dict]:
|
|
||||||
"""Bridge method for invoking rerank with monitoring"""
|
|
||||||
start_time = time.time()
|
|
||||||
status = 'success'
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.requester.invoke_rerank(
|
|
||||||
model=model,
|
|
||||||
query=query,
|
|
||||||
documents=documents,
|
|
||||||
extra_args=extra_args,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
status = 'error'
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.requester.ap.logger.debug(
|
|
||||||
f'[Rerank] model={model.model_entity.name} docs={len(documents)} '
|
|
||||||
f'duration={duration_ms}ms status={status}'
|
|
||||||
)
|
|
||||||
except Exception as monitor_err:
|
|
||||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record rerank call: {monitor_err}')
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeLLMModel:
|
class RuntimeLLMModel:
|
||||||
"""运行时模型"""
|
"""运行时模型"""
|
||||||
@@ -318,29 +284,10 @@ class RuntimeEmbeddingModel:
|
|||||||
self.provider = provider
|
self.provider = provider
|
||||||
|
|
||||||
|
|
||||||
class RuntimeRerankModel:
|
|
||||||
"""运行时 Rerank 模型"""
|
|
||||||
|
|
||||||
model_entity: persistence_model.RerankModel
|
|
||||||
"""模型数据"""
|
|
||||||
|
|
||||||
provider: RuntimeProvider
|
|
||||||
"""提供商实例"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
model_entity: persistence_model.RerankModel,
|
|
||||||
provider: RuntimeProvider,
|
|
||||||
):
|
|
||||||
self.model_entity = model_entity
|
|
||||||
self.provider = provider
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderAPIRequester(metaclass=abc.ABCMeta):
|
class ProviderAPIRequester(metaclass=abc.ABCMeta):
|
||||||
"""Provider API请求器"""
|
"""Provider API请求器"""
|
||||||
|
|
||||||
name: str = None
|
name: str = None
|
||||||
init_api_key: str = 'langbot-init-placeholder'
|
|
||||||
|
|
||||||
ap: app.Application
|
ap: app.Application
|
||||||
|
|
||||||
@@ -429,23 +376,3 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
|
|||||||
或者 tuple[typing.List[typing.List[float]], dict]: 返回 (embedding 向量, usage_info)
|
或者 tuple[typing.List[typing.List[float]], dict]: 返回 (embedding 向量, usage_info)
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def invoke_rerank(
|
|
||||||
self,
|
|
||||||
model: RuntimeRerankModel,
|
|
||||||
query: str,
|
|
||||||
documents: typing.List[str],
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
) -> typing.List[dict]:
|
|
||||||
"""调用 Rerank API
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model (RuntimeRerankModel): 使用的模型信息
|
|
||||||
query (str): 查询文本
|
|
||||||
documents (typing.List[str]): 待重排序的文档列表
|
|
||||||
extra_args (dict[str, typing.Any], optional): 额外的参数. Defaults to {}.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
typing.List[dict]: [{"index": int, "relevance_score": float}, ...]
|
|
||||||
"""
|
|
||||||
raise NotImplementedError('This requester does not support rerank')
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ spec:
|
|||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
- rerank
|
|
||||||
provider_category: maas
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ spec:
|
|||||||
default: 120
|
default: 120
|
||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- rerank
|
|
||||||
provider_category: maas
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
|
|||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
self.client = openai.AsyncClient(
|
self.client = openai.AsyncClient(
|
||||||
api_key=self.init_api_key,
|
api_key='',
|
||||||
base_url=self.requester_cfg['base_url'].replace(' ', ''),
|
base_url=self.requester_cfg['base_url'].replace(' ', ''),
|
||||||
timeout=self.requester_cfg['timeout'],
|
timeout=self.requester_cfg['timeout'],
|
||||||
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
||||||
@@ -615,88 +615,3 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
|
|||||||
raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')
|
raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')
|
||||||
except openai.APIError as e:
|
except openai.APIError as e:
|
||||||
raise errors.RequesterError(f'请求错误: {e.message}')
|
raise errors.RequesterError(f'请求错误: {e.message}')
|
||||||
|
|
||||||
async def invoke_rerank(
|
|
||||||
self,
|
|
||||||
model: requester.RuntimeRerankModel,
|
|
||||||
query: str,
|
|
||||||
documents: typing.List[str],
|
|
||||||
extra_args: dict[str, typing.Any] = {},
|
|
||||||
) -> typing.List[dict]:
|
|
||||||
"""Standard /rerank endpoint (Jina/Cohere/SiliconFlow/Voyage/DashScope compatible)
|
|
||||||
|
|
||||||
Supports extra_args from model.extra_args:
|
|
||||||
- rerank_url: full URL override (e.g. "https://dashscope.aliyuncs.com/compatible-api/v1/reranks")
|
|
||||||
- rerank_path: path override appended to base_url (e.g. "reranks" instead of default "rerank")
|
|
||||||
- Any other fields are merged into the request payload.
|
|
||||||
"""
|
|
||||||
api_key = model.provider.token_mgr.get_token()
|
|
||||||
base_url = self.requester_cfg.get('base_url', '').rstrip('/')
|
|
||||||
timeout = self.requester_cfg.get('timeout', 120)
|
|
||||||
|
|
||||||
merged_args = {}
|
|
||||||
if model.model_entity.extra_args:
|
|
||||||
merged_args.update(model.model_entity.extra_args)
|
|
||||||
if extra_args:
|
|
||||||
merged_args.update(extra_args)
|
|
||||||
|
|
||||||
rerank_url = merged_args.pop('rerank_url', None)
|
|
||||||
rerank_path = merged_args.pop('rerank_path', 'rerank')
|
|
||||||
if not rerank_url:
|
|
||||||
rerank_url = f'{base_url}/{rerank_path}'
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'model': model.model_entity.name,
|
|
||||||
'query': query,
|
|
||||||
'documents': documents[:64],
|
|
||||||
'top_n': min(len(documents), 64),
|
|
||||||
}
|
|
||||||
|
|
||||||
if merged_args:
|
|
||||||
payload.update(merged_args)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(trust_env=True, timeout=timeout) as client:
|
|
||||||
resp = await client.post(rerank_url, headers=headers, json=payload)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
results = self._parse_rerank_response(data)
|
|
||||||
|
|
||||||
if results:
|
|
||||||
scores = [r.get('relevance_score', 0.0) for r in results]
|
|
||||||
min_score = min(scores)
|
|
||||||
max_score = max(scores)
|
|
||||||
if max_score - min_score > 1e-6:
|
|
||||||
for r in results:
|
|
||||||
r['relevance_score'] = (r['relevance_score'] - min_score) / (max_score - min_score)
|
|
||||||
|
|
||||||
return results
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
raise errors.RequesterError(f'Rerank request failed: {e.response.status_code} - {e.response.text}')
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
raise errors.RequesterError('Rerank request timed out')
|
|
||||||
except Exception as e:
|
|
||||||
raise errors.RequesterError(f'Rerank request error: {str(e)}')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_rerank_response(data: dict) -> typing.List[dict]:
|
|
||||||
"""Parse rerank response from various providers.
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- Jina/Cohere/SiliconFlow: {"results": [{"index", "relevance_score"}]}
|
|
||||||
- Voyage AI: {"data": [{"index", "relevance_score"}]}
|
|
||||||
- DashScope: {"output": {"results": [{"index", "relevance_score"}]}}
|
|
||||||
"""
|
|
||||||
if 'results' in data:
|
|
||||||
return data['results']
|
|
||||||
if 'data' in data:
|
|
||||||
return data['data']
|
|
||||||
if 'output' in data and isinstance(data['output'], dict):
|
|
||||||
return data['output'].get('results', [])
|
|
||||||
return []
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ spec:
|
|||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
- rerank
|
|
||||||
provider_category: manufacturer
|
provider_category: manufacturer
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128" id="Chroma--Streamline-Svg-Logos" height="128" width="128">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<desc>
|
<rect width="24" height="24" rx="5" fill="#7B68EE"/>
|
||||||
Chroma Streamline Icon: https://streamlinehq.com
|
<circle cx="12" cy="12" r="6" fill="#FF6B35"/>
|
||||||
</desc>
|
<circle cx="12" cy="12" r="3" fill="#7B68EE"/>
|
||||||
<path fill="#ffde2d" d="M84.88839999999999 104.10666666666665c23.0732 0 41.77773333333333 -17.956266666666664 41.77773333333333 -40.10653333333333 0 -22.150266666666667 -18.70453333333333 -40.10653333333333 -41.77773333333333 -40.10653333333333 -23.0732 0 -41.77773333333333 17.956266666666664 -41.77773333333333 40.10653333333333 0 22.150266666666667 18.70453333333333 40.10653333333333 41.77773333333333 40.10653333333333Z" stroke-width="1.3333"></path>
|
<path d="M12 6V18" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
<path fill="#327eff" d="M43.111066666666666 104.10666666666665c23.0732 0 41.77773333333333 -17.956266666666664 41.77773333333333 -40.10653333333333 0 -22.150266666666667 -18.70453333333333 -40.10653333333333 -41.77773333333333 -40.10653333333333C20.037866666666666 23.8936 1.3333333333333333 41.849866666666664 1.3333333333333333 64.00013333333334 1.3333333333333333 86.15039999999999 20.037866666666666 104.10666666666665 43.111066666666666 104.10666666666665Z" stroke-width="1.3333"></path>
|
<path d="M6 12H18" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
<path fill="#ff6446" d="M84.88866666666667 64.00013333333334c0 22.150399999999998 -18.704666666666665 40.10626666666666 -41.778 40.10626666666666V64.00013333333334h41.778Zm-41.778 0c0 -22.150266666666667 18.70453333333333 -40.10653333333333 41.778 -40.10653333333333v40.10653333333333H43.11066666666666Z" stroke-width="1.3333"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 413 B |
@@ -1 +0,0 @@
|
|||||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 769 B |
@@ -1,31 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: LLMAPIRequester
|
|
||||||
metadata:
|
|
||||||
name: cohere-rerank
|
|
||||||
label:
|
|
||||||
en_US: Cohere
|
|
||||||
zh_Hans: Cohere
|
|
||||||
icon: cohere.svg
|
|
||||||
spec:
|
|
||||||
config:
|
|
||||||
- name: base_url
|
|
||||||
label:
|
|
||||||
en_US: Base URL
|
|
||||||
zh_Hans: 基础 URL
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: https://api.cohere.com/v2
|
|
||||||
- name: timeout
|
|
||||||
label:
|
|
||||||
en_US: Timeout
|
|
||||||
zh_Hans: 超时时间
|
|
||||||
type: integer
|
|
||||||
required: true
|
|
||||||
default: 120
|
|
||||||
support_type:
|
|
||||||
- rerank
|
|
||||||
provider_category: manufacturer
|
|
||||||
execution:
|
|
||||||
python:
|
|
||||||
path: ./chatcmpl.py
|
|
||||||
attr: OpenAIChatCompletions
|
|
||||||
@@ -25,7 +25,6 @@ spec:
|
|||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
- rerank
|
|
||||||
provider_category: maas
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Jina</title><path d="M6.608 21.416a4.608 4.608 0 100-9.217 4.608 4.608 0 000 9.217zM20.894 2.015c.614 0 1.106.492 1.106 1.106v9.002c0 5.13-4.148 9.309-9.217 9.37v-9.355l-.03-9.032c0-.614.491-1.106 1.106-1.106h7.158l-.123.015z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 404 B |
@@ -1,31 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: LLMAPIRequester
|
|
||||||
metadata:
|
|
||||||
name: jina-rerank
|
|
||||||
label:
|
|
||||||
en_US: Jina
|
|
||||||
zh_Hans: Jina
|
|
||||||
icon: jina.svg
|
|
||||||
spec:
|
|
||||||
config:
|
|
||||||
- name: base_url
|
|
||||||
label:
|
|
||||||
en_US: Base URL
|
|
||||||
zh_Hans: 基础 URL
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: https://api.jina.ai/v1
|
|
||||||
- name: timeout
|
|
||||||
label:
|
|
||||||
en_US: Timeout
|
|
||||||
zh_Hans: 超时时间
|
|
||||||
type: integer
|
|
||||||
required: true
|
|
||||||
default: 120
|
|
||||||
support_type:
|
|
||||||
- rerank
|
|
||||||
provider_category: manufacturer
|
|
||||||
execution:
|
|
||||||
python:
|
|
||||||
path: ./chatcmpl.py
|
|
||||||
attr: OpenAIChatCompletions
|
|
||||||
@@ -25,7 +25,7 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
|
|||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
self.client = openai.AsyncClient(
|
self.client = openai.AsyncClient(
|
||||||
api_key=self.init_api_key,
|
api_key='',
|
||||||
base_url=self.requester_cfg['base_url'],
|
base_url=self.requester_cfg['base_url'],
|
||||||
timeout=self.requester_cfg['timeout'],
|
timeout=self.requester_cfg['timeout'],
|
||||||
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ spec:
|
|||||||
support_type:
|
support_type:
|
||||||
- llm
|
- llm
|
||||||
- text-embedding
|
- text-embedding
|
||||||
- rerank
|
|
||||||
provider_category: maas
|
provider_category: maas
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qiniu</title><path d="M23.111 4.6a.914.914 0 00-.861.161A13.443 13.443 0 017.947 8.897L7.38 6.831a1.076 1.076 0 00-1.211-.698l.27 2.18c-1.816-.827-2.313-.946-3.587-2.45C2.674 5.729 1.263 4.472.89 4.6a11.906 11.906 0 005.892 6.497l.738 5.97s.33 2.286 2.473 2.286h4.586c2.144 0 2.474-2.286 2.474-2.286l.518-4.28c-1.393-.11-2.268.857-2.546 1.814-.465 1.614-.465 1.716-.557 1.998-.188.575-.806.644-.806.644h-2.753s-.617-.07-.806-.644c-.12-.371-.727-2.54-1.335-4.74A11.877 11.877 0 0023.11 4.599V4.6z" fill="#06AEEF"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 649 B |