mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-18 03:34:20 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc4d8838eb | |||
| fa0a77f09f | |||
| fd6a7b73d4 | |||
| bf0848d60b | |||
| e06fac2bb7 | |||
| bec61427a0 | |||
| 5fae7b2eb0 | |||
| 2eebdfe16a | |||
| 9cd3544d59 | |||
| de4d14fee3 | |||
| f29c568381 | |||
| af3f557055 | |||
| b894842736 | |||
| e190029e1f | |||
| e4940a8050 | |||
| 617c95ebc4 | |||
| 1cdd428bcc | |||
| 71ac719aee | |||
| 4621e6cc9f | |||
| 66087f83e1 | |||
| 25f9330491 | |||
| 14b1e0d33b | |||
| 83ccb33fd3 | |||
| 05bcf543ba | |||
| 7cd063bb5d | |||
| 8f1317b39e | |||
| 77a0de5ef0 | |||
| 875227a2fe | |||
| 2317392ee5 | |||
| c7efa4dd7f | |||
| e701daa8e0 | |||
| 1ae99199b2 | |||
| 7c067a1cb3 | |||
| 478bc62576 | |||
| a740eb8ee9 | |||
| f8aedd02b3 | |||
| ea638cab80 | |||
| 7129dd536e | |||
| 1b1cc7769b | |||
| 44b8354dfd | |||
| 55ec9d11ae | |||
| 5b3d3801b5 | |||
| 9f1ea75d09 | |||
| 6e37aae636 | |||
| 921d12f596 | |||
| 6bf6deaefd | |||
| 1201949f2c | |||
| 1c419e3591 | |||
| b0a9be77b0 | |||
| e02ade5a30 | |||
| 1a51ba8e7e | |||
| e7b22d6ebf | |||
| dddfa8ac79 | |||
| 99e2976826 | |||
| 71e44f0e54 | |||
| 4c904c2375 |
@@ -1,5 +1,5 @@
|
|||||||
name: 漏洞反馈
|
name: 漏洞反馈
|
||||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://docs.langbot.app/zh/workshop/network-details.html
|
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://docs.langbot.app/en/workshop/network-details.html
|
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd /tmp/langbot_build_web/web
|
cd /tmp/langbot_build_web/web
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npx vite build
|
||||||
- name: Package Output
|
- name: Package Output
|
||||||
run: |
|
run: |
|
||||||
cp -r /tmp/langbot_build_web/web/out ./web
|
cp -r /tmp/langbot_build_web/web/dist ./web
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ jobs:
|
|||||||
npm install -g pnpm
|
npm install -g pnpm
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
mkdir -p ../src/langbot/web/out
|
mkdir -p ../src/langbot/web/dist
|
||||||
cp -r out ../src/langbot/web/
|
cp -r dist ../src/langbot/web/
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
name: Test Migrations
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- 'src/langbot/pkg/persistence/**'
|
||||||
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
paths:
|
||||||
|
- 'src/langbot/pkg/persistence/**'
|
||||||
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-migrations-sqlite:
|
||||||
|
name: Migrations (SQLite)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Test Alembic upgrade (SQLite)
|
||||||
|
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:
|
||||||
|
name: Migrations (PostgreSQL)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: langbot
|
||||||
|
POSTGRES_PASSWORD: langbot
|
||||||
|
POSTGRES_DB: langbot_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd="pg_isready -U langbot"
|
||||||
|
--health-interval=5s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Test Alembic upgrade (PostgreSQL)
|
||||||
|
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
|
||||||
|
|
||||||
|
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())
|
||||||
|
"
|
||||||
@@ -52,3 +52,6 @@ src/langbot/web/
|
|||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
|
# Next.js build cache (legacy)
|
||||||
|
web/.next/
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
|
|
||||||
RUN cd web && npm install && npm run build
|
RUN cd web && npm install && npx vite build
|
||||||
|
|
||||||
FROM python:3.12.7-slim
|
FROM python:3.12.7-slim
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
COPY --from=node /app/web/out ./web/out
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
RUN apt update \
|
RUN apt update \
|
||||||
&& apt install gcc -y \
|
&& apt install gcc -y \
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Website</a> |
|
<a href="https://langbot.app">Website</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features">Features</a> |
|
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide">Docs</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
||||||
@@ -45,7 +45,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
|||||||
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
||||||
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
||||||
|
|
||||||
[→ Learn more about all features](https://docs.langbot.app/en/insight/features)
|
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**More options:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt) · [Kubernetes](./docker/README_K8S.md)
|
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ docker compose up -d
|
|||||||
| [接口 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 | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://docs.langbot.app/en/insight/features)
|
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官网</a> |
|
<a href="https://langbot.app">官网</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/guide.html">文档</a> |
|
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
||||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">插件市场</a> |
|
<a href="https://space.langbot.app">插件市场</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||||
@@ -45,7 +45,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
|||||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||||
|
|
||||||
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [宝塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ docker compose up -d
|
|||||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
|
|
||||||
[→ 查看完整集成列表](https://docs.langbot.app/zh/insight/features.html)
|
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
### TTS(语音合成)
|
### TTS(语音合成)
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Inicio</a> |
|
<a href="https://langbot.app">Inicio</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Características</a> |
|
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Documentación</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
|||||||
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
||||||
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
||||||
|
|
||||||
[→ Conocer más sobre todas las funcionalidades](https://docs.langbot.app/en/insight/features.html)
|
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Más opciones:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 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 | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://docs.langbot.app/en/insight/features.html)
|
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Accueil</a> |
|
<a href="https://langbot.app">Accueil</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a> |
|
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Documentation</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
|||||||
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
||||||
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
||||||
|
|
||||||
[→ En savoir plus sur toutes les fonctionnalités](https://docs.langbot.app/en/insight/features.html)
|
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Plus d'options :** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manuel](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [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 | ✅ |
|
||||||
|
|
||||||
[→ Voir toutes les intégrations](https://docs.langbot.app/en/insight/features.html)
|
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">ホーム</a> |
|
<a href="https://langbot.app">ホーム</a> |
|
||||||
<a href="https://docs.langbot.app/ja/insight/features.html">機能</a> |
|
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
||||||
<a href="https://docs.langbot.app/ja/insight/guide.html">ドキュメント</a> |
|
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
||||||
<a href="https://docs.langbot.app/ja/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
|||||||
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
||||||
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
||||||
|
|
||||||
[→ すべての機能について詳しく見る](https://docs.langbot.app/ja/insight/features.html)
|
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**その他:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://docs.langbot.app/en/insight/features.html)
|
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">홈</a> |
|
<a href="https://langbot.app">홈</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">기능</a> |
|
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">문서</a> |
|
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
|||||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
||||||
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
||||||
|
|
||||||
[→ 모든 기능 자세히 보기](https://docs.langbot.app/en/insight/features.html)
|
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**더 많은 옵션:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://docs.langbot.app/en/insight/features.html)
|
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Главная</a> |
|
<a href="https://langbot.app">Главная</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Возможности</a> |
|
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Документация</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot — это **платформа с открытым исходным к
|
|||||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
||||||
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
||||||
|
|
||||||
[→ Подробнее обо всех возможностях](https://docs.langbot.app/en/insight/features.html)
|
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Другие варианты:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Ручная установка](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
|
|
||||||
[→ Смотреть все интеграции](https://docs.langbot.app/en/insight/features.html)
|
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官網</a> |
|
<a href="https://langbot.app">官網</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/guide.html">文件</a> |
|
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
||||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">外掛市場</a> |
|
<a href="https://space.langbot.app">外掛市場</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
|||||||
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
||||||
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
||||||
|
|
||||||
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手動部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [寶塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ docker compose up -d
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||||
|
|
||||||
[→ 查看完整整合列表](https://docs.langbot.app/zh/insight/features.html)
|
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Trang chủ</a> |
|
<a href="https://langbot.app">Trang chủ</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Tính năng</a> |
|
<a href="https://link.langbot.app/en/docs/features">Tính năng</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Tài liệu</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
|||||||
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
||||||
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
||||||
|
|
||||||
[→ Tìm hiểu thêm về tất cả tính năng](https://docs.langbot.app/en/insight/features.html)
|
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Thêm tùy chọn:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Thủ công](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 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 | ✅ |
|
||||||
|
|
||||||
[→ Xem tất cả tích hợp](https://docs.langbot.app/en/insight/features.html)
|
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ spec:
|
|||||||
### 参考资源
|
### 参考资源
|
||||||
|
|
||||||
- [LangBot 官方文档](https://docs.langbot.app)
|
- [LangBot 官方文档](https://docs.langbot.app)
|
||||||
- [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
- [Docker 部署文档](https://link.langbot.app/zh/docs/docker)
|
||||||
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -625,5 +625,5 @@ spec:
|
|||||||
### References
|
### References
|
||||||
|
|
||||||
- [LangBot Official Documentation](https://docs.langbot.app)
|
- [LangBot Official Documentation](https://docs.langbot.app)
|
||||||
- [Docker Deployment Guide](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
- [Docker Deployment Guide](https://link.langbot.app/zh/docs/docker)
|
||||||
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.4"
|
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"]
|
||||||
@@ -39,6 +39,7 @@ dependencies = [
|
|||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"slack-sdk>=3.35.0",
|
"slack-sdk>=3.35.0",
|
||||||
|
"alembic>=1.15.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.40",
|
"sqlalchemy[asyncio]>=2.0.40",
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
"telegramify-markdown>=0.5.1",
|
"telegramify-markdown>=0.5.1",
|
||||||
@@ -64,7 +65,7 @@ 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==0.3.5",
|
"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",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
@@ -111,7 +112,7 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.9.4'
|
__version__ = '4.9.6'
|
||||||
|
|||||||
@@ -182,6 +182,88 @@ class DingTalkClient:
|
|||||||
for handler in self._message_handlers[msg_type]:
|
for handler in self._message_handlers[msg_type]:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
||||||
|
async def _parse_quoted_message(self, replied_msg: dict) -> dict:
|
||||||
|
"""Parse the quoted/replied message and extract its content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
replied_msg: The repliedMsg object from DingTalk message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing the quoted message info with keys:
|
||||||
|
- message_id: The original message ID
|
||||||
|
- msg_type: The message type (text, file, picture, audio, etc.)
|
||||||
|
- content: The text content (if any)
|
||||||
|
- file_url: The file download URL (if file type)
|
||||||
|
- file_name: The file name (if file type)
|
||||||
|
- picture: The picture base64 (if picture type)
|
||||||
|
- audio: The audio base64 (if audio type)
|
||||||
|
"""
|
||||||
|
quote_info = {
|
||||||
|
'message_id': replied_msg.get('msgId', ''),
|
||||||
|
'msg_type': replied_msg.get('msgType', ''),
|
||||||
|
'sender_id': replied_msg.get('senderId', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg_type = replied_msg.get('msgType', '')
|
||||||
|
content = replied_msg.get('content', {})
|
||||||
|
|
||||||
|
# Handle content as string (JSON) or dict
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
content = json.loads(content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
content = {}
|
||||||
|
|
||||||
|
if msg_type == 'text':
|
||||||
|
# Text message
|
||||||
|
if isinstance(content, dict):
|
||||||
|
quote_info['content'] = content.get('content', '')
|
||||||
|
else:
|
||||||
|
quote_info['content'] = str(content)
|
||||||
|
|
||||||
|
elif msg_type == 'file':
|
||||||
|
# File message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
file_name = content.get('fileName')
|
||||||
|
if download_code and file_name:
|
||||||
|
try:
|
||||||
|
quote_info['file_url'] = await self.get_file_url(download_code)
|
||||||
|
quote_info['file_name'] = file_name
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to get quoted file URL: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'picture':
|
||||||
|
# Picture message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
try:
|
||||||
|
quote_info['picture'] = await self.download_image(download_code)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to download quoted image: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'audio':
|
||||||
|
# Audio message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
try:
|
||||||
|
quote_info['audio'] = await self.get_audio_url(download_code)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to get quoted audio: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'richText':
|
||||||
|
# Rich text message - extract text content
|
||||||
|
rich_text = content.get('richText', [])
|
||||||
|
texts = []
|
||||||
|
for item in rich_text:
|
||||||
|
if 'text' in item and item['text'] != '\n':
|
||||||
|
texts.append(item['text'])
|
||||||
|
quote_info['content'] = '\n'.join(texts)
|
||||||
|
|
||||||
|
return quote_info
|
||||||
|
|
||||||
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
||||||
try:
|
try:
|
||||||
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
||||||
@@ -193,6 +275,15 @@ class DingTalkClient:
|
|||||||
elif str(incoming_message.conversation_type) == '2':
|
elif str(incoming_message.conversation_type) == '2':
|
||||||
message_data['conversation_type'] = 'GroupMessage'
|
message_data['conversation_type'] = 'GroupMessage'
|
||||||
|
|
||||||
|
# Check for quoted/replied message
|
||||||
|
raw_data = incoming_message.to_dict()
|
||||||
|
text_data = raw_data.get('text', {})
|
||||||
|
if isinstance(text_data, dict) and text_data.get('isReplyMsg'):
|
||||||
|
replied_msg = text_data.get('repliedMsg', {})
|
||||||
|
if replied_msg:
|
||||||
|
quote_info = await self._parse_quoted_message(replied_msg)
|
||||||
|
message_data['QuotedMessage'] = quote_info
|
||||||
|
|
||||||
if incoming_message.message_type == 'richText':
|
if incoming_message.message_type == 'richText':
|
||||||
data = incoming_message.rich_text_content.to_dict()
|
data = incoming_message.rich_text_content.to_dict()
|
||||||
|
|
||||||
@@ -268,7 +359,25 @@ class DingTalkClient:
|
|||||||
|
|
||||||
message_data['Type'] = 'image'
|
message_data['Type'] = 'image'
|
||||||
elif incoming_message.message_type == 'audio':
|
elif incoming_message.message_type == 'audio':
|
||||||
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
|
raw_content = incoming_message.to_dict().get('content', {})
|
||||||
|
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||||||
|
if isinstance(raw_content, str):
|
||||||
|
try:
|
||||||
|
raw_content = json.loads(raw_content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raw_content = {}
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
# 提取钉钉自带的语音转写文字(Powered by Qwen)
|
||||||
|
recognition = raw_content.get('recognition', '')
|
||||||
|
if recognition:
|
||||||
|
message_data['Content'] = recognition
|
||||||
|
|
||||||
|
download_code = raw_content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
message_data['Audio'] = await self.get_audio_url(download_code)
|
||||||
|
|
||||||
message_data['Type'] = 'audio'
|
message_data['Type'] = 'audio'
|
||||||
elif incoming_message.message_type == 'file':
|
elif incoming_message.message_type == 'file':
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class DingTalkEvent(dict):
|
|||||||
def conversation(self):
|
def conversation(self):
|
||||||
return self.get('conversation_type', '')
|
return self.get('conversation_type', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quoted_message(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get the quoted/replied message info if this is a reply message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing:
|
||||||
|
- message_id: The original message ID
|
||||||
|
- msg_type: The message type (text, file, picture, audio, etc.)
|
||||||
|
- content: The text content (if any)
|
||||||
|
- file_url: The file download URL (if file type)
|
||||||
|
- file_name: The file name (if file type)
|
||||||
|
- picture: The picture base64 (if picture type)
|
||||||
|
- audio: The audio base64 (if audio type)
|
||||||
|
"""
|
||||||
|
return self.get('QuotedMessage')
|
||||||
|
|
||||||
def __getattr__(self, key: str) -> Optional[Any]:
|
def __getattr__(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
允许通过属性访问数据中的任意字段。
|
允许通过属性访问数据中的任意字段。
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ class StreamSession:
|
|||||||
# 缓存最近一次片段,处理重试或超时兜底
|
# 缓存最近一次片段,处理重试或超时兜底
|
||||||
last_chunk: Optional[StreamChunk] = None
|
last_chunk: Optional[StreamChunk] = None
|
||||||
|
|
||||||
|
# 反馈 ID,用于接收用户点赞/点踩反馈
|
||||||
|
feedback_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class StreamSessionManager:
|
class StreamSessionManager:
|
||||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||||
@@ -74,6 +77,7 @@ class StreamSessionManager:
|
|||||||
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
||||||
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
||||||
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
||||||
|
self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射
|
||||||
|
|
||||||
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
||||||
if not msg_id:
|
if not msg_id:
|
||||||
@@ -83,6 +87,32 @@ class StreamSessionManager:
|
|||||||
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
||||||
return self._sessions.get(stream_id)
|
return self._sessions.get(stream_id)
|
||||||
|
|
||||||
|
def get_session_by_feedback_id(self, feedback_id: str) -> Optional[StreamSession]:
|
||||||
|
"""根据 feedback_id 查找会话。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feedback_id: 企业微信反馈事件中的反馈 ID。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[StreamSession]: 找到的会话实例,未找到返回 None。
|
||||||
|
"""
|
||||||
|
if not feedback_id:
|
||||||
|
return None
|
||||||
|
stream_id = self._feedback_index.get(feedback_id)
|
||||||
|
if stream_id:
|
||||||
|
return self._sessions.get(stream_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def register_feedback_id(self, stream_id: str, feedback_id: str) -> None:
|
||||||
|
"""注册 feedback_id 与 stream_id 的映射。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream_id: 企业微信流式会话 ID。
|
||||||
|
feedback_id: 反馈 ID。
|
||||||
|
"""
|
||||||
|
if feedback_id and stream_id:
|
||||||
|
self._feedback_index[feedback_id] = stream_id
|
||||||
|
|
||||||
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
||||||
"""根据企业微信回调创建或获取会话。
|
"""根据企业微信回调创建或获取会话。
|
||||||
|
|
||||||
@@ -198,6 +228,9 @@ class StreamSessionManager:
|
|||||||
msg_id = session.msg_id
|
msg_id = session.msg_id
|
||||||
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
||||||
self._msg_index.pop(msg_id, None)
|
self._msg_index.pop(msg_id, None)
|
||||||
|
# Clean up feedback index for expired sessions
|
||||||
|
if session.feedback_id:
|
||||||
|
self._feedback_index.pop(session.feedback_id, None)
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||||
@@ -404,10 +437,10 @@ async def parse_wecom_bot_message(
|
|||||||
}
|
}
|
||||||
if voice_info.get('content'):
|
if voice_info.get('content'):
|
||||||
message_data['content'] = voice_info.get('content')
|
message_data['content'] = voice_info.get('content')
|
||||||
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
# if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
||||||
voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
# voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
if voice_base64:
|
# if voice_base64:
|
||||||
message_data['voice']['base64'] = voice_base64
|
# message_data['voice']['base64'] = voice_base64
|
||||||
elif msg_type == 'video':
|
elif msg_type == 'video':
|
||||||
video_info = msg_json.get('video', {}) or {}
|
video_info = msg_json.get('video', {}) or {}
|
||||||
download_url = video_info.get('url')
|
download_url = video_info.get('url')
|
||||||
@@ -419,10 +452,12 @@ async def parse_wecom_bot_message(
|
|||||||
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
'filename': video_info.get('filename') or video_info.get('name'),
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
}
|
}
|
||||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
# if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
# video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
if video_base64:
|
# if video_base64:
|
||||||
video_data['base64'] = video_base64
|
# video_data['base64'] = video_base64
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
message_data['video'] = video_data
|
message_data['video'] = video_data
|
||||||
elif msg_type == 'file':
|
elif msg_type == 'file':
|
||||||
file_info = msg_json.get('file', {}) or {}
|
file_info = msg_json.get('file', {}) or {}
|
||||||
@@ -436,12 +471,15 @@ async def parse_wecom_bot_message(
|
|||||||
'download_url': download_url,
|
'download_url': download_url,
|
||||||
'extra': file_info,
|
'extra': file_info,
|
||||||
}
|
}
|
||||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
# if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
# file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
||||||
if file_bytes:
|
# if file_bytes:
|
||||||
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
# file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||||
if dl_filename and not file_data.get('filename'):
|
# if dl_filename and not file_data.get('filename'):
|
||||||
file_data['filename'] = dl_filename
|
# file_data['filename'] = dl_filename
|
||||||
|
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
message_data['file'] = file_data
|
message_data['file'] = file_data
|
||||||
elif msg_type == 'link':
|
elif msg_type == 'link':
|
||||||
message_data['link'] = msg_json.get('link', {})
|
message_data['link'] = msg_json.get('link', {})
|
||||||
@@ -557,6 +595,120 @@ async def parse_wecom_bot_message(
|
|||||||
if msg_json.get('aibotid'):
|
if msg_json.get('aibotid'):
|
||||||
message_data['aibotid'] = msg_json.get('aibotid', '')
|
message_data['aibotid'] = msg_json.get('aibotid', '')
|
||||||
|
|
||||||
|
# Handle quote (referenced message) - important for group chat file references
|
||||||
|
quote_info = msg_json.get('quote')
|
||||||
|
if quote_info:
|
||||||
|
quote_data: dict[str, Any] = {}
|
||||||
|
quote_type = quote_info.get('msgtype', '')
|
||||||
|
quote_data['msgtype'] = quote_type
|
||||||
|
|
||||||
|
if quote_type == 'text':
|
||||||
|
quote_data['content'] = quote_info.get('text', {}).get('content', '')
|
||||||
|
elif quote_type == 'image':
|
||||||
|
img_info = quote_info.get('image', {})
|
||||||
|
img_url = img_info.get('url', '')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
|
if base64_data:
|
||||||
|
quote_data['picurl'] = base64_data
|
||||||
|
quote_data['images'] = [base64_data]
|
||||||
|
elif quote_type == 'file':
|
||||||
|
file_info = quote_info.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
|
file_data = {
|
||||||
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
|
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||||
|
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||||
|
'download_url': download_url,
|
||||||
|
'extra': file_info,
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['file'] = file_data
|
||||||
|
elif quote_type == 'voice':
|
||||||
|
voice_info = quote_info.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
item_aeskey = voice_info.get('aeskey', '')
|
||||||
|
voice_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||||
|
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||||
|
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||||
|
}
|
||||||
|
if voice_info.get('content'):
|
||||||
|
quote_data['content'] = voice_info.get('content')
|
||||||
|
# Same as private chat: append aeskey to url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
voice_data['url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['voice'] = voice_data
|
||||||
|
elif quote_type == 'video':
|
||||||
|
video_info = quote_info.get('video', {}) or {}
|
||||||
|
download_url = video_info.get('url')
|
||||||
|
item_aeskey = video_info.get('aeskey', '')
|
||||||
|
video_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||||
|
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||||
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
video_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['video'] = video_data
|
||||||
|
elif quote_type == 'link':
|
||||||
|
quote_data['link'] = quote_info.get('link', {})
|
||||||
|
link = quote_data['link']
|
||||||
|
title = link.get('title', '')
|
||||||
|
desc = link.get('description') or link.get('digest', '')
|
||||||
|
quote_data['content'] = '\n'.join(filter(None, [title, desc]))
|
||||||
|
elif quote_type == 'mixed':
|
||||||
|
# Handle mixed type in quote (text + images + files etc.)
|
||||||
|
items = quote_info.get('mixed', {}).get('msg_item', [])
|
||||||
|
texts = []
|
||||||
|
images = []
|
||||||
|
files = []
|
||||||
|
for item in items:
|
||||||
|
item_type = item.get('msgtype')
|
||||||
|
if item_type == 'text':
|
||||||
|
texts.append(item.get('text', {}).get('content', ''))
|
||||||
|
elif item_type == 'image':
|
||||||
|
img_info = item.get('image', {})
|
||||||
|
img_url = img_info.get('url')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
|
if base64_data:
|
||||||
|
images.append(base64_data)
|
||||||
|
elif item_type == 'file':
|
||||||
|
file_info = item.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
|
file_data = {
|
||||||
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
|
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||||
|
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||||
|
'download_url': download_url,
|
||||||
|
'extra': file_info,
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
files.append(file_data)
|
||||||
|
if texts:
|
||||||
|
quote_data['content'] = ' '.join(texts)
|
||||||
|
if images:
|
||||||
|
quote_data['images'] = images
|
||||||
|
quote_data['picurl'] = images[0]
|
||||||
|
if files:
|
||||||
|
quote_data['files'] = files
|
||||||
|
quote_data['file'] = files[0]
|
||||||
|
|
||||||
|
message_data['quote'] = quote_data
|
||||||
|
|
||||||
return message_data
|
return message_data
|
||||||
|
|
||||||
|
|
||||||
@@ -597,14 +749,27 @@ class WecomBotClient:
|
|||||||
self.stream_sessions = StreamSessionManager(logger=logger)
|
self.stream_sessions = StreamSessionManager(logger=logger)
|
||||||
self.stream_poll_timeout = 0.5
|
self.stream_poll_timeout = 0.5
|
||||||
|
|
||||||
|
self._feedback_callback: Optional[Callable] = None
|
||||||
|
|
||||||
|
def set_feedback_callback(self, callback: Callable) -> None:
|
||||||
|
"""设置反馈回调函数。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: 反馈回调函数,签名: async def callback(feedback_id, feedback_type, feedback_content, inaccurate_reasons, session)
|
||||||
|
"""
|
||||||
|
self._feedback_callback = callback
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
|
def _build_stream_payload(
|
||||||
|
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""按照企业微信协议拼装返回报文。
|
"""按照企业微信协议拼装返回报文。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stream_id: 企业微信会话 ID。
|
stream_id: 企业微信会话 ID。
|
||||||
content: 推送的文本内容。
|
content: 推送的文本内容。
|
||||||
finish: 是否为最终片段。
|
finish: 是否为最终片段。
|
||||||
|
feedback_id: 反馈 ID,用于接收用户点赞/点踩反馈。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: 可直接加密返回的 payload。
|
dict[str, Any]: 可直接加密返回的 payload。
|
||||||
@@ -612,13 +777,16 @@ class WecomBotClient:
|
|||||||
Example:
|
Example:
|
||||||
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
||||||
"""
|
"""
|
||||||
|
stream_payload = {
|
||||||
|
'id': stream_id,
|
||||||
|
'finish': finish,
|
||||||
|
'content': content,
|
||||||
|
}
|
||||||
|
if feedback_id:
|
||||||
|
stream_payload['feedback'] = {'id': feedback_id}
|
||||||
return {
|
return {
|
||||||
'msgtype': 'stream',
|
'msgtype': 'stream',
|
||||||
'stream': {
|
'stream': stream_payload,
|
||||||
'id': stream_id,
|
|
||||||
'finish': finish,
|
|
||||||
'content': content,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
@@ -674,9 +842,14 @@ class WecomBotClient:
|
|||||||
"""
|
"""
|
||||||
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
||||||
|
|
||||||
|
feedback_id = str(uuid.uuid4())
|
||||||
|
session.feedback_id = feedback_id
|
||||||
|
self.stream_sessions.register_feedback_id(session.stream_id, feedback_id)
|
||||||
|
|
||||||
message_data = await self.get_message(msg_json)
|
message_data = await self.get_message(msg_json)
|
||||||
if message_data:
|
if message_data:
|
||||||
message_data['stream_id'] = session.stream_id
|
message_data['stream_id'] = session.stream_id
|
||||||
|
message_data['feedback_id'] = feedback_id
|
||||||
try:
|
try:
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -685,7 +858,7 @@ class WecomBotClient:
|
|||||||
if is_new:
|
if is_new:
|
||||||
asyncio.create_task(self._dispatch_event(event))
|
asyncio.create_task(self._dispatch_event(event))
|
||||||
|
|
||||||
payload = self._build_stream_payload(session.stream_id, '', False)
|
payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
|
||||||
return await self._encrypt_and_reply(payload, nonce)
|
return await self._encrypt_and_reply(payload, nonce)
|
||||||
|
|
||||||
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
@@ -810,11 +983,81 @@ class WecomBotClient:
|
|||||||
|
|
||||||
msg_json = json.loads(decrypted_xml)
|
msg_json = json.loads(decrypted_xml)
|
||||||
|
|
||||||
|
event = msg_json.get('event', {})
|
||||||
|
event_type = event.get('eventtype', '')
|
||||||
|
|
||||||
|
if event_type == 'feedback_event':
|
||||||
|
return await self._handle_feedback_event(msg_json, nonce)
|
||||||
|
|
||||||
if msg_json.get('msgtype') == 'stream':
|
if msg_json.get('msgtype') == 'stream':
|
||||||
return await self._handle_post_followup_response(msg_json, nonce)
|
return await self._handle_post_followup_response(msg_json, nonce)
|
||||||
|
|
||||||
return await self._handle_post_initial_response(msg_json, nonce)
|
return await self._handle_post_initial_response(msg_json, nonce)
|
||||||
|
|
||||||
|
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
|
"""处理企业微信用户反馈事件(点赞/点踩)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_json: 解密后的企业微信反馈事件 JSON。
|
||||||
|
nonce: 企业微信回调参数 nonce。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[Response, int]: Quart Response 及状态码。
|
||||||
|
|
||||||
|
Note:
|
||||||
|
企业微信协议要求:反馈事件目前仅支持回复空包。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
feedback_event = msg_json.get('event', {}).get('feedback_event', {})
|
||||||
|
feedback_id = feedback_event.get('id', '')
|
||||||
|
feedback_type = feedback_event.get('type', 0)
|
||||||
|
feedback_content = feedback_event.get('content', '')
|
||||||
|
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
||||||
|
|
||||||
|
await self.logger.info(
|
||||||
|
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
|
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||||
|
)
|
||||||
|
|
||||||
|
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||||
|
|
||||||
|
if session:
|
||||||
|
await self.logger.info(
|
||||||
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈')
|
||||||
|
|
||||||
|
# Dispatch feedback event regardless of session availability
|
||||||
|
for handler in self._message_handlers.get('feedback', []):
|
||||||
|
try:
|
||||||
|
await handler(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
if self._feedback_callback:
|
||||||
|
try:
|
||||||
|
await self._feedback_callback(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
return await self._encrypt_and_reply({}, nonce)
|
||||||
|
|
||||||
async def get_message(self, msg_json):
|
async def get_message(self, msg_json):
|
||||||
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
||||||
|
|
||||||
@@ -883,6 +1126,15 @@ class WecomBotClient:
|
|||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def on_feedback(self):
|
||||||
|
def decorator(func: Callable):
|
||||||
|
if 'feedback' not in self._message_handlers:
|
||||||
|
self._message_handlers['feedback'] = []
|
||||||
|
self._message_handlers['feedback'].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||||
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||||
if data:
|
if data:
|
||||||
|
|||||||
@@ -133,3 +133,24 @@ class WecomBotEvent(dict):
|
|||||||
AI Bot ID
|
AI Bot ID
|
||||||
"""
|
"""
|
||||||
return self.get('aibotid', '')
|
return self.get('aibotid', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def feedback_id(self) -> str:
|
||||||
|
"""
|
||||||
|
反馈 ID,用于关联用户点赞/点踩反馈
|
||||||
|
"""
|
||||||
|
return self.get('feedback_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_id(self) -> str:
|
||||||
|
"""
|
||||||
|
流式消息 ID
|
||||||
|
"""
|
||||||
|
return self.get('stream_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quote(self):
|
||||||
|
"""
|
||||||
|
引用消息信息(群聊中用户引用其他消息时返回)
|
||||||
|
"""
|
||||||
|
return self.get('quote', {})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any, Callable, Optional
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message
|
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||||
@@ -96,6 +96,12 @@ class WecomBotWsClient:
|
|||||||
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
||||||
# Dedup: skip sending when content hasn't changed
|
# Dedup: skip sending when content hasn't changed
|
||||||
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
||||||
|
# Stream session info for feedback tracking
|
||||||
|
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
|
||||||
|
# Feedback tracking: feedback_id -> session info
|
||||||
|
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
|
||||||
|
# msg_id -> feedback_id (for associating feedback with message)
|
||||||
|
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
|
||||||
|
|
||||||
# ── Public API ──────────────────────────────────────────────────
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -164,12 +170,27 @@ class WecomBotWsClient:
|
|||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def on_feedback(self) -> Callable:
|
||||||
|
"""Decorator to register a feedback event handler.
|
||||||
|
|
||||||
|
Same interface as WecomBotClient.on_feedback for compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable):
|
||||||
|
if 'feedback' not in self._message_handlers:
|
||||||
|
self._message_handlers['feedback'] = []
|
||||||
|
self._message_handlers['feedback'].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
async def reply_stream(
|
async def reply_stream(
|
||||||
self,
|
self,
|
||||||
req_id: str,
|
req_id: str,
|
||||||
stream_id: str,
|
stream_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
finish: bool = False,
|
finish: bool = False,
|
||||||
|
feedback_id: str = '',
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""Send a streaming reply frame.
|
"""Send a streaming reply frame.
|
||||||
|
|
||||||
@@ -178,17 +199,22 @@ class WecomBotWsClient:
|
|||||||
stream_id: The stream ID for this streaming session.
|
stream_id: The stream ID for this streaming session.
|
||||||
content: The content to send (supports Markdown).
|
content: The content to send (supports Markdown).
|
||||||
finish: Whether this is the final chunk.
|
finish: Whether this is the final chunk.
|
||||||
|
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The ACK frame dict, or None on failure.
|
The ACK frame dict, or None on failure.
|
||||||
"""
|
"""
|
||||||
|
stream_payload = {
|
||||||
|
'id': stream_id,
|
||||||
|
'finish': finish,
|
||||||
|
'content': content,
|
||||||
|
}
|
||||||
|
if feedback_id:
|
||||||
|
stream_payload['feedback'] = {'id': feedback_id}
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
'msgtype': 'stream',
|
'msgtype': 'stream',
|
||||||
'stream': {
|
'stream': stream_payload,
|
||||||
'id': stream_id,
|
|
||||||
'finish': finish,
|
|
||||||
'content': content,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return await self._send_reply(req_id, body)
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
@@ -253,11 +279,23 @@ class WecomBotWsClient:
|
|||||||
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
||||||
if not is_final and content == self._stream_last_content.get(msg_id):
|
if not is_final and content == self._stream_last_content.get(msg_id):
|
||||||
return True
|
return True
|
||||||
await self.reply_stream(req_id, stream_id, content, finish=is_final)
|
|
||||||
|
# Generate feedback_id for final chunk
|
||||||
|
feedback_id = ''
|
||||||
|
if is_final:
|
||||||
|
feedback_id = _generate_req_id('feedback')
|
||||||
|
self._msg_feedback_ids[msg_id] = feedback_id
|
||||||
|
# Store session info for feedback tracking
|
||||||
|
session_info = self._stream_sessions.get(msg_id)
|
||||||
|
if session_info:
|
||||||
|
self._feedback_sessions[feedback_id] = session_info
|
||||||
|
|
||||||
|
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
|
||||||
self._stream_last_content[msg_id] = content
|
self._stream_last_content[msg_id] = content
|
||||||
if is_final:
|
if is_final:
|
||||||
self._stream_ids.pop(msg_id, None)
|
self._stream_ids.pop(msg_id, None)
|
||||||
self._stream_last_content.pop(msg_id, None)
|
self._stream_last_content.pop(msg_id, None)
|
||||||
|
self._stream_sessions.pop(msg_id, None)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
||||||
@@ -445,6 +483,15 @@ class WecomBotWsClient:
|
|||||||
msg_id = message_data.get('msgid', '')
|
msg_id = message_data.get('msgid', '')
|
||||||
if msg_id:
|
if msg_id:
|
||||||
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
||||||
|
# Store session info for feedback tracking
|
||||||
|
self._stream_sessions[msg_id] = {
|
||||||
|
'req_id': req_id,
|
||||||
|
'stream_id': stream_id,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'user_id': message_data.get('userid', ''),
|
||||||
|
'chat_id': message_data.get('chatid', ''),
|
||||||
|
'chat_type': message_data.get('type', 'single'),
|
||||||
|
}
|
||||||
message_data['stream_id'] = stream_id
|
message_data['stream_id'] = stream_id
|
||||||
message_data['req_id'] = req_id
|
message_data['req_id'] = req_id
|
||||||
|
|
||||||
@@ -454,7 +501,7 @@ class WecomBotWsClient:
|
|||||||
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _handle_event_callback(self, frame: dict):
|
async def _handle_event_callback(self, frame: dict):
|
||||||
"""Handle an incoming event callback frame (enter_chat, template_card_event, etc.)."""
|
"""Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event)."""
|
||||||
try:
|
try:
|
||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
@@ -479,14 +526,54 @@ class WecomBotWsClient:
|
|||||||
if body.get('chatid'):
|
if body.get('chatid'):
|
||||||
message_data['chatid'] = body.get('chatid', '')
|
message_data['chatid'] = body.get('chatid', '')
|
||||||
|
|
||||||
|
if event_type == 'feedback_event':
|
||||||
|
feedback_event = event_info.get('feedback_event', {})
|
||||||
|
feedback_id = feedback_event.get('id', '')
|
||||||
|
feedback_type = feedback_event.get('type', 0)
|
||||||
|
feedback_content = feedback_event.get('content', '')
|
||||||
|
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
||||||
|
|
||||||
|
await self.logger.info(
|
||||||
|
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
|
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up session by feedback_id
|
||||||
|
session_info = self._feedback_sessions.get(feedback_id)
|
||||||
|
session = None
|
||||||
|
if session_info:
|
||||||
|
session = StreamSession(
|
||||||
|
stream_id=session_info.get('stream_id', ''),
|
||||||
|
msg_id=session_info.get('msg_id', ''),
|
||||||
|
chat_id=session_info.get('chat_id') or None,
|
||||||
|
user_id=session_info.get('user_id') or None,
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
)
|
||||||
|
await self.logger.info(
|
||||||
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
|
||||||
|
|
||||||
|
for handler in self._message_handlers.get('feedback', []):
|
||||||
|
try:
|
||||||
|
await handler(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
|
||||||
# Dispatch to event-specific handlers
|
|
||||||
if event_type in self._message_handlers:
|
if event_type in self._message_handlers:
|
||||||
for handler in self._message_handlers[event_type]:
|
for handler in self._message_handlers[event_type]:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
||||||
# Also dispatch to generic 'event' handlers
|
|
||||||
if 'event' in self._message_handlers:
|
if 'event' in self._message_handlers:
|
||||||
for handler in self._message_handlers['event']:
|
for handler in self._message_handlers['event']:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|||||||
@@ -456,6 +456,31 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
'platform',
|
'platform',
|
||||||
'user_id',
|
'user_id',
|
||||||
]
|
]
|
||||||
|
elif export_type == 'feedback':
|
||||||
|
data = await self.ap.monitoring_service.export_feedback(
|
||||||
|
bot_ids=bot_ids if bot_ids else None,
|
||||||
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
headers = [
|
||||||
|
'id',
|
||||||
|
'timestamp',
|
||||||
|
'feedback_id',
|
||||||
|
'feedback_type',
|
||||||
|
'feedback_content',
|
||||||
|
'inaccurate_reasons',
|
||||||
|
'bot_id',
|
||||||
|
'bot_name',
|
||||||
|
'pipeline_id',
|
||||||
|
'pipeline_name',
|
||||||
|
'session_id',
|
||||||
|
'message_id',
|
||||||
|
'stream_id',
|
||||||
|
'user_id',
|
||||||
|
'platform',
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
||||||
|
|
||||||
@@ -486,3 +511,63 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return response, 200
|
return response, 200
|
||||||
|
|
||||||
|
@self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def get_feedback_stats() -> str:
|
||||||
|
"""Get feedback statistics"""
|
||||||
|
# Parse query parameters
|
||||||
|
bot_ids = quart.request.args.getlist('botId')
|
||||||
|
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||||
|
start_time_str = quart.request.args.get('startTime')
|
||||||
|
end_time_str = quart.request.args.get('endTime')
|
||||||
|
|
||||||
|
# Parse datetime
|
||||||
|
start_time = parse_iso_datetime(start_time_str)
|
||||||
|
end_time = parse_iso_datetime(end_time_str)
|
||||||
|
|
||||||
|
stats = await self.ap.monitoring_service.get_feedback_stats(
|
||||||
|
bot_ids=bot_ids if bot_ids else None,
|
||||||
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data=stats)
|
||||||
|
|
||||||
|
@self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def get_feedback() -> str:
|
||||||
|
"""Get feedback list"""
|
||||||
|
# Parse query parameters
|
||||||
|
bot_ids = quart.request.args.getlist('botId')
|
||||||
|
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||||
|
feedback_type_str = quart.request.args.get('feedbackType')
|
||||||
|
start_time_str = quart.request.args.get('startTime')
|
||||||
|
end_time_str = quart.request.args.get('endTime')
|
||||||
|
limit = int(quart.request.args.get('limit', 100))
|
||||||
|
offset = int(quart.request.args.get('offset', 0))
|
||||||
|
|
||||||
|
# Parse datetime
|
||||||
|
start_time = parse_iso_datetime(start_time_str)
|
||||||
|
end_time = parse_iso_datetime(end_time_str)
|
||||||
|
|
||||||
|
# Parse feedback type
|
||||||
|
feedback_type = int(feedback_type_str) if feedback_type_str else None
|
||||||
|
|
||||||
|
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
|
||||||
|
bot_ids=bot_ids if bot_ids else None,
|
||||||
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'feedback': feedback_list,
|
||||||
|
'total': total,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -265,6 +265,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(400, -1, 'Missing asset_url parameter')
|
return self.http_status(400, -1, 'Missing asset_url parameter')
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
|
||||||
|
ctx.metadata['install_source'] = 'github'
|
||||||
install_info = {
|
install_info = {
|
||||||
'asset_url': asset_url,
|
'asset_url': asset_url,
|
||||||
'owner': owner,
|
'owner': owner,
|
||||||
@@ -295,12 +297,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
|
|
||||||
|
plugin_author = data.get('plugin_author', '')
|
||||||
|
plugin_name = data.get('plugin_name', '')
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
|
ctx.metadata['install_source'] = 'marketplace'
|
||||||
wrapper = self.ap.task_mgr.create_user_task(
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
||||||
kind='plugin-operation',
|
kind='plugin-operation',
|
||||||
name='plugin-install-marketplace',
|
name='plugin-install-marketplace',
|
||||||
label=f'Installing plugin from marketplace ...{data}',
|
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,11 +330,13 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
|
||||||
|
ctx.metadata['install_source'] = 'local'
|
||||||
wrapper = self.ap.task_mgr.create_user_task(
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
||||||
kind='plugin-operation',
|
kind='plugin-operation',
|
||||||
name='plugin-install-local',
|
name='plugin-install-local',
|
||||||
label=f'Installing plugin from local ...{file.filename}',
|
label=f'Installing plugin from local {file.filename}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('tools', '/api/v1/tools')
|
||||||
|
class ToolsRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""获取所有可用工具列表"""
|
||||||
|
tools = await self.ap.tool_mgr.get_all_tools()
|
||||||
|
|
||||||
|
tool_list = []
|
||||||
|
for tool in tools:
|
||||||
|
tool_list.append(
|
||||||
|
{
|
||||||
|
'name': tool.name,
|
||||||
|
'description': tool.description,
|
||||||
|
'human_desc': tool.human_desc,
|
||||||
|
'parameters': tool.parameters,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data={'tools': tool_list})
|
||||||
|
|
||||||
|
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(tool_name: str) -> str:
|
||||||
|
"""获取特定工具详情"""
|
||||||
|
tools = await self.ap.tool_mgr.get_all_tools()
|
||||||
|
|
||||||
|
for tool in tools:
|
||||||
|
if tool.name == tool_name:
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'tool': {
|
||||||
|
'name': tool.name,
|
||||||
|
'description': tool.description,
|
||||||
|
'human_desc': tool.human_desc,
|
||||||
|
'parameters': tool.parameters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.http_status(404, -1, f'Tool not found: {tool_name}')
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import quart
|
import quart
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
from .. import group
|
from .. import group
|
||||||
from .....utils import constants
|
from .....utils import constants
|
||||||
|
from .....entity.persistence.metadata import Metadata
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('system', '/api/v1/system')
|
@group.group_class('system', '/api/v1/system')
|
||||||
@@ -9,6 +13,24 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
|
# Read wizard_status and wizard_progress from metadata table
|
||||||
|
wizard_status = 'none'
|
||||||
|
wizard_progress = None
|
||||||
|
try:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress']))
|
||||||
|
)
|
||||||
|
for row in result:
|
||||||
|
if row.key == 'wizard_status':
|
||||||
|
wizard_status = row.value
|
||||||
|
elif row.key == 'wizard_progress':
|
||||||
|
try:
|
||||||
|
wizard_progress = json.loads(row.value)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
wizard_progress = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
@@ -27,17 +49,83 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
'disable_models_service', False
|
'disable_models_service', False
|
||||||
),
|
),
|
||||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||||
|
'wizard_status': wizard_status,
|
||||||
|
'wizard_progress': wizard_progress,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""Mark wizard status in metadata table and clear progress.
|
||||||
|
|
||||||
|
Accepts JSON body: { "status": "skipped" | "completed" }
|
||||||
|
"""
|
||||||
|
data = await quart.request.get_json(silent=True) or {}
|
||||||
|
status = data.get('status', 'completed')
|
||||||
|
if status not in ('skipped', 'completed'):
|
||||||
|
return self.http_status(400, 400, f'Invalid wizard status: {status}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status')
|
||||||
|
)
|
||||||
|
if result.first():
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(Metadata).values(key='wizard_status', value=status)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear wizard progress when wizard is completed/skipped
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress')
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, 500, f'Failed to update wizard status: {e}')
|
||||||
|
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
@self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""Save wizard progress to metadata table.
|
||||||
|
|
||||||
|
Accepts JSON body with wizard state fields:
|
||||||
|
{ "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null,
|
||||||
|
"bot_saved": bool, "selected_runner": str|null }
|
||||||
|
"""
|
||||||
|
data = await quart.request.get_json(silent=True) or {}
|
||||||
|
progress_json = json.dumps(data, ensure_ascii=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress')
|
||||||
|
)
|
||||||
|
if result.first():
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, 500, f'Failed to save wizard progress: {e}')
|
||||||
|
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
task_type = quart.request.args.get('type')
|
task_type = quart.request.args.get('type')
|
||||||
|
task_kind = quart.request.args.get('kind')
|
||||||
|
|
||||||
if task_type == '':
|
if task_type == '':
|
||||||
task_type = None
|
task_type = None
|
||||||
|
if task_kind == '':
|
||||||
|
task_kind = None
|
||||||
|
|
||||||
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type))
|
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
|
||||||
|
|
||||||
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(task_id: str) -> str:
|
async def _(task_id: str) -> str:
|
||||||
|
|||||||
@@ -105,23 +105,24 @@ class HTTPController:
|
|||||||
):
|
):
|
||||||
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
||||||
path += '.html'
|
path += '.html'
|
||||||
elif path.startswith('home/'):
|
elif not path.startswith('api/'):
|
||||||
# SPA fallback for /home/* sub-routes.
|
# SPA fallback: serve index.html for all non-API, non-static routes
|
||||||
# Entity detail views use query params (e.g. /home/bots?id=uuid),
|
# so that React Router can handle client-side routing (Vite SPA).
|
||||||
# so the pre-rendered list page is served directly via path + '.html'.
|
# For /home/* sub-routes, first try parent .html files (pre-rendered pages).
|
||||||
# This fallback handles any remaining unmatched sub-paths.
|
if path.startswith('home/'):
|
||||||
segments = path.rstrip('/').split('/')
|
segments = path.rstrip('/').split('/')
|
||||||
|
for i in range(len(segments) - 1, 0, -1):
|
||||||
|
parent_path = '/'.join(segments[:i]) + '.html'
|
||||||
|
if os.path.exists(os.path.join(frontend_path, parent_path)):
|
||||||
|
response = await quart.send_from_directory(
|
||||||
|
frontend_path, parent_path, mimetype='text/html'
|
||||||
|
)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||||
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
response.headers['Expires'] = '0'
|
||||||
|
return response
|
||||||
|
|
||||||
# Walk up parent segments looking for matching .html files
|
# Fallback to index.html for SPA client-side routing
|
||||||
for i in range(len(segments) - 1, 0, -1):
|
|
||||||
parent_path = '/'.join(segments[:i]) + '.html'
|
|
||||||
if os.path.exists(os.path.join(frontend_path, parent_path)):
|
|
||||||
response = await quart.send_from_directory(frontend_path, parent_path, mimetype='text/html')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
|
||||||
response.headers['Pragma'] = 'no-cache'
|
|
||||||
response.headers['Expires'] = '0'
|
|
||||||
return response
|
|
||||||
# Final fallback to index.html for /home/* routes
|
|
||||||
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
||||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||||
response.headers['Pragma'] = 'no-cache'
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
|||||||
@@ -1183,3 +1183,314 @@ class MonitoringService:
|
|||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ========== Feedback Methods ==========
|
||||||
|
|
||||||
|
async def record_feedback(
|
||||||
|
self,
|
||||||
|
feedback_id: str,
|
||||||
|
feedback_type: int,
|
||||||
|
feedback_content: str | None = None,
|
||||||
|
inaccurate_reasons: list[str] | None = None,
|
||||||
|
bot_id: str | None = None,
|
||||||
|
bot_name: str | None = None,
|
||||||
|
pipeline_id: str | None = None,
|
||||||
|
pipeline_name: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
message_id: str | None = None,
|
||||||
|
stream_id: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Record user feedback (like/dislike) from AI Bot conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feedback_id: Unique feedback identifier from platform (e.g., WeChat Work)
|
||||||
|
feedback_type: 1 = like (thumbs up), 2 = dislike (thumbs down)
|
||||||
|
feedback_content: Optional user feedback text
|
||||||
|
inaccurate_reasons: List of reasons for inaccurate response (for dislike)
|
||||||
|
bot_id: Bot ID
|
||||||
|
bot_name: Bot name
|
||||||
|
pipeline_id: Pipeline ID
|
||||||
|
pipeline_name: Pipeline name
|
||||||
|
session_id: Session ID
|
||||||
|
message_id: Message ID
|
||||||
|
stream_id: Stream ID (for WeChat Work streaming messages)
|
||||||
|
user_id: User ID
|
||||||
|
platform: Platform name (e.g., 'wecom')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The record ID
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
|
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
|
||||||
|
|
||||||
|
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
|
||||||
|
|
||||||
|
# Handle cancel feedback (type=3): delete existing record
|
||||||
|
if feedback_type == 3:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if record with this feedback_id already exists
|
||||||
|
existing_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
|
||||||
|
)
|
||||||
|
existing_row = existing_result.first()
|
||||||
|
|
||||||
|
if existing_row:
|
||||||
|
# UPDATE existing record
|
||||||
|
existing = existing_row[0] if isinstance(existing_row, tuple) else existing_row
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(MonitoringFeedback)
|
||||||
|
.where(MonitoringFeedback.feedback_id == feedback_id)
|
||||||
|
.values(
|
||||||
|
timestamp=now,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=reasons_json,
|
||||||
|
bot_id=bot_id or existing.bot_id,
|
||||||
|
bot_name=bot_name or existing.bot_name,
|
||||||
|
pipeline_id=pipeline_id or existing.pipeline_id,
|
||||||
|
pipeline_name=pipeline_name or existing.pipeline_name,
|
||||||
|
session_id=session_id or existing.session_id,
|
||||||
|
message_id=message_id or existing.message_id,
|
||||||
|
stream_id=stream_id or existing.stream_id,
|
||||||
|
user_id=user_id or existing.user_id,
|
||||||
|
platform=platform or existing.platform,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return existing.id
|
||||||
|
else:
|
||||||
|
# INSERT new record with IntegrityError defense
|
||||||
|
record_id = str(uuid.uuid4())
|
||||||
|
record_data = {
|
||||||
|
'id': record_id,
|
||||||
|
'timestamp': now,
|
||||||
|
'feedback_id': feedback_id,
|
||||||
|
'feedback_type': feedback_type,
|
||||||
|
'feedback_content': feedback_content,
|
||||||
|
'inaccurate_reasons': reasons_json,
|
||||||
|
'bot_id': bot_id,
|
||||||
|
'bot_name': bot_name,
|
||||||
|
'pipeline_id': pipeline_id,
|
||||||
|
'pipeline_name': pipeline_name,
|
||||||
|
'session_id': session_id,
|
||||||
|
'message_id': message_id,
|
||||||
|
'stream_id': stream_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
'platform': platform,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringFeedback).values(record_data))
|
||||||
|
return record_id
|
||||||
|
except Exception:
|
||||||
|
# UNIQUE constraint conflict (concurrent feedback for same feedback_id)
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(MonitoringFeedback)
|
||||||
|
.where(MonitoringFeedback.feedback_id == feedback_id)
|
||||||
|
.values(
|
||||||
|
timestamp=now,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=reasons_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return feedback_id
|
||||||
|
|
||||||
|
async def get_feedback_stats(
|
||||||
|
self,
|
||||||
|
bot_ids: list[str] | None = None,
|
||||||
|
pipeline_ids: list[str] | None = None,
|
||||||
|
start_time: datetime.datetime | None = None,
|
||||||
|
end_time: datetime.datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Get feedback statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if bot_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||||
|
if pipeline_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||||
|
if start_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||||
|
if end_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||||
|
|
||||||
|
# Get total likes (feedback_type = 1)
|
||||||
|
likes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
|
||||||
|
persistence_monitoring.MonitoringFeedback.feedback_type == 1
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
likes_query = likes_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
likes_result = await self.ap.persistence_mgr.execute_async(likes_query)
|
||||||
|
total_likes = likes_result.scalar() or 0
|
||||||
|
|
||||||
|
# Get total dislikes (feedback_type = 2)
|
||||||
|
dislikes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
|
||||||
|
persistence_monitoring.MonitoringFeedback.feedback_type == 2
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
dislikes_query = dislikes_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
dislikes_result = await self.ap.persistence_mgr.execute_async(dislikes_query)
|
||||||
|
total_dislikes = dislikes_result.scalar() or 0
|
||||||
|
|
||||||
|
# Get total feedback count
|
||||||
|
total_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
|
||||||
|
if conditions:
|
||||||
|
total_query = total_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
total_result = await self.ap.persistence_mgr.execute_async(total_query)
|
||||||
|
total_feedback = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Calculate satisfaction rate
|
||||||
|
satisfaction_rate = (total_likes / total_feedback * 100) if total_feedback > 0 else 0
|
||||||
|
|
||||||
|
# Get feedback by bot
|
||||||
|
bot_stats_query = sqlalchemy.select(
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_id,
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_name,
|
||||||
|
sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id).label('total'),
|
||||||
|
sqlalchemy.func.sum(
|
||||||
|
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 1, 1), else_=0)
|
||||||
|
).label('likes'),
|
||||||
|
sqlalchemy.func.sum(
|
||||||
|
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 2, 1), else_=0)
|
||||||
|
).label('dislikes'),
|
||||||
|
).group_by(
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_id,
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_name,
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
bot_stats_query = bot_stats_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
bot_stats_result = await self.ap.persistence_mgr.execute_async(bot_stats_query)
|
||||||
|
bot_stats = [
|
||||||
|
{
|
||||||
|
'bot_id': row.bot_id,
|
||||||
|
'bot_name': row.bot_name,
|
||||||
|
'total': row.total,
|
||||||
|
'likes': row.likes or 0,
|
||||||
|
'dislikes': row.dislikes or 0,
|
||||||
|
}
|
||||||
|
for row in bot_stats_result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_feedback': total_feedback,
|
||||||
|
'total_likes': total_likes,
|
||||||
|
'total_dislikes': total_dislikes,
|
||||||
|
'satisfaction_rate': round(satisfaction_rate, 2),
|
||||||
|
'by_bot': bot_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_feedback_list(
|
||||||
|
self,
|
||||||
|
bot_ids: list[str] | None = None,
|
||||||
|
pipeline_ids: list[str] | None = None,
|
||||||
|
feedback_type: int | None = None,
|
||||||
|
start_time: datetime.datetime | None = None,
|
||||||
|
end_time: datetime.datetime | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""Get feedback list with filters."""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if bot_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||||
|
if pipeline_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||||
|
if feedback_type is not None:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type)
|
||||||
|
if start_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||||
|
if end_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
|
||||||
|
if conditions:
|
||||||
|
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# Get feedback list
|
||||||
|
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
|
||||||
|
persistence_monitoring.MonitoringFeedback.timestamp.desc()
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
query = query.where(sqlalchemy.and_(*conditions))
|
||||||
|
query = query.limit(limit).offset(offset)
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
self.ap.persistence_mgr.serialize_model(
|
||||||
|
persistence_monitoring.MonitoringFeedback, row[0] if isinstance(row, tuple) else row
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def export_feedback(
|
||||||
|
self,
|
||||||
|
bot_ids: list[str] | None = None,
|
||||||
|
pipeline_ids: list[str] | None = None,
|
||||||
|
start_time: datetime.datetime | None = None,
|
||||||
|
end_time: datetime.datetime | None = None,
|
||||||
|
limit: int = 100000,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Export feedback as list of dictionaries for CSV conversion."""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if bot_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||||
|
if pipeline_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||||
|
if start_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||||
|
if end_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||||
|
|
||||||
|
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
|
||||||
|
persistence_monitoring.MonitoringFeedback.timestamp.desc()
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
query = query.where(sqlalchemy.and_(*conditions))
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': row[0].id if isinstance(row, tuple) else row.id,
|
||||||
|
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
|
||||||
|
'feedback_id': row[0].feedback_id if isinstance(row, tuple) else row.feedback_id,
|
||||||
|
'feedback_type': 'like'
|
||||||
|
if (row[0].feedback_type if isinstance(row, tuple) else row.feedback_type) == 1
|
||||||
|
else 'dislike',
|
||||||
|
'feedback_content': row[0].feedback_content if isinstance(row, tuple) else row.feedback_content,
|
||||||
|
'inaccurate_reasons': row[0].inaccurate_reasons if isinstance(row, tuple) else row.inaccurate_reasons,
|
||||||
|
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
|
||||||
|
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
|
||||||
|
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
|
||||||
|
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
|
||||||
|
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
|
||||||
|
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
|
||||||
|
'stream_id': row[0].stream_id if isinstance(row, tuple) else row.stream_id,
|
||||||
|
'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,
|
||||||
|
'platform': row[0].platform if isinstance(row, tuple) else row.platform,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class UserService:
|
|||||||
|
|
||||||
user_obj = result_list[0]
|
user_obj = result_list[0]
|
||||||
|
|
||||||
# Check if this is a Space account
|
# Check if this user has a local password set
|
||||||
if user_obj.account_type == 'space':
|
if not user_obj.password:
|
||||||
raise ValueError('请使用 Space 账户登录')
|
raise ValueError('请使用 Space 账户登录')
|
||||||
|
|
||||||
ph = argon2.PasswordHasher()
|
ph = argon2.PasswordHasher()
|
||||||
@@ -108,9 +108,8 @@ class UserService:
|
|||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
|
|
||||||
# Space accounts cannot change password locally
|
if not user_obj.password:
|
||||||
if user_obj.account_type == 'space':
|
raise ValueError('No local password set, please set a password first')
|
||||||
raise ValueError('Space account cannot change password locally')
|
|
||||||
|
|
||||||
ph.verify(user_obj.password, current_password)
|
ph.verify(user_obj.password, current_password)
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,12 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
|||||||
if i == len(keys) - 1:
|
if i == len(keys) - 1:
|
||||||
# At the final key
|
# At the final key
|
||||||
if key in current:
|
if key in current:
|
||||||
if isinstance(current[key], (dict, list)):
|
if isinstance(current[key], list):
|
||||||
# Skip dict and list types
|
# Convert comma-separated string to list
|
||||||
|
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
|
||||||
|
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||||
|
elif isinstance(current[key], dict):
|
||||||
|
# Skip dict types
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Valid scalar value - convert and set it
|
# Valid scalar value - convert and set it
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ class TaskContext:
|
|||||||
log: str
|
log: str
|
||||||
"""Log"""
|
"""Log"""
|
||||||
|
|
||||||
|
metadata: dict
|
||||||
|
"""Structured metadata for progress reporting"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.current_action = 'default'
|
self.current_action = 'default'
|
||||||
self.log = ''
|
self.log = ''
|
||||||
|
self.metadata = {}
|
||||||
|
|
||||||
def _log(self, msg: str):
|
def _log(self, msg: str):
|
||||||
self.log += msg + '\n'
|
self.log += msg + '\n'
|
||||||
@@ -38,7 +42,7 @@ class TaskContext:
|
|||||||
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
|
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {'current_action': self.current_action, 'log': self.log}
|
return {'current_action': self.current_action, 'log': self.log, 'metadata': self.metadata}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def new() -> TaskContext:
|
def new() -> TaskContext:
|
||||||
@@ -211,9 +215,14 @@ class AsyncTaskManager:
|
|||||||
def get_tasks_dict(
|
def get_tasks_dict(
|
||||||
self,
|
self,
|
||||||
type: str = None,
|
type: str = None,
|
||||||
|
kind: str = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type],
|
'tasks': [
|
||||||
|
t.to_dict()
|
||||||
|
for t in self.tasks
|
||||||
|
if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
|
||||||
|
],
|
||||||
'id_index': TaskWrapper._id_index,
|
'id_index': TaskWrapper._id_index,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,23 @@ class I18nString(pydantic.BaseModel):
|
|||||||
"""英文"""
|
"""英文"""
|
||||||
|
|
||||||
zh_Hans: typing.Optional[str] = None
|
zh_Hans: typing.Optional[str] = None
|
||||||
"""中文"""
|
"""简体中文"""
|
||||||
|
|
||||||
|
zh_Hant: typing.Optional[str] = None
|
||||||
|
"""繁体中文"""
|
||||||
|
|
||||||
ja_JP: typing.Optional[str] = None
|
ja_JP: typing.Optional[str] = None
|
||||||
"""日文"""
|
"""日文"""
|
||||||
|
|
||||||
|
th_TH: typing.Optional[str] = None
|
||||||
|
"""泰文"""
|
||||||
|
|
||||||
|
vi_VN: typing.Optional[str] = None
|
||||||
|
"""越南文"""
|
||||||
|
|
||||||
|
es_ES: typing.Optional[str] = None
|
||||||
|
"""西班牙文"""
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""转换为字典"""
|
"""转换为字典"""
|
||||||
dic = {}
|
dic = {}
|
||||||
@@ -29,8 +41,16 @@ class I18nString(pydantic.BaseModel):
|
|||||||
dic['en_US'] = self.en_US
|
dic['en_US'] = self.en_US
|
||||||
if self.zh_Hans is not None:
|
if self.zh_Hans is not None:
|
||||||
dic['zh_Hans'] = self.zh_Hans
|
dic['zh_Hans'] = self.zh_Hans
|
||||||
|
if self.zh_Hant is not None:
|
||||||
|
dic['zh_Hant'] = self.zh_Hant
|
||||||
if self.ja_JP is not None:
|
if self.ja_JP is not None:
|
||||||
dic['ja_JP'] = self.ja_JP
|
dic['ja_JP'] = self.ja_JP
|
||||||
|
if self.th_TH is not None:
|
||||||
|
dic['th_TH'] = self.th_TH
|
||||||
|
if self.vi_VN is not None:
|
||||||
|
dic['vi_VN'] = self.vi_VN
|
||||||
|
if self.es_ES is not None:
|
||||||
|
dic['es_ES'] = self.es_ES
|
||||||
return dic
|
return dic
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Bot(Base):
|
|||||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
sqlalchemy.DateTime,
|
sqlalchemy.DateTime,
|
||||||
|
|||||||
@@ -106,3 +106,26 @@ class MonitoringEmbeddingCall(Base):
|
|||||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
|
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringFeedback(Base):
|
||||||
|
"""User feedback records (like/dislike) from AI Bot conversations"""
|
||||||
|
|
||||||
|
__tablename__ = 'monitoring_feedback'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||||
|
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||||
|
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||||
|
feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike
|
||||||
|
feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text
|
||||||
|
inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons
|
||||||
|
# Context fields
|
||||||
|
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Alembic environment for LangBot.
|
||||||
|
|
||||||
|
This env.py is designed to be called programmatically (not via CLI).
|
||||||
|
It supports both SQLite and PostgreSQL.
|
||||||
|
|
||||||
|
The sync connection is passed via config attributes by the runner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode — emit SQL without a live connection."""
|
||||||
|
url = context.config.get_main_option('sqlalchemy.url')
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={'paramstyle': 'named'},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations with a live sync connection passed via config attributes."""
|
||||||
|
connection: Connection = context.config.attributes.get('connection')
|
||||||
|
if connection is None:
|
||||||
|
raise RuntimeError('connection not provided in alembic config attributes')
|
||||||
|
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
# render_as_batch=True is critical for SQLite ALTER TABLE support
|
||||||
|
render_as_batch=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Alembic script.py.mako — template for auto-generated revisions
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""baseline: stamp existing schema (db version 25)
|
||||||
|
|
||||||
|
This is a no-op migration that marks the starting point for Alembic.
|
||||||
|
All tables already exist via create_all() + legacy DBMigration system.
|
||||||
|
|
||||||
|
Revision ID: 0001_baseline
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-04-08
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = '0001_baseline'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# No-op: existing schema is already at database_version=25
|
||||||
|
# This revision serves as the Alembic baseline.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""example: sample migration demonstrating Alembic patterns
|
||||||
|
|
||||||
|
This is a SAMPLE showing how to write migrations that work
|
||||||
|
seamlessly across SQLite and PostgreSQL. Delete or adapt as needed.
|
||||||
|
|
||||||
|
Revision ID: 0002_sample
|
||||||
|
Revises: 0001_baseline
|
||||||
|
Create Date: 2026-04-08
|
||||||
|
|
||||||
|
Patterns demonstrated:
|
||||||
|
1. Schema change (add column) — works on both DBs via render_as_batch
|
||||||
|
2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = '0002_sample'
|
||||||
|
down_revision = '0001_baseline'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""
|
||||||
|
EXAMPLE: Uncomment to use. This shows the patterns.
|
||||||
|
|
||||||
|
# --- Pattern 1: Schema change (add/drop column) ---
|
||||||
|
# render_as_batch=True in env.py makes this work on SQLite too.
|
||||||
|
#
|
||||||
|
# op.add_column('pipelines', sa.Column('description', sa.String(512), server_default=''))
|
||||||
|
|
||||||
|
# --- Pattern 2: Data migration (read + modify JSON field) ---
|
||||||
|
# No if/else for sqlite vs postgres needed!
|
||||||
|
#
|
||||||
|
# conn = op.get_bind()
|
||||||
|
# rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall()
|
||||||
|
# for row in rows:
|
||||||
|
# config = json.loads(row[1]) if isinstance(row[1], str) else row[1]
|
||||||
|
# # Modify the config
|
||||||
|
# config.setdefault('ai', {}).setdefault('some_new_key', 'default_value')
|
||||||
|
# conn.execute(
|
||||||
|
# sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"),
|
||||||
|
# {"cfg": json.dumps(config), "uuid": row[0]}
|
||||||
|
# )
|
||||||
|
|
||||||
|
# --- Pattern 3: Create a new table ---
|
||||||
|
#
|
||||||
|
# op.create_table(
|
||||||
|
# 'audit_log',
|
||||||
|
# sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
# sa.Column('action', sa.String(255), nullable=False),
|
||||||
|
# sa.Column('detail', sa.Text),
|
||||||
|
# sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
|
||||||
|
# )
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""
|
||||||
|
# op.drop_column('pipelines', 'description')
|
||||||
|
# op.drop_table('audit_log')
|
||||||
|
"""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Programmatic Alembic runner for LangBot.
|
||||||
|
|
||||||
|
Usage from async code:
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade
|
||||||
|
await run_alembic_upgrade(async_engine)
|
||||||
|
|
||||||
|
CLI usage (autogenerate):
|
||||||
|
python -m langbot.pkg.persistence.alembic_runner autogenerate "add description column"
|
||||||
|
python -m langbot.pkg.persistence.alembic_runner upgrade
|
||||||
|
python -m langbot.pkg.persistence.alembic_runner current
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic import command
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
|
||||||
|
_ALEMBIC_DIR = os.path.join(os.path.dirname(__file__), 'alembic')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_config(connection: Connection) -> Config:
|
||||||
|
"""Build an Alembic Config with sync connection attached."""
|
||||||
|
cfg = Config()
|
||||||
|
cfg.set_main_option('script_location', _ALEMBIC_DIR)
|
||||||
|
cfg.attributes['connection'] = connection
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _do_upgrade(connection: Connection, revision: str = 'head') -> None:
|
||||||
|
"""Synchronous upgrade — runs inside run_sync."""
|
||||||
|
cfg = _build_config(connection)
|
||||||
|
command.upgrade(cfg, revision)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_stamp(connection: Connection, revision: str = 'head') -> None:
|
||||||
|
"""Synchronous stamp — runs inside run_sync."""
|
||||||
|
cfg = _build_config(connection)
|
||||||
|
command.stamp(cfg, revision)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_get_current(connection: Connection) -> str | None:
|
||||||
|
"""Get current alembic revision synchronously."""
|
||||||
|
ctx = MigrationContext.configure(connection)
|
||||||
|
return ctx.get_current_revision()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
|
||||||
|
"""Synchronous autogenerate — runs inside run_sync."""
|
||||||
|
cfg = _build_config(connection)
|
||||||
|
command.revision(cfg, message=message, autogenerate=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_alembic_upgrade(async_engine: AsyncEngine, revision: str = 'head') -> None:
|
||||||
|
"""Run Alembic upgrade to the given revision."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
await conn.run_sync(_do_upgrade, revision)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -> None:
|
||||||
|
"""Stamp the database with a revision without running migrations."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
await conn.run_sync(_do_stamp, revision)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
|
||||||
|
"""Get current alembic revision, or None if not stamped."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
return await conn.run_sync(_do_get_current)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_alembic_autogenerate(async_engine: AsyncEngine, message: str = 'auto migration') -> None:
|
||||||
|
"""Compare ORM models against DB schema and generate a migration script."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
await conn.run_sync(_do_autogenerate, message)
|
||||||
|
|
||||||
|
|
||||||
|
# CLI entrypoint: python -m langbot.pkg.persistence.alembic_runner <command> [args]
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
def _get_engine():
|
||||||
|
"""Create engine from data/config.yaml or default SQLite."""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
with open('data/config.yaml') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
db_cfg = config.get('database', {})
|
||||||
|
db_type = db_cfg.get('use', 'sqlite')
|
||||||
|
if db_type == 'postgresql':
|
||||||
|
pg = db_cfg.get('postgresql', {})
|
||||||
|
url = (
|
||||||
|
f'postgresql+asyncpg://{pg.get("user", "postgres")}:{pg.get("password", "postgres")}'
|
||||||
|
f'@{pg.get("host", "127.0.0.1")}:{pg.get("port", 5432)}/{pg.get("database", "postgres")}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
path = db_cfg.get('sqlite', {}).get('path', 'data/langbot.db')
|
||||||
|
url = f'sqlite+aiosqlite:///{path}'
|
||||||
|
except Exception:
|
||||||
|
url = 'sqlite+aiosqlite:///data/langbot.db'
|
||||||
|
|
||||||
|
return create_async_engine(url)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print('Usage: python -m langbot.pkg.persistence.alembic_runner <command> [args]')
|
||||||
|
print('Commands:')
|
||||||
|
print(' autogenerate "message" — Generate migration from ORM model diff')
|
||||||
|
print(' upgrade [revision] — Upgrade database (default: head)')
|
||||||
|
print(' stamp [revision] — Stamp revision without running (default: head)')
|
||||||
|
print(' current — Show current revision')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
engine = _get_engine()
|
||||||
|
|
||||||
|
if cmd == 'autogenerate':
|
||||||
|
msg = sys.argv[2] if len(sys.argv) > 2 else 'auto migration'
|
||||||
|
asyncio.run(run_alembic_autogenerate(engine, msg))
|
||||||
|
print(f'Migration generated: {msg}')
|
||||||
|
elif cmd == 'upgrade':
|
||||||
|
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
|
||||||
|
asyncio.run(run_alembic_upgrade(engine, rev))
|
||||||
|
print(f'Upgraded to: {rev}')
|
||||||
|
elif cmd == 'stamp':
|
||||||
|
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
|
||||||
|
asyncio.run(run_alembic_stamp(engine, rev))
|
||||||
|
print(f'Stamped: {rev}')
|
||||||
|
elif cmd == 'current':
|
||||||
|
rev = asyncio.run(get_alembic_current(engine))
|
||||||
|
print(f'Current revision: {rev}')
|
||||||
|
else:
|
||||||
|
print(f'Unknown command: {cmd}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -76,6 +76,9 @@ class PersistenceManager:
|
|||||||
|
|
||||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||||
|
|
||||||
|
# Run Alembic migrations (new migration system)
|
||||||
|
await self._run_alembic_migrations()
|
||||||
|
|
||||||
await self.write_space_model_providers()
|
await self.write_space_model_providers()
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
@@ -135,6 +138,28 @@ class PersistenceManager:
|
|||||||
|
|
||||||
# =================================
|
# =================================
|
||||||
|
|
||||||
|
async def _run_alembic_migrations(self):
|
||||||
|
"""Run Alembic-based migrations after legacy migrations complete."""
|
||||||
|
from . import alembic_runner
|
||||||
|
|
||||||
|
engine = self.get_db_engine()
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_rev = await alembic_runner.get_alembic_current(engine)
|
||||||
|
|
||||||
|
if current_rev is None:
|
||||||
|
# First time: stamp baseline so Alembic knows existing schema is up-to-date
|
||||||
|
self.ap.logger.info('Alembic: no revision found, stamping baseline...')
|
||||||
|
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
|
||||||
|
current_rev = '0001_baseline'
|
||||||
|
|
||||||
|
# Upgrade to head
|
||||||
|
await alembic_runner.run_alembic_upgrade(engine, 'head')
|
||||||
|
self.ap.logger.info('Alembic migrations completed.')
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
||||||
async with self.get_db_engine().connect() as conn:
|
async with self.get_db_engine().connect() as conn:
|
||||||
result = await conn.execute(*args, **kwargs)
|
result = await conn.execute(*args, **kwargs)
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import sqlalchemy
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(25)
|
||||||
|
class DBMigrateBotPipelineRoutingRules(migration.DBMigration):
|
||||||
|
"""Add pipeline_routing_rules column to bots table"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
sql_text = sqlalchemy.text("ALTER TABLE bots ADD COLUMN pipeline_routing_rules JSON NOT NULL DEFAULT '[]'")
|
||||||
|
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
sql_text = sqlalchemy.text('ALTER TABLE bots DROP COLUMN pipeline_routing_rules')
|
||||||
|
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||||
@@ -37,6 +37,7 @@ class PendingMessage:
|
|||||||
message_chain: platform_message.MessageChain
|
message_chain: platform_message.MessageChain
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||||
pipeline_uuid: typing.Optional[str]
|
pipeline_uuid: typing.Optional[str]
|
||||||
|
routed_by_rule: bool = False
|
||||||
timestamp: float = field(default_factory=time.time)
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ class MessageAggregator:
|
|||||||
message_chain: platform_message.MessageChain,
|
message_chain: platform_message.MessageChain,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
|
routed_by_rule: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a message to the aggregation buffer
|
"""Add a message to the aggregation buffer
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ class MessageAggregator:
|
|||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ class MessageAggregator:
|
|||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
force_flush = False
|
force_flush = False
|
||||||
@@ -217,6 +221,7 @@ class MessageAggregator:
|
|||||||
message_chain=msg.message_chain,
|
message_chain=msg.message_chain,
|
||||||
adapter=msg.adapter,
|
adapter=msg.adapter,
|
||||||
pipeline_uuid=msg.pipeline_uuid,
|
pipeline_uuid=msg.pipeline_uuid,
|
||||||
|
routed_by_rule=msg.routed_by_rule,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -231,6 +236,7 @@ class MessageAggregator:
|
|||||||
message_chain=merged_msg.message_chain,
|
message_chain=merged_msg.message_chain,
|
||||||
adapter=merged_msg.adapter,
|
adapter=merged_msg.adapter,
|
||||||
pipeline_uuid=merged_msg.pipeline_uuid,
|
pipeline_uuid=merged_msg.pipeline_uuid,
|
||||||
|
routed_by_rule=merged_msg.routed_by_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ class Controller:
|
|||||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||||
if pipeline:
|
if pipeline:
|
||||||
await pipeline.run(selected_query)
|
await pipeline.run(selected_query)
|
||||||
|
else:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'No pipeline_uuid for query {selected_query.query_id}, query dropped'
|
||||||
|
)
|
||||||
|
|
||||||
async with self.ap.query_pool:
|
async with self.ap.query_pool:
|
||||||
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
||||||
|
|||||||
@@ -323,6 +323,9 @@ class RuntimePipeline:
|
|||||||
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
||||||
|
|
||||||
if event_ctx.is_prevented_default():
|
if event_ctx.is_prevented_default():
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.ap.logger.debug(f'Processing query {query.query_id}')
|
self.ap.logger.debug(f'Processing query {query.query_id}')
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class QueryPool:
|
|||||||
message_chain: platform_message.MessageChain,
|
message_chain: platform_message.MessageChain,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
|
routed_by_rule: bool = False,
|
||||||
) -> pipeline_query.Query:
|
) -> pipeline_query.Query:
|
||||||
async with self.condition:
|
async with self.condition:
|
||||||
query_id = self.query_id_counter
|
query_id = self.query_id_counter
|
||||||
@@ -52,7 +53,7 @@ class QueryPool:
|
|||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
message_event=message_event,
|
message_event=message_event,
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
variables={},
|
variables={'_routed_by_rule': routed_by_rule},
|
||||||
resp_messages=[],
|
resp_messages=[],
|
||||||
resp_message_chain=[],
|
resp_message_chain=[],
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ 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.url is not None:
|
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
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:
|
||||||
@@ -172,6 +171,15 @@ 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):
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
|
||||||
|
elif isinstance(msg, platform_message.Voice):
|
||||||
|
if msg.base64:
|
||||||
|
content_list.append(
|
||||||
|
provider_message.ContentElement.from_file_base64(msg.base64, 'voice.silk')
|
||||||
|
)
|
||||||
|
elif msg.url:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(msg.url, 'voice'))
|
||||||
|
|
||||||
query.variables['user_message_text'] = plain_text
|
query.variables['user_message_text'] = plain_text
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
|
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
else:
|
else:
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'NormalMessageReceived event prevented default for query {query.query_id} without reply'
|
||||||
|
)
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
||||||
else:
|
else:
|
||||||
if event_ctx.event.user_message_alter is not None:
|
if event_ctx.event.user_message_alter is not None:
|
||||||
@@ -205,6 +208,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
|
'edition': constants.edition,
|
||||||
'pipeline_plugins': pipeline_plugins,
|
'pipeline_plugins': pipeline_plugins,
|
||||||
'error': locals().get('error_info', None),
|
'error': locals().get('error_info', None),
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ class GroupRespondRuleCheckStage(stage.PipelineStage):
|
|||||||
if query.launcher_type.value != 'group': # 只处理群消息
|
if query.launcher_type.value != 'group': # 只处理群消息
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
|
# 通过路由规则明确指定的流水线,跳过群响应规则检查
|
||||||
|
if query.variables and query.variables.get('_routed_by_rule', False):
|
||||||
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
rules = query.pipeline_config['trigger']['group-respond-rules']
|
rules = query.pipeline_config['trigger']['group-respond-rules']
|
||||||
|
|
||||||
use_rule = rules
|
use_rule = rules
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ from ..core import app, entities as core_entities, taskmgr
|
|||||||
from ..discover import engine
|
from ..discover import engine
|
||||||
|
|
||||||
from ..entity.persistence import bot as persistence_bot
|
from ..entity.persistence import bot as persistence_bot
|
||||||
|
from ..entity.persistence import pipeline as persistence_pipeline
|
||||||
|
|
||||||
from ..entity.errors import platform as platform_errors
|
from ..entity.errors import platform as platform_errors
|
||||||
|
|
||||||
@@ -51,6 +54,148 @@ 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_DISPLAY_NAME = 'Discarded'
|
||||||
|
|
||||||
|
def resolve_pipeline_uuid(
|
||||||
|
self,
|
||||||
|
launcher_type: str,
|
||||||
|
launcher_id: str,
|
||||||
|
message_text: str,
|
||||||
|
message_element_types: list[str] | None = None,
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
"""Resolve pipeline UUID based on routing rules.
|
||||||
|
|
||||||
|
Rules are evaluated in order; first match wins.
|
||||||
|
Falls back to use_pipeline_uuid if no rule matches.
|
||||||
|
|
||||||
|
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:
|
||||||
|
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
|
||||||
|
when a routing rule matched, False when falling back to default.
|
||||||
|
"""
|
||||||
|
rules = self.bot_entity.pipeline_routing_rules or []
|
||||||
|
element_type_set = set(message_element_types or [])
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
rule_type = rule.get('type')
|
||||||
|
operator = rule.get('operator', 'eq')
|
||||||
|
rule_value = rule.get('value', '')
|
||||||
|
target_uuid = rule.get('pipeline_uuid')
|
||||||
|
if not rule_type or not target_uuid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
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(
|
||||||
|
self,
|
||||||
|
launcher_type: provider_session.LauncherTypes,
|
||||||
|
launcher_id: str | int,
|
||||||
|
sender_id: str | int,
|
||||||
|
message_event: platform_events.MessageEvent,
|
||||||
|
message_chain: platform_message.MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""Record a discarded message in the monitoring system."""
|
||||||
|
try:
|
||||||
|
if hasattr(message_chain, 'model_dump'):
|
||||||
|
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
message_content = str(message_chain)
|
||||||
|
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(message_event, 'sender'):
|
||||||
|
if hasattr(message_event.sender, 'nickname'):
|
||||||
|
sender_name = message_event.sender.nickname
|
||||||
|
elif hasattr(message_event.sender, 'member_name'):
|
||||||
|
sender_name = message_event.sender.member_name
|
||||||
|
|
||||||
|
# Use the same session_id format as monitoring_helper.py
|
||||||
|
session_id = f'{launcher_type}_{launcher_id}'
|
||||||
|
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_message(
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||||
|
pipeline_id=self.PIPELINE_DISCARD,
|
||||||
|
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||||
|
message_content=message_content,
|
||||||
|
session_id=session_id,
|
||||||
|
status='discarded',
|
||||||
|
level='info',
|
||||||
|
platform=platform,
|
||||||
|
user_id=str(sender_id),
|
||||||
|
user_name=sender_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the session exists so the message appears in the session monitor.
|
||||||
|
# Don't overwrite pipeline info — a session may have messages from
|
||||||
|
# multiple pipelines; discarding shouldn't change the displayed pipeline.
|
||||||
|
session_updated = await self.ap.monitoring_service.update_session_activity(
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if not session_updated:
|
||||||
|
# No session yet (first message for this launcher was discarded).
|
||||||
|
await self.ap.monitoring_service.record_session_start(
|
||||||
|
session_id=session_id,
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||||
|
pipeline_id=self.PIPELINE_DISCARD,
|
||||||
|
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||||
|
platform=platform,
|
||||||
|
user_id=str(sender_id),
|
||||||
|
user_name=sender_name,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'Failed to record discarded message: {e}')
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
async def on_friend_message(
|
async def on_friend_message(
|
||||||
event: platform_events.FriendMessage,
|
event: platform_events.FriendMessage,
|
||||||
@@ -82,6 +227,23 @@ class RuntimeBot:
|
|||||||
if custom_launcher_id:
|
if custom_launcher_id:
|
||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
|
message_text = str(event.message_chain)
|
||||||
|
element_types = [comp.type for comp in event.message_chain]
|
||||||
|
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||||
|
'person', launcher_id, message_text, element_types
|
||||||
|
)
|
||||||
|
|
||||||
|
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||||
|
await self.logger.info('Person message discarded by routing rule')
|
||||||
|
await self._record_discarded_message(
|
||||||
|
provider_session.LauncherTypes.PERSON,
|
||||||
|
launcher_id,
|
||||||
|
event.sender.id,
|
||||||
|
event,
|
||||||
|
event.message_chain,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.msg_aggregator.add_message(
|
await self.ap.msg_aggregator.add_message(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||||
@@ -90,7 +252,8 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
||||||
@@ -125,6 +288,23 @@ class RuntimeBot:
|
|||||||
if custom_launcher_id:
|
if custom_launcher_id:
|
||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
|
message_text = str(event.message_chain)
|
||||||
|
element_types = [comp.type for comp in event.message_chain]
|
||||||
|
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||||
|
'group', launcher_id, message_text, element_types
|
||||||
|
)
|
||||||
|
|
||||||
|
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||||
|
await self.logger.info('Group message discarded by routing rule')
|
||||||
|
await self._record_discarded_message(
|
||||||
|
provider_session.LauncherTypes.GROUP,
|
||||||
|
launcher_id,
|
||||||
|
event.sender.id,
|
||||||
|
event,
|
||||||
|
event.message_chain,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.msg_aggregator.add_message(
|
await self.ap.msg_aggregator.add_message(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||||
@@ -133,7 +313,8 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
||||||
@@ -141,6 +322,50 @@ class RuntimeBot:
|
|||||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||||
|
|
||||||
|
# Register feedback listener (only effective on adapters that support it)
|
||||||
|
async def on_feedback(
|
||||||
|
event: platform_events.FeedbackEvent,
|
||||||
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
# Resolve pipeline name
|
||||||
|
pipeline_name = ''
|
||||||
|
if self.bot_entity.use_pipeline_uuid:
|
||||||
|
try:
|
||||||
|
pipeline_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
|
||||||
|
persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pipeline_row = pipeline_result.first()
|
||||||
|
if pipeline_row:
|
||||||
|
pipeline_name = pipeline_row[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_feedback(
|
||||||
|
feedback_id=event.feedback_id,
|
||||||
|
feedback_type=event.feedback_type,
|
||||||
|
feedback_content=event.feedback_content,
|
||||||
|
inaccurate_reasons=event.inaccurate_reasons,
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name,
|
||||||
|
pipeline_id=self.bot_entity.use_pipeline_uuid or '',
|
||||||
|
pipeline_name=pipeline_name,
|
||||||
|
session_id=event.session_id,
|
||||||
|
message_id=event.message_id,
|
||||||
|
stream_id=event.stream_id,
|
||||||
|
user_id=event.user_id,
|
||||||
|
platform=adapter.__class__.__name__,
|
||||||
|
)
|
||||||
|
await self.logger.info(
|
||||||
|
f'Recorded feedback: feedback_id={event.feedback_id}, type={event.feedback_type}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
self.adapter.register_listener(platform_events.FeedbackEvent, on_feedback)
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
async def exception_wrapper():
|
async def exception_wrapper():
|
||||||
try:
|
try:
|
||||||
@@ -196,12 +421,20 @@ class PlatformManager:
|
|||||||
# delete all bot log images
|
# delete all bot log images
|
||||||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
||||||
|
|
||||||
|
disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []
|
||||||
|
|
||||||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||||||
for component in self.adapter_components:
|
for component in self.adapter_components:
|
||||||
|
if component.metadata.name in disabled_adapters:
|
||||||
|
continue
|
||||||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||||
self.adapter_dict = adapter_dict
|
self.adapter_dict = adapter_dict
|
||||||
|
|
||||||
|
# Filter out disabled adapters from components list (for API responses)
|
||||||
|
if disabled_adapters:
|
||||||
|
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
|
||||||
|
|
||||||
# initialize websocket adapter
|
# initialize websocket adapter
|
||||||
websocket_adapter_class = self.adapter_dict['websocket']
|
websocket_adapter_class = self.adapter_dict['websocket']
|
||||||
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
||||||
|
|||||||
@@ -5,19 +5,29 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: OneBot v11
|
en_US: OneBot v11
|
||||||
zh_Hans: OneBot v11
|
zh_Hans: OneBot v11
|
||||||
|
zh_Hant: OneBot v11
|
||||||
description:
|
description:
|
||||||
en_US: OneBot v11 Adapter
|
en_US: OneBot v11 Adapter, used for QQ bots
|
||||||
zh_Hans: OneBot v11 适配器,请查看文档了解使用方式
|
zh_Hans: OneBot v11 适配器,用于接入 QQ 机器人协议端,请查看文档了解使用方式
|
||||||
|
zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式
|
||||||
icon: onebot.png
|
icon: onebot.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- protocol
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
||||||
|
en: https://link.langbot.app/en/platforms/aiocqhttp
|
||||||
|
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
||||||
config:
|
config:
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
en_US: Host
|
en_US: Host
|
||||||
zh_Hans: 主机
|
zh_Hans: 主机
|
||||||
|
zh_Hant: 主機
|
||||||
description:
|
description:
|
||||||
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
|
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
|
||||||
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||||
|
zh_Hant: OneBot v11 監聽的反向 WS 主機,除非你知道自己在做什麼,否則請填 0.0.0.0
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: 0.0.0.0
|
default: 0.0.0.0
|
||||||
@@ -25,9 +35,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Port
|
en_US: Port
|
||||||
zh_Hans: 端口
|
zh_Hans: 端口
|
||||||
|
zh_Hant: 連接埠
|
||||||
description:
|
description:
|
||||||
en_US: Port
|
en_US: Port
|
||||||
zh_Hans: 监听的端口
|
zh_Hans: 监听的端口
|
||||||
|
zh_Hant: 監聽的連接埠
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 2280
|
default: 2280
|
||||||
@@ -35,9 +47,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Access Token
|
en_US: Access Token
|
||||||
zh_Hans: 访问令牌
|
zh_Hans: 访问令牌
|
||||||
|
zh_Hant: 存取令牌
|
||||||
description:
|
description:
|
||||||
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
|
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
|
||||||
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
|
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
|
||||||
|
zh_Hant: 自訂的與協定端的連線令牌,若協定端未設定,則不填
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
||||||
else:
|
else:
|
||||||
# 回退到原有简单逻辑
|
# 回退到原有简单逻辑
|
||||||
if event.content:
|
# 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理
|
||||||
|
if event.content and event.type != 'audio':
|
||||||
text_content = event.content.replace('@' + bot_name, '')
|
text_content = event.content.replace('@' + bot_name, '')
|
||||||
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
||||||
if event.picture:
|
if event.picture:
|
||||||
@@ -81,7 +82,38 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if event.file:
|
if event.file:
|
||||||
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
||||||
if event.audio:
|
if event.audio:
|
||||||
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
# 优先使用钉钉自带的语音转写文字(recognition字段)
|
||||||
|
if event.content and event.type == 'audio':
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||||||
|
else:
|
||||||
|
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
||||||
|
|
||||||
|
# Handle quoted/replied message - extract content as top-level components
|
||||||
|
# so that plugins like FileReader can process them the same way as direct messages
|
||||||
|
if event.quoted_message:
|
||||||
|
quote_info = event.quoted_message
|
||||||
|
msg_type = quote_info.get('msg_type', '')
|
||||||
|
|
||||||
|
# Process quoted file - add as top-level File component (same as private chat)
|
||||||
|
if msg_type == 'file' and quote_info.get('file_url'):
|
||||||
|
file_name = quote_info.get('file_name', 'file')
|
||||||
|
yiri_msg_list.append(platform_message.File(url=quote_info['file_url'], name=file_name))
|
||||||
|
|
||||||
|
# Process quoted image - add as top-level Image component
|
||||||
|
elif msg_type == 'picture' and quote_info.get('picture'):
|
||||||
|
yiri_msg_list.append(platform_message.Image(base64=quote_info['picture']))
|
||||||
|
|
||||||
|
# Process quoted audio - add as top-level Voice component
|
||||||
|
elif msg_type == 'audio' and quote_info.get('audio'):
|
||||||
|
yiri_msg_list.append(platform_message.Voice(base64=quote_info['audio']))
|
||||||
|
|
||||||
|
# Process quoted text - add as Plain text with context prefix
|
||||||
|
elif msg_type == 'text' and quote_info.get('content'):
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
|
||||||
|
|
||||||
|
# Process quoted rich text - add as Plain text with context prefix
|
||||||
|
elif msg_type == 'richText' and quote_info.get('content'):
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
|
||||||
|
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,25 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: DingTalk
|
en_US: DingTalk
|
||||||
zh_Hans: 钉钉
|
zh_Hans: 钉钉
|
||||||
|
zh_Hant: 釘釘
|
||||||
description:
|
description:
|
||||||
en_US: DingTalk Adapter
|
en_US: DingTalk Adapter
|
||||||
zh_Hans: 钉钉适配器,请查看文档了解使用方式
|
zh_Hans: 钉钉适配器,请查看文档了解使用方式
|
||||||
|
zh_Hant: 釘釘適配器,請查看文件了解使用方式
|
||||||
icon: dingtalk.svg
|
icon: dingtalk.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/dingtalk
|
||||||
|
en: https://link.langbot.app/en/platforms/dingtalk
|
||||||
|
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||||
config:
|
config:
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
zh_Hans: 客户端ID
|
zh_Hans: 客户端ID
|
||||||
|
zh_Hant: 用戶端ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +31,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Client Secret
|
en_US: Client Secret
|
||||||
zh_Hans: 客户端密钥
|
zh_Hans: 客户端密钥
|
||||||
|
zh_Hant: 用戶端密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +39,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Robot Code
|
en_US: Robot Code
|
||||||
zh_Hans: 机器人代码
|
zh_Hans: 机器人代码
|
||||||
|
zh_Hant: 機器人代碼
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +47,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Robot Name
|
en_US: Robot Name
|
||||||
zh_Hans: 机器人名称
|
zh_Hans: 机器人名称
|
||||||
|
zh_Hant: 機器人名稱
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,6 +55,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Markdown Card
|
en_US: Markdown Card
|
||||||
zh_Hans: 是否使用 Markdown 卡片
|
zh_Hans: 是否使用 Markdown 卡片
|
||||||
|
zh_Hant: 是否使用 Markdown 卡片
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
@@ -50,9 +63,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Stream Reply Mode
|
en_US: Enable Stream Reply Mode
|
||||||
zh_Hans: 启用钉钉卡片流式回复模式
|
zh_Hans: 启用钉钉卡片流式回复模式
|
||||||
|
zh_Hant: 啟用釘釘卡片串流回覆模式
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||||
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
||||||
|
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
@@ -60,6 +75,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Card Auto Layout
|
en_US: Card Auto Layout
|
||||||
zh_Hans: 卡片宽屏自动布局
|
zh_Hans: 卡片宽屏自动布局
|
||||||
|
zh_Hant: 卡片寬螢幕自動佈局
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
@@ -67,6 +83,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: card template id
|
en_US: card template id
|
||||||
zh_Hans: 卡片模板ID
|
zh_Hans: 卡片模板ID
|
||||||
|
zh_Hant: 卡片範本ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "填写你的卡片template_id"
|
default: "填写你的卡片template_id"
|
||||||
|
|||||||
@@ -5,16 +5,38 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Discord
|
en_US: Discord
|
||||||
zh_Hans: Discord
|
zh_Hans: Discord
|
||||||
|
zh_Hant: Discord
|
||||||
|
ja_JP: Discord
|
||||||
|
th_TH: Discord
|
||||||
|
vi_VN: Discord
|
||||||
|
es_ES: Discord
|
||||||
description:
|
description:
|
||||||
en_US: Discord Adapter
|
en_US: Discord Adapter
|
||||||
zh_Hans: Discord 适配器,请查看文档了解使用方式
|
zh_Hans: Discord 适配器,需要可连接 Discord 服务器的网络环境
|
||||||
|
zh_Hant: Discord 適配器,需要可連線 Discord 伺服器的網路環境
|
||||||
|
ja_JP: Discord アダプター、Discord サーバーに接続可能なネットワーク環境が必要です
|
||||||
|
th_TH: อะแดปเตอร์ Discord ต้องการสภาพแวดล้อมเครือข่ายที่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ Discord ได้
|
||||||
|
vi_VN: Bộ điều hợp Discord, cần môi trường mạng có thể kết nối với máy chủ Discord
|
||||||
|
es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord
|
||||||
icon: discord.svg
|
icon: discord.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/discord
|
||||||
|
en: https://link.langbot.app/en/platforms/discord
|
||||||
|
ja: https://link.langbot.app/ja/platforms/discord
|
||||||
config:
|
config:
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
zh_Hans: 客户端ID
|
zh_Hans: 客户端ID
|
||||||
|
zh_Hant: 用戶端ID
|
||||||
|
ja_JP: クライアント ID
|
||||||
|
th_TH: รหัสไคลเอนต์
|
||||||
|
vi_VN: ID khách hàng
|
||||||
|
es_ES: ID de cliente
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +44,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
ja_JP: トークン
|
||||||
|
th_TH: โทเค็น
|
||||||
|
vi_VN: Mã thông báo
|
||||||
|
es_ES: Token
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,16 +5,25 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: KOOK
|
en_US: KOOK
|
||||||
zh_Hans: KOOK
|
zh_Hans: KOOK
|
||||||
|
zh_Hant: KOOK
|
||||||
description:
|
description:
|
||||||
en_US: KOOK Adapter (formerly KaiHeiLa)
|
en_US: KOOK Adapter (formerly KaiHeiLa)
|
||||||
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
||||||
|
zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息
|
||||||
icon: kook.png
|
icon: kook.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/kook
|
||||||
|
en: https://link.langbot.app/en/platforms/kook
|
||||||
|
ja: https://link.langbot.app/ja/platforms/kook
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Bot Token
|
en_US: Bot Token
|
||||||
zh_Hans: 机器人令牌
|
zh_Hans: 机器人令牌
|
||||||
|
zh_Hant: 機器人令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -709,21 +709,29 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
||||||
|
|
||||||
# Check for quote/reply message
|
# Check for quote/reply message
|
||||||
|
# Extract files/images/voice from quote and add them as top-level components
|
||||||
|
# so that plugins like FileReader can process them the same way as direct messages
|
||||||
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
|
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
|
||||||
if quote_message_id:
|
if quote_message_id:
|
||||||
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
||||||
if quote_chain:
|
if quote_chain:
|
||||||
# Filter out Source component from quoted chain, keep only content
|
# Filter out Source component from quoted chain, keep only content
|
||||||
quote_origin = platform_message.MessageChain(
|
quote_components = [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
|
||||||
[comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
|
|
||||||
)
|
# Add quoted content as top-level components instead of wrapping in Quote
|
||||||
if quote_origin:
|
for comp in quote_components:
|
||||||
message_chain.append(
|
if isinstance(comp, platform_message.File):
|
||||||
platform_message.Quote(
|
# Add file as top-level component (same as direct message)
|
||||||
message_id=quote_message_id,
|
message_chain.append(comp)
|
||||||
origin=quote_origin,
|
elif isinstance(comp, platform_message.Image):
|
||||||
)
|
# Add image as top-level component
|
||||||
)
|
message_chain.append(comp)
|
||||||
|
elif isinstance(comp, platform_message.Voice):
|
||||||
|
# Add voice as top-level component
|
||||||
|
message_chain.append(comp)
|
||||||
|
elif isinstance(comp, platform_message.Plain):
|
||||||
|
# Add text with context prefix
|
||||||
|
message_chain.append(platform_message.Plain(text=f'[引用消息] {comp.text}'))
|
||||||
|
|
||||||
if event.event.message.chat_type == 'p2p':
|
if event.event.message.chat_type == 'p2p':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
@@ -797,8 +805,65 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||||
asyncio.create_task(on_message(event))
|
asyncio.create_task(on_message(event))
|
||||||
|
|
||||||
|
def sync_on_card_action(event):
|
||||||
|
try:
|
||||||
|
action_value_obj = getattr(getattr(event.event, 'action', None), 'value', {})
|
||||||
|
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||||
|
|
||||||
|
if action_value == '有帮助':
|
||||||
|
feedback_type = 1
|
||||||
|
elif action_value == '无帮助':
|
||||||
|
feedback_type = 2
|
||||||
|
else:
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}})
|
||||||
|
|
||||||
|
operator = getattr(event.event, 'operator', None)
|
||||||
|
context = getattr(event.event, 'context', None)
|
||||||
|
|
||||||
|
user_id = getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None)
|
||||||
|
open_chat_id = getattr(context, 'open_chat_id', None)
|
||||||
|
open_message_id = getattr(context, 'open_message_id', None)
|
||||||
|
|
||||||
|
if open_chat_id:
|
||||||
|
session_id = f'group_{open_chat_id}'
|
||||||
|
elif user_id:
|
||||||
|
session_id = f'person_{user_id}'
|
||||||
|
else:
|
||||||
|
session_id = None
|
||||||
|
|
||||||
|
feedback_event = platform_events.FeedbackEvent(
|
||||||
|
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=action_value,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=open_message_id,
|
||||||
|
source_platform_object=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
asyncio.create_task(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||||
|
else:
|
||||||
|
loop.run_until_complete(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||||
|
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
|
||||||
|
except Exception:
|
||||||
|
asyncio.create_task(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}'))
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
|
||||||
|
|
||||||
event_handler = (
|
event_handler = (
|
||||||
lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build()
|
lark_oapi.EventDispatcherHandler.builder('', '')
|
||||||
|
.register_p2_im_message_receive_v1(sync_on_message)
|
||||||
|
.register_p2_card_action_trigger(sync_on_card_action)
|
||||||
|
.build()
|
||||||
)
|
)
|
||||||
|
|
||||||
bot_account_id = config['bot_name']
|
bot_account_id = config['bot_name']
|
||||||
@@ -1088,6 +1153,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'size': 'medium',
|
'size': 'medium',
|
||||||
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
|
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
|
||||||
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
|
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
|
||||||
|
'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}],
|
||||||
'margin': '0px 0px 0px 0px',
|
'margin': '0px 0px 0px 0px',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1111,6 +1177,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'size': 'medium',
|
'size': 'medium',
|
||||||
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
|
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
|
||||||
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
|
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
|
||||||
|
'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}],
|
||||||
'margin': '0px 0px 0px 0px',
|
'margin': '0px 0px 0px 0px',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1472,6 +1539,52 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
if event.__class__ in self.listeners:
|
if event.__class__ in self.listeners:
|
||||||
await self.listeners[event.__class__](event, self)
|
await self.listeners[event.__class__](event, self)
|
||||||
|
elif 'card.action.trigger' == type:
|
||||||
|
try:
|
||||||
|
event_data = data.get('event', {})
|
||||||
|
operator = event_data.get('operator', {})
|
||||||
|
action = event_data.get('action', {})
|
||||||
|
context_data = event_data.get('context', {})
|
||||||
|
|
||||||
|
action_value_obj = action.get('value', {})
|
||||||
|
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||||
|
|
||||||
|
if action_value == '有帮助':
|
||||||
|
feedback_type = 1
|
||||||
|
elif action_value == '无帮助':
|
||||||
|
feedback_type = 2
|
||||||
|
else:
|
||||||
|
return {'toast': {'type': 'success', 'content': '操作成功'}}
|
||||||
|
|
||||||
|
user_id = operator.get('open_id') or operator.get('user_id')
|
||||||
|
open_chat_id = context_data.get('open_chat_id')
|
||||||
|
open_message_id = context_data.get('open_message_id')
|
||||||
|
|
||||||
|
if open_chat_id:
|
||||||
|
session_id = f'group_{open_chat_id}'
|
||||||
|
elif user_id:
|
||||||
|
session_id = f'person_{user_id}'
|
||||||
|
else:
|
||||||
|
session_id = None
|
||||||
|
|
||||||
|
feedback_event = platform_events.FeedbackEvent(
|
||||||
|
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=action_value,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=open_message_id,
|
||||||
|
source_platform_object=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
|
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
|
||||||
|
|
||||||
|
return {'toast': {'type': 'success', 'content': '感谢您的反馈'}}
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')
|
||||||
|
return {'toast': {'type': 'error', 'content': '反馈处理失败'}}
|
||||||
|
|
||||||
elif 'im.chat.member.bot.added_v1' == type:
|
elif 'im.chat.member.bot.added_v1' == type:
|
||||||
try:
|
try:
|
||||||
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
||||||
|
|||||||
@@ -5,16 +5,30 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Lark
|
en_US: Lark
|
||||||
zh_Hans: 飞书
|
zh_Hans: 飞书
|
||||||
|
zh_Hant: 飛書
|
||||||
|
ja_JP: Lark
|
||||||
description:
|
description:
|
||||||
en_US: Lark Adapter
|
en_US: Lark Adapter, supports both long connection and Webhook modes. Please refer to the documentation for usage details.
|
||||||
zh_Hans: 飞书适配器,请查看文档了解使用方式
|
zh_Hans: 飞书适配器,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
||||||
|
zh_Hant: 飛書適配器,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
|
||||||
|
ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
icon: lark.svg
|
icon: lark.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- china
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/lark
|
||||||
|
en: https://link.langbot.app/en/platforms/lark
|
||||||
|
ja: https://link.langbot.app/ja/platforms/lark
|
||||||
config:
|
config:
|
||||||
- name: app_id
|
- name: app_id
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
zh_Hans: 应用ID
|
zh_Hans: 应用ID
|
||||||
|
zh_Hant: 應用ID
|
||||||
|
ja_JP: アプリ ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +36,8 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App Secret
|
en_US: App Secret
|
||||||
zh_Hans: 应用密钥
|
zh_Hans: 应用密钥
|
||||||
|
zh_Hant: 應用密鑰
|
||||||
|
ja_JP: アプリシークレット
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,9 +45,13 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Bot Name
|
en_US: Bot Name
|
||||||
zh_Hans: 机器人名称
|
zh_Hans: 机器人名称
|
||||||
|
zh_Hant: 機器人名稱
|
||||||
|
ja_JP: ボット名
|
||||||
description:
|
description:
|
||||||
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
|
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
|
||||||
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
|
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
|
||||||
|
zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常接收訊息
|
||||||
|
ja_JP: Lark のボット名と一致する必要があります。一致しない場合、グループ内でメッセージを受信できません
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -39,29 +59,63 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Webhook Mode
|
en_US: Enable Webhook Mode
|
||||||
zh_Hans: 启用Webhook模式
|
zh_Hans: 启用Webhook模式
|
||||||
|
zh_Hant: 啟用 Webhook 模式
|
||||||
|
ja_JP: Webhook モードを有効化
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
||||||
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
||||||
|
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
|
||||||
|
ja_JP: 有効にすると、ボットは Webhook モードでメッセージを受信します。無効の場合は WS 長期接続モードを使用します
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
ja_JP: Webhook コールバック URL
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your Lark app's webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到飞书应用的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到飛書應用的 Webhook 設定中
|
||||||
|
ja_JP: この URL をコピーして Lark アプリの Webhook 設定に貼り付けてください
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: enable-webhook
|
||||||
|
operator: eq
|
||||||
|
value: true
|
||||||
- name: encrypt-key
|
- name: encrypt-key
|
||||||
label:
|
label:
|
||||||
en_US: Encrypt Key
|
en_US: Encrypt Key
|
||||||
zh_Hans: 加密密钥
|
zh_Hans: 加密密钥
|
||||||
|
zh_Hant: 加密密鑰
|
||||||
|
ja_JP: 暗号化キー
|
||||||
description:
|
description:
|
||||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||||
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
|
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
|
||||||
|
zh_Hant: 僅在啟用 Webhook 模式時有效,請填寫加密密鑰
|
||||||
|
ja_JP: Webhook モードが有効な場合にのみ有効です。暗号化キーを入力してください
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: enable-webhook
|
||||||
|
operator: eq
|
||||||
|
value: true
|
||||||
- name: enable-stream-reply
|
- name: enable-stream-reply
|
||||||
label:
|
label:
|
||||||
en_US: Enable Stream Reply Mode
|
en_US: Enable Stream Reply Mode
|
||||||
zh_Hans: 启用飞书流式回复模式
|
zh_Hans: 启用飞书流式回复模式
|
||||||
|
zh_Hant: 啟用飛書串流回覆模式
|
||||||
|
ja_JP: ストリーミング返信モードを有効化
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||||
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
|
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
|
||||||
|
zh_Hant: 如果啟用,將使用飛書串流方式來回覆內容
|
||||||
|
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
@@ -69,28 +123,40 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App Type
|
en_US: App Type
|
||||||
zh_Hans: 应用类型
|
zh_Hans: 应用类型
|
||||||
|
zh_Hant: 應用類型
|
||||||
|
ja_JP: アプリタイプ
|
||||||
description:
|
description:
|
||||||
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
|
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
|
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
|
||||||
type: select
|
type: select
|
||||||
options:
|
options:
|
||||||
- name: self
|
- name: self
|
||||||
label:
|
label:
|
||||||
en_US: Self-built Application
|
en_US: Self-built Application
|
||||||
zh_Hans: 自建应用
|
zh_Hans: 自建应用
|
||||||
|
zh_Hant: 自建應用
|
||||||
|
ja_JP: カスタムアプリ
|
||||||
- name: isv
|
- name: isv
|
||||||
label:
|
label:
|
||||||
en_US: Store Application
|
en_US: Store Application
|
||||||
zh_Hans: 商店应用
|
zh_Hans: 商店应用
|
||||||
|
zh_Hant: 商店應用
|
||||||
|
ja_JP: ストアアプリ
|
||||||
required: false
|
required: false
|
||||||
default: self
|
default: self
|
||||||
- name: bot_added_welcome
|
- name: bot_added_welcome
|
||||||
label:
|
label:
|
||||||
en_US: Bot Welcome Message
|
en_US: Bot Welcome Message
|
||||||
zh_Hans: 机器人进群欢迎语
|
zh_Hans: 机器人进群欢迎语
|
||||||
|
zh_Hant: 機器人進群歡迎語
|
||||||
|
ja_JP: ボット参加時のウェルカムメッセージ
|
||||||
description:
|
description:
|
||||||
en_US: Welcome message when the bot is added to a group, supports Markdown format
|
en_US: Welcome message when the bot is added to a group, supports Markdown format
|
||||||
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
|
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
|
||||||
|
zh_Hant: 機器人進群歡迎語,支援 Markdown 格式
|
||||||
|
ja_JP: ボットがグループに追加された際のウェルカムメッセージ。Markdown 形式に対応しています
|
||||||
type: text
|
type: text
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,20 +5,56 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: LINE
|
en_US: LINE
|
||||||
zh_Hans: LINE
|
zh_Hans: LINE
|
||||||
|
zh_Hant: LINE
|
||||||
|
th_TH: LINE
|
||||||
|
vi_VN: LINE
|
||||||
|
es_ES: LINE
|
||||||
description:
|
description:
|
||||||
en_US: LINE Adapter
|
en_US: LINE Adapter, requires a public URL to receive LINE message pushes, please refer to the documentation for usage details
|
||||||
zh_Hans: LINE适配器,请查看文档了解使用方式
|
zh_Hans: LINE适配器,需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式
|
||||||
ja_JP: LINEアダプター、ドキュメントを参照してください
|
zh_Hant: LINE 適配器,需要公網地址以接收 LINE 訊息推送,請查看文件了解使用方式
|
||||||
zh_Hant: LINE適配器,請查看文檔了解使用方式
|
ja_JP: LINEアダプター、LINEのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
|
th_TH: อะแดปเตอร์ LINE ต้องการ URL สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก LINE โปรดดูเอกสารประกอบสำหรับรายละเอียดการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp LINE, cần URL công cộng để nhận thông báo tin nhắn LINE, vui lòng xem tài liệu để biết chi tiết cách sử dụng
|
||||||
|
es_ES: Adaptador de LINE, requiere una URL pública para recibir notificaciones de mensajes de LINE, consulte la documentación para obtener detalles de uso
|
||||||
icon: line.png
|
icon: line.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/line
|
||||||
|
en: https://link.langbot.app/en/platforms/line
|
||||||
|
ja: https://link.langbot.app/ja/platforms/line
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
ja_JP: Webhook コールバック URL
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
th_TH: URL การเรียกกลับ Webhook
|
||||||
|
vi_VN: URL gọi lại Webhook
|
||||||
|
es_ES: URL de devolución de llamada Webhook
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your LINE channel's webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到 LINE 频道的 Webhook 配置中
|
||||||
|
ja_JP: この URL をコピーして LINE チャンネルの Webhook 設定に貼り付けてください
|
||||||
|
zh_Hant: 複製此地址並貼到 LINE 頻道的 Webhook 設定中
|
||||||
|
th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่า Webhook ของช่อง LINE ของคุณ
|
||||||
|
vi_VN: Sao chép URL này và dán vào cấu hình webhook của kênh LINE của bạn
|
||||||
|
es_ES: Copie esta URL y péguela en la configuración de webhook de su canal LINE
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: channel_access_token
|
- name: channel_access_token
|
||||||
label:
|
label:
|
||||||
en_US: Channel access token
|
en_US: Channel access token
|
||||||
zh_Hans: 频道访问令牌
|
zh_Hans: 频道访问令牌
|
||||||
ja_JP: チャンネルアクセストークン
|
ja_JP: チャンネルアクセストークン
|
||||||
zh_Hant: 頻道訪問令牌
|
zh_Hant: 頻道存取令牌
|
||||||
|
th_TH: โทเค็นการเข้าถึงช่อง
|
||||||
|
vi_VN: Mã truy cập kênh
|
||||||
|
es_ES: Token de acceso del canal
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -27,12 +63,18 @@ spec:
|
|||||||
en_US: Channel secret
|
en_US: Channel secret
|
||||||
zh_Hans: 消息密钥
|
zh_Hans: 消息密钥
|
||||||
ja_JP: チャンネルシークレット
|
ja_JP: チャンネルシークレット
|
||||||
zh_Hant: 消息密钥
|
zh_Hant: 訊息密鑰
|
||||||
|
th_TH: รหัสลับช่อง
|
||||||
|
vi_VN: Khóa bí mật kênh
|
||||||
|
es_ES: Secreto del canal
|
||||||
description:
|
description:
|
||||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||||
zh_Hans: 请填写加密密钥
|
zh_Hans: 请填写加密密钥
|
||||||
ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください
|
ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください
|
||||||
zh_Hant: 請填寫加密密钥
|
zh_Hant: 請填寫加密密鑰
|
||||||
|
th_TH: กรุณากรอกคีย์เข้ารหัส
|
||||||
|
vi_VN: Vui lòng điền khóa mã hóa
|
||||||
|
es_ES: Por favor, introduzca la clave de cifrado
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,23 +5,44 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Official Account
|
en_US: Official Account
|
||||||
zh_Hans: 微信公众号
|
zh_Hans: 微信公众号
|
||||||
|
zh_Hant: 微信公眾號
|
||||||
description:
|
description:
|
||||||
en_US: Official Account Adapter
|
en_US: Official Account Adapter
|
||||||
zh_Hans: 微信公众号适配器,请查看文档了解使用方式
|
zh_Hans: 微信公众号适配器,需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||||
|
zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||||
icon: officialaccount.png
|
icon: officialaccount.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/officialaccount
|
||||||
|
en: https://link.langbot.app/en/platforms/officialaccount
|
||||||
|
ja: https://link.langbot.app/ja/platforms/officialaccount
|
||||||
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 Official Account webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到微信公众号的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到微信公眾號的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
type: string
|
zh_Hant: 令牌
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
- name: EncodingAESKey
|
- name: EncodingAESKey
|
||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥
|
zh_Hans: 消息加解密密钥
|
||||||
|
zh_Hant: 訊息加解密密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +50,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
zh_Hans: 应用ID
|
zh_Hans: 应用ID
|
||||||
|
zh_Hant: 應用ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +58,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App Secret
|
en_US: App Secret
|
||||||
zh_Hans: 应用密钥
|
zh_Hans: 应用密钥
|
||||||
|
zh_Hant: 應用密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,6 +66,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Mode
|
en_US: Mode
|
||||||
zh_Hans: 接入模式
|
zh_Hans: 接入模式
|
||||||
|
zh_Hant: 接入模式
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "drop"
|
default: "drop"
|
||||||
@@ -50,6 +74,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Loading Message
|
en_US: Loading Message
|
||||||
zh_Hans: 加载消息
|
zh_Hans: 加载消息
|
||||||
|
zh_Hant: 載入訊息
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "AI正在思考中,请发送任意内容获取回复。"
|
default: "AI正在思考中,请发送任意内容获取回复。"
|
||||||
@@ -57,9 +82,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
zh_Hans: API 基础 URL
|
zh_Hans: API 基础 URL
|
||||||
|
zh_Hant: API 基礎 URL
|
||||||
description:
|
description:
|
||||||
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
|
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
|
||||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项
|
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项
|
||||||
|
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取微信公眾號 API,可根據文件修改此項
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: "https://api.weixin.qq.com"
|
default: "https://api.weixin.qq.com"
|
||||||
|
|||||||
@@ -4,20 +4,31 @@ metadata:
|
|||||||
name: openclaw-weixin
|
name: openclaw-weixin
|
||||||
label:
|
label:
|
||||||
en_US: OpenClaw WeChat
|
en_US: OpenClaw WeChat
|
||||||
zh_Hans: OpenClaw 微信
|
zh_Hans: 个人微信机器人
|
||||||
|
zh_Hant: 個人微信機器人
|
||||||
description:
|
description:
|
||||||
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
|
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
|
||||||
zh_Hans: OpenClaw 微信适配器,通过扫码登录支持个人微信
|
zh_Hans: 微信官方个人助手,扫码即可登录使用
|
||||||
|
zh_Hant: 微信官方個人助手,掃碼即可登入使用
|
||||||
icon: wechat.png
|
icon: wechat.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
|
||||||
|
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
||||||
|
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
zh_Hans: API 基础地址
|
zh_Hans: API 基础地址
|
||||||
|
zh_Hant: API 基礎地址
|
||||||
description:
|
description:
|
||||||
en_US: The base URL of the OpenClaw WeChat backend API
|
en_US: The base URL of the OpenClaw WeChat backend API
|
||||||
zh_Hans: OpenClaw 微信后端 API 的基础地址
|
zh_Hans: OpenClaw 微信后端 API 的基础地址
|
||||||
|
zh_Hant: OpenClaw 微信後端 API 的基礎地址
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://ilinkai.weixin.qq.com"
|
default: "https://ilinkai.weixin.qq.com"
|
||||||
@@ -25,9 +36,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
description:
|
description:
|
||||||
en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup.
|
en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup.
|
||||||
zh_Hans: 扫码登录授权后获取的 Bearer 令牌。留空则启动时自动触发扫码登录。
|
zh_Hans: 扫码登录授权后获取的 Bearer 令牌。请留空并保存,将在启动时输出二维码到日志,扫码后即可自动登录。
|
||||||
|
zh_Hant: 掃碼登入授權後取得的 Bearer 令牌。請留空並儲存,將在啟動時輸出 QR Code 到日誌,掃碼後即可自動登入。
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
@@ -35,9 +48,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Account ID
|
en_US: Account ID
|
||||||
zh_Hans: 账号标识
|
zh_Hans: 账号标识
|
||||||
|
zh_Hant: 帳號標識
|
||||||
description:
|
description:
|
||||||
en_US: A label for this WeChat account (used for display purposes)
|
en_US: A label for this WeChat account (used for display purposes)
|
||||||
zh_Hans: 此微信账号的标识(用于显示)
|
zh_Hans: 此微信账号的标识(用于显示)
|
||||||
|
zh_Hant: 此微信帳號的標識(用於顯示)
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: "openclaw-weixin"
|
default: "openclaw-weixin"
|
||||||
@@ -45,9 +60,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Poll Timeout (seconds)
|
en_US: Poll Timeout (seconds)
|
||||||
zh_Hans: 轮询超时(秒)
|
zh_Hans: 轮询超时(秒)
|
||||||
|
zh_Hant: 輪詢逾時(秒)
|
||||||
description:
|
description:
|
||||||
en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration
|
en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration
|
||||||
zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长
|
zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长
|
||||||
|
zh_Hant: getUpdates 長輪詢逾時時間,伺服端最多持有請求的時長
|
||||||
type: integer
|
type: integer
|
||||||
required: false
|
required: false
|
||||||
default: 35
|
default: 35
|
||||||
|
|||||||
@@ -5,16 +5,37 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: QQ Official API
|
en_US: QQ Official API
|
||||||
zh_Hans: QQ 官方 API
|
zh_Hans: QQ 官方 API
|
||||||
|
zh_Hant: QQ 官方 API
|
||||||
description:
|
description:
|
||||||
en_US: QQ Official API (Webhook)
|
en_US: QQ Official API (Webhook)
|
||||||
zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式
|
zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||||
|
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||||
icon: qqofficial.svg
|
icon: qqofficial.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/qqofficial
|
||||||
|
en: https://link.langbot.app/en/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
|
||||||
zh_Hans: 应用ID
|
zh_Hans: 应用ID
|
||||||
|
zh_Hant: 應用ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +43,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
zh_Hans: 密钥
|
zh_Hans: 密钥
|
||||||
|
zh_Hant: 密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +51,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,36 +5,70 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Satori
|
en_US: Satori
|
||||||
zh_Hans: Satori
|
zh_Hans: Satori
|
||||||
|
zh_Hant: Satori
|
||||||
|
th_TH: Satori
|
||||||
|
vi_VN: Satori
|
||||||
|
es_ES: Satori
|
||||||
description:
|
description:
|
||||||
en_US: SatoriAdapter
|
en_US: SatoriAdapter
|
||||||
zh_Hans: 古明地觉协议适配器
|
zh_Hans: Satori 协议适配器,支持多种平台的接入,请查看文档了解使用方式
|
||||||
|
zh_Hant: Satori 協定適配器,支援多種平台的接入,請查看文件了解使用方式
|
||||||
|
th_TH: อะแดปเตอร์โปรโตคอล Satori รองรับการเชื่อมต่อหลายแพลตฟอร์ม โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp giao thức Satori, hỗ trợ kết nối nhiều nền tảng, vui lòng xem tài liệu để biết cách sử dụng
|
||||||
|
es_ES: Adaptador del protocolo Satori, soporta acceso a múltiples plataformas, consulte la documentación para obtener instrucciones de uso
|
||||||
icon: satori.png
|
icon: satori.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- protocol
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/satori
|
||||||
|
en: https://link.langbot.app/en/platforms/satori
|
||||||
|
ja: https://link.langbot.app/ja/platforms/satori
|
||||||
config:
|
config:
|
||||||
- name: platform
|
- name: platform
|
||||||
label:
|
label:
|
||||||
en_US: Platform
|
en_US: Platform
|
||||||
zh_Hans: 平台名称
|
zh_Hans: 平台名称
|
||||||
|
zh_Hant: 平台名稱
|
||||||
|
th_TH: ชื่อแพลตฟอร์ม
|
||||||
|
vi_VN: Tên nền tảng
|
||||||
|
es_ES: Nombre de la plataforma
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "llonebot"
|
default: "llonebot"
|
||||||
description:
|
description:
|
||||||
en_US: The platform name (e.g., llonebot, discord, telegram)
|
en_US: The platform name (e.g., llonebot, discord, telegram)
|
||||||
zh_Hans: 平台名称(如 llonebot, discord, telegram)
|
zh_Hans: 平台名称(如 llonebot, discord, telegram)
|
||||||
|
zh_Hant: 平台名稱(如 llonebot、discord、telegram)
|
||||||
|
th_TH: ชื่อแพลตฟอร์ม (เช่น llonebot, discord, telegram)
|
||||||
|
vi_VN: "Tên nền tảng (ví dụ: llonebot, discord, telegram)"
|
||||||
|
es_ES: El nombre de la plataforma (p. ej., llonebot, discord, telegram)
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
en_US: Host
|
en_US: Host
|
||||||
zh_Hans: 主机地址
|
zh_Hans: 主机地址
|
||||||
|
zh_Hant: 主機地址
|
||||||
|
th_TH: ที่อยู่โฮสต์
|
||||||
|
vi_VN: Địa chỉ máy chủ
|
||||||
|
es_ES: Dirección del host
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "127.0.0.1"
|
default: "127.0.0.1"
|
||||||
description:
|
description:
|
||||||
en_US: The host address of LLOneBot Satori server (e.g., 127.0.0.1, localhost, 192.168.1.100)
|
en_US: The host address of LLOneBot Satori server (e.g., 127.0.0.1, localhost, 192.168.1.100)
|
||||||
zh_Hans: LLOneBot Satori服务器的主机地址(如 127.0.0.1, localhost, 192.168.1.100)
|
zh_Hans: LLOneBot Satori服务器的主机地址(如 127.0.0.1, localhost, 192.168.1.100)
|
||||||
|
zh_Hant: LLOneBot Satori 伺服器的主機地址(如 127.0.0.1、localhost、192.168.1.100)
|
||||||
|
th_TH: ที่อยู่โฮสต์ของเซิร์ฟเวอร์ LLOneBot Satori (เช่น 127.0.0.1, localhost, 192.168.1.100)
|
||||||
|
vi_VN: "Địa chỉ máy chủ LLOneBot Satori (ví dụ: 127.0.0.1, localhost, 192.168.1.100)"
|
||||||
|
es_ES: La dirección del host del servidor LLOneBot Satori (p. ej., 127.0.0.1, localhost, 192.168.1.100)
|
||||||
- name: port
|
- name: port
|
||||||
label:
|
label:
|
||||||
en_US: Port
|
en_US: Port
|
||||||
zh_Hans: 监听端口
|
zh_Hans: 监听端口
|
||||||
|
zh_Hant: 監聽連接埠
|
||||||
|
th_TH: พอร์ต
|
||||||
|
vi_VN: Cổng
|
||||||
|
es_ES: Puerto
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 5600
|
default: 5600
|
||||||
@@ -42,6 +76,10 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Satori API Endpoint
|
en_US: Satori API Endpoint
|
||||||
zh_Hans: Satori API 终结点
|
zh_Hans: Satori API 终结点
|
||||||
|
zh_Hant: Satori API 端點
|
||||||
|
th_TH: จุดปลาย Satori API
|
||||||
|
vi_VN: Điểm cuối Satori API
|
||||||
|
es_ES: Punto de acceso de la API Satori
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "http://localhost:5600/v1"
|
default: "http://localhost:5600/v1"
|
||||||
@@ -49,6 +87,10 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Satori WebSocket Endpoint
|
en_US: Satori WebSocket Endpoint
|
||||||
zh_Hans: Satori WebSocket 终结点
|
zh_Hans: Satori WebSocket 终结点
|
||||||
|
zh_Hant: Satori WebSocket 端點
|
||||||
|
th_TH: จุดปลาย Satori WebSocket
|
||||||
|
vi_VN: Điểm cuối Satori WebSocket
|
||||||
|
es_ES: Punto de acceso WebSocket de Satori
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "ws://localhost:5600/v1/events"
|
default: "ws://localhost:5600/v1/events"
|
||||||
@@ -56,6 +98,10 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
th_TH: โทเค็น
|
||||||
|
vi_VN: Mã thông báo
|
||||||
|
es_ES: Token
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,16 +5,58 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Slack
|
en_US: Slack
|
||||||
zh_Hans: Slack
|
zh_Hans: Slack
|
||||||
|
zh_Hant: Slack
|
||||||
|
ja_JP: Slack
|
||||||
|
th_TH: Slack
|
||||||
|
vi_VN: Slack
|
||||||
|
es_ES: Slack
|
||||||
description:
|
description:
|
||||||
en_US: Slack Adapter
|
en_US: Slack Adapter
|
||||||
zh_Hans: Slack 适配器,请查看文档了解使用方式
|
zh_Hans: Slack 适配器,需要公网地址以接收 Slack 消息推送,请查看文档了解使用方式
|
||||||
|
zh_Hant: Slack 適配器,需要公網地址以接收 Slack 訊息推送,請查看文件了解使用方式
|
||||||
|
ja_JP: Slack アダプター、Slackのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
|
th_TH: อะแดปเตอร์ Slack ต้องการที่อยู่สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก Slack โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp Slack, cần địa chỉ công cộng để nhận thông báo tin nhắn từ Slack, vui lòng xem tài liệu để biết cách sử dụng
|
||||||
|
es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso
|
||||||
icon: slack.png
|
icon: slack.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/slack
|
||||||
|
en: https://link.langbot.app/en/platforms/slack
|
||||||
|
ja: https://link.langbot.app/ja/platforms/slack
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
ja_JP: Webhook コールバック URL
|
||||||
|
th_TH: URL การเรียกกลับ Webhook
|
||||||
|
vi_VN: URL gọi lại Webhook
|
||||||
|
es_ES: URL de devolución de llamada Webhook
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your Slack app's event subscription configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到 Slack 应用的事件订阅配置中
|
||||||
|
zh_Hant: 複製此地址並貼到 Slack 應用的事件訂閱設定中
|
||||||
|
ja_JP: この URL をコピーして Slack アプリのイベントサブスクリプション設定に貼り付けてください
|
||||||
|
th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่าการสมัครรับเหตุการณ์ของแอป Slack ของคุณ
|
||||||
|
vi_VN: Sao chép URL này và dán vào cấu hình đăng ký sự kiện của ứng dụng Slack của bạn
|
||||||
|
es_ES: Copie esta URL y péguela en la configuración de suscripción de eventos de su aplicación Slack
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: bot_token
|
- name: bot_token
|
||||||
label:
|
label:
|
||||||
en_US: Bot Token
|
en_US: Bot Token
|
||||||
zh_Hans: 机器人令牌
|
zh_Hans: 机器人令牌
|
||||||
|
zh_Hant: 機器人令牌
|
||||||
|
ja_JP: ボットトークン
|
||||||
|
th_TH: โทเค็นบอท
|
||||||
|
vi_VN: Mã thông báo Bot
|
||||||
|
es_ES: Token del bot
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +64,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: signing_secret
|
en_US: signing_secret
|
||||||
zh_Hans: 密钥
|
zh_Hans: 密钥
|
||||||
|
zh_Hant: 密鑰
|
||||||
|
ja_JP: 署名シークレット
|
||||||
|
th_TH: คีย์ลายเซ็น
|
||||||
|
vi_VN: Khóa ký
|
||||||
|
es_ES: Secreto de firma
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,23 +5,50 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Telegram
|
en_US: Telegram
|
||||||
zh_Hans: 电报
|
zh_Hans: 电报
|
||||||
|
zh_Hant: Telegram
|
||||||
|
ja_JP: Telegram
|
||||||
|
th_TH: Telegram
|
||||||
|
vi_VN: Telegram
|
||||||
|
es_ES: Telegram
|
||||||
description:
|
description:
|
||||||
en_US: Telegram Adapter
|
en_US: Telegram Adapter
|
||||||
zh_Hans: 电报适配器,请查看文档了解使用方式
|
zh_Hans: Telegram 适配器,请查看文档了解使用方式
|
||||||
|
zh_Hant: Telegram 適配器,請查看文件了解使用方式
|
||||||
|
ja_JP: Telegram アダプター。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
|
th_TH: อะแดปเตอร์ Telegram โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp Telegram, vui lòng xem tài liệu để biết cách sử dụng
|
||||||
|
es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso
|
||||||
icon: telegram.svg
|
icon: telegram.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/telegram
|
||||||
|
en: https://link.langbot.app/en/platforms/telegram
|
||||||
|
ja: https://link.langbot.app/ja/platforms/telegram
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
ja_JP: トークン
|
||||||
|
th_TH: โทเค็น
|
||||||
|
vi_VN: Mã thông báo
|
||||||
|
es_ES: Token
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: "token_from_botfather"
|
||||||
- name: markdown_card
|
- name: markdown_card
|
||||||
label:
|
label:
|
||||||
en_US: Markdown Card
|
en_US: Markdown Card
|
||||||
zh_Hans: 是否使用 Markdown 卡片
|
zh_Hans: 是否使用 Markdown 卡片
|
||||||
|
zh_Hant: 是否使用 Markdown 卡片
|
||||||
|
ja_JP: Markdown カードを使用
|
||||||
|
th_TH: การ์ด Markdown
|
||||||
|
vi_VN: Thẻ Markdown
|
||||||
|
es_ES: Tarjeta Markdown
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
@@ -29,9 +56,19 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Stream Reply Mode
|
en_US: Enable Stream Reply Mode
|
||||||
zh_Hans: 启用电报流式回复模式
|
zh_Hans: 启用电报流式回复模式
|
||||||
|
zh_Hant: 啟用 Telegram 串流回覆模式
|
||||||
|
ja_JP: ストリーミング返信モードを有効化
|
||||||
|
th_TH: เปิดใช้งานโหมดตอบกลับแบบสตรีม
|
||||||
|
vi_VN: Bật chế độ trả lời trực tuyến
|
||||||
|
es_ES: Habilitar modo de respuesta en streaming
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use the stream of telegram reply mode
|
en_US: If enabled, the bot will use the stream of telegram reply mode
|
||||||
zh_Hans: 如果启用,将使用电报流式方式来回复内容
|
zh_Hans: 如果启用,将使用电报流式方式来回复内容
|
||||||
|
zh_Hant: 如果啟用,將使用 Telegram 串流方式來回覆內容
|
||||||
|
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||||
|
th_TH: หากเปิดใช้งาน บอทจะใช้โหมดสตรีมของ Telegram ในการตอบกลับ
|
||||||
|
vi_VN: Nếu bật, bot sẽ sử dụng chế độ trả lời trực tuyến của Telegram
|
||||||
|
es_ES: Si está habilitado, el bot usará el modo de respuesta en streaming de Telegram
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
|||||||
@@ -5,11 +5,21 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: "WebSocket Chat"
|
en_US: "WebSocket Chat"
|
||||||
zh_Hans: "WebSocket 聊天"
|
zh_Hans: "WebSocket 聊天"
|
||||||
|
zh_Hant: "WebSocket 聊天"
|
||||||
|
th_TH: "แชท WebSocket"
|
||||||
|
vi_VN: "Trò chuyện WebSocket"
|
||||||
|
es_ES: "Chat WebSocket"
|
||||||
description:
|
description:
|
||||||
en_US: "WebSocket adapter for bidirectional real-time communication"
|
en_US: "WebSocket adapter for bidirectional real-time communication"
|
||||||
zh_Hans: "用于双向实时通信的 WebSocket 适配器"
|
zh_Hans: "用于双向实时通信的 WebSocket 适配器"
|
||||||
|
zh_Hant: "用於雙向即時通訊的 WebSocket 適配器"
|
||||||
|
th_TH: "อะแดปเตอร์ WebSocket สำหรับการสื่อสารแบบเรียลไทม์สองทิศทาง"
|
||||||
|
vi_VN: "Bộ điều hợp WebSocket cho giao tiếp thời gian thực hai chiều"
|
||||||
|
es_ES: "Adaptador WebSocket para comunicación bidireccional en tiempo real"
|
||||||
icon: ""
|
icon: ""
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- protocol
|
||||||
config: []
|
config: []
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
@@ -4,17 +4,26 @@ metadata:
|
|||||||
name: wechatpad
|
name: wechatpad
|
||||||
label:
|
label:
|
||||||
en_US: WeChatPad
|
en_US: WeChatPad
|
||||||
zh_CN: WeChatPad(个人微信ipad)
|
zh_Hans: WeChatPad(个人微信ipad)
|
||||||
|
zh_Hant: WeChatPad(個人微信iPad)
|
||||||
description:
|
description:
|
||||||
en_US: WeChatPad Adapter
|
en_US: WeChatPad Adapter
|
||||||
zh_CN: WeChatPad 适配器
|
zh_Hans: WeChatPad 适配器,基于WeChatPad的个人微信解决方案,请查看文档了解使用方式
|
||||||
|
zh_Hant: WeChatPad 適配器,基於 WeChatPad 的個人微信解決方案,請查看文件了解使用方式
|
||||||
icon: wechatpad.png
|
icon: wechatpad.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/wechatpad
|
||||||
|
en: https://link.langbot.app/en/platforms/wechatpad
|
||||||
|
ja: https://link.langbot.app/ja/platforms/wechatpad
|
||||||
config:
|
config:
|
||||||
- name: wechatpad_url
|
- name: wechatpad_url
|
||||||
label:
|
label:
|
||||||
en_US: WeChatPad ERL
|
en_US: WeChatPad ERL
|
||||||
zh_CN: WeChatPad URL
|
zh_CN: WeChatPad URL
|
||||||
|
zh_Hant: WeChatPad URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +31,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: WeChatPad_Ws
|
en_US: WeChatPad_Ws
|
||||||
zh_CN: WeChatPad_Ws
|
zh_CN: WeChatPad_Ws
|
||||||
|
zh_Hant: WeChatPad_Ws
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +39,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Admin_Key
|
en_US: Admin_Key
|
||||||
zh_CN: 管理员密匙
|
zh_CN: 管理员密匙
|
||||||
|
zh_Hant: 管理員密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +47,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_CN: 令牌
|
zh_CN: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,6 +55,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: wxid
|
en_US: wxid
|
||||||
zh_CN: wxid
|
zh_CN: wxid
|
||||||
|
zh_Hant: wxid
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,16 +5,38 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: WeCom
|
en_US: WeCom
|
||||||
zh_Hans: 企业微信
|
zh_Hans: 企业微信
|
||||||
|
zh_Hant: 企業微信
|
||||||
description:
|
description:
|
||||||
en_US: WeCom Adapter
|
en_US: WeCom Adapter
|
||||||
zh_Hans: 企业微信适配器,请查看文档了解使用方式
|
zh_Hans: 企业微信内部机器人,请查看文档了解使用方式
|
||||||
|
zh_Hant: 企業微信內部機器人,請查看文件了解使用方式
|
||||||
icon: wecom.png
|
icon: wecom.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/wecom
|
||||||
|
en: https://link.langbot.app/en/platforms/wecom
|
||||||
|
ja: https://link.langbot.app/ja/platforms/wecom
|
||||||
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 WeCom app's webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到企业微信应用的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到企業微信應用的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: corpid
|
- name: corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
zh_Hans: 企业ID
|
zh_Hans: 企业ID
|
||||||
|
zh_Hant: 企業ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +44,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
zh_Hans: 密钥 (Secret)
|
zh_Hans: 密钥 (Secret)
|
||||||
|
zh_Hant: 密鑰 (Secret)
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +52,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌 (Token)
|
zh_Hans: 令牌 (Token)
|
||||||
|
zh_Hant: 令牌 (Token)
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +60,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||||
|
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,9 +68,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
zh_Hans: API 基础 URL
|
zh_Hans: API 基础 URL
|
||||||
|
zh_Hant: API 基礎 URL
|
||||||
description:
|
description:
|
||||||
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
||||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项
|
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项
|
||||||
|
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API,可根據文件填寫此項
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||||
|
|||||||
@@ -126,6 +126,107 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if summary:
|
if summary:
|
||||||
yiri_msg_list.append(platform_message.Plain(text=summary))
|
yiri_msg_list.append(platform_message.Plain(text=summary))
|
||||||
|
|
||||||
|
# Handle quoted message (引用消息) - important for group chat file references
|
||||||
|
# Extract files/images/voice from quote and add them as top-level components
|
||||||
|
# so that plugins like FileReader can process them the same way as direct messages
|
||||||
|
quote_info = event.quote or {}
|
||||||
|
if quote_info:
|
||||||
|
# Process quote text content - add as Plain for context
|
||||||
|
if quote_info.get('content'):
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info.get("content")}'))
|
||||||
|
|
||||||
|
# Process quote images - add as top-level Image components
|
||||||
|
quote_images = quote_info.get('images', [])
|
||||||
|
if not quote_images and quote_info.get('picurl'):
|
||||||
|
quote_images = [quote_info.get('picurl')]
|
||||||
|
for img_data in quote_images:
|
||||||
|
if img_data:
|
||||||
|
yiri_msg_list.append(platform_message.Image(base64=img_data))
|
||||||
|
|
||||||
|
# Process quote file - add as top-level File component (same as private chat)
|
||||||
|
quote_file = quote_info.get('file') or {}
|
||||||
|
if quote_file:
|
||||||
|
file_url = (
|
||||||
|
quote_file.get('base64')
|
||||||
|
or quote_file.get('download_url')
|
||||||
|
or quote_file.get('url')
|
||||||
|
or quote_file.get('fileurl')
|
||||||
|
)
|
||||||
|
file_name = quote_file.get('filename') or quote_file.get('name')
|
||||||
|
file_size = quote_file.get('filesize') or quote_file.get('size')
|
||||||
|
if file_url or file_name:
|
||||||
|
file_kwargs = {}
|
||||||
|
if file_url:
|
||||||
|
file_kwargs['url'] = file_url
|
||||||
|
if file_name:
|
||||||
|
file_kwargs['name'] = file_name
|
||||||
|
if file_size is not None:
|
||||||
|
file_kwargs['size'] = file_size
|
||||||
|
try:
|
||||||
|
yiri_msg_list.append(platform_message.File(**file_kwargs))
|
||||||
|
except Exception:
|
||||||
|
yiri_msg_list.append(platform_message.Unknown(text='[quoted file unsupported]'))
|
||||||
|
|
||||||
|
# Process quote voice - add as top-level Voice/File component
|
||||||
|
quote_voice = quote_info.get('voice') or {}
|
||||||
|
if quote_voice:
|
||||||
|
voice_payload = quote_voice.get('base64') or quote_voice.get('url')
|
||||||
|
if voice_payload:
|
||||||
|
if quote_voice.get('base64') and not voice_payload.startswith('data:'):
|
||||||
|
voice_payload = f'data:audio/mpeg;base64,{quote_voice.get("base64")}'
|
||||||
|
try:
|
||||||
|
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
voice_kwargs = {'url': voice_payload}
|
||||||
|
voice_name = quote_voice.get('filename') or quote_voice.get('name')
|
||||||
|
voice_size = quote_voice.get('filesize') or quote_voice.get('size')
|
||||||
|
if voice_name:
|
||||||
|
voice_kwargs['name'] = voice_name
|
||||||
|
if voice_size is not None:
|
||||||
|
voice_kwargs['size'] = voice_size
|
||||||
|
yiri_msg_list.append(platform_message.File(**voice_kwargs))
|
||||||
|
except Exception:
|
||||||
|
yiri_msg_list.append(platform_message.Unknown(text='[quoted voice unsupported]'))
|
||||||
|
|
||||||
|
# Process quote video - add as top-level File component
|
||||||
|
quote_video = quote_info.get('video') or {}
|
||||||
|
if quote_video:
|
||||||
|
video_payload = (
|
||||||
|
quote_video.get('base64')
|
||||||
|
or quote_video.get('url')
|
||||||
|
or quote_video.get('download_url')
|
||||||
|
or quote_video.get('fileurl')
|
||||||
|
)
|
||||||
|
if video_payload:
|
||||||
|
video_kwargs = {'url': video_payload}
|
||||||
|
video_name = quote_video.get('filename') or quote_video.get('name')
|
||||||
|
video_size = quote_video.get('filesize') or quote_video.get('size')
|
||||||
|
if video_name:
|
||||||
|
video_kwargs['name'] = video_name
|
||||||
|
if video_size is not None:
|
||||||
|
video_kwargs['size'] = video_size
|
||||||
|
try:
|
||||||
|
yiri_msg_list.append(platform_message.File(**video_kwargs))
|
||||||
|
except Exception:
|
||||||
|
yiri_msg_list.append(platform_message.Unknown(text='[quoted video unsupported]'))
|
||||||
|
|
||||||
|
# Process quote link - add as Plain text
|
||||||
|
quote_link = quote_info.get('link') or {}
|
||||||
|
if quote_link:
|
||||||
|
link_summary = '\n'.join(
|
||||||
|
filter(
|
||||||
|
None,
|
||||||
|
[
|
||||||
|
quote_link.get('title', ''),
|
||||||
|
quote_link.get('description') or quote_link.get('digest', ''),
|
||||||
|
quote_link.get('url', ''),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if link_summary:
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用链接] {link_summary}'))
|
||||||
|
|
||||||
has_content_element = any(
|
has_content_element = any(
|
||||||
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
||||||
)
|
)
|
||||||
@@ -311,6 +412,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.bot.on_message('single')(self.on_message)
|
self.bot.on_message('single')(self.on_message)
|
||||||
elif event_type == platform_events.GroupMessage:
|
elif event_type == platform_events.GroupMessage:
|
||||||
self.bot.on_message('group')(self.on_message)
|
self.bot.on_message('group')(self.on_message)
|
||||||
|
elif event_type == platform_events.FeedbackEvent:
|
||||||
|
if hasattr(self.bot, 'on_feedback'):
|
||||||
|
self.bot.on_feedback()(self._on_feedback)
|
||||||
except Exception:
|
except Exception:
|
||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
|
|
||||||
@@ -318,6 +422,53 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||||
self.bot_uuid = bot_uuid
|
self.bot_uuid = bot_uuid
|
||||||
|
|
||||||
|
async def _on_feedback(self, **kwargs):
|
||||||
|
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
|
||||||
|
try:
|
||||||
|
feedback_id = kwargs.get('feedback_id', '')
|
||||||
|
feedback_type = kwargs.get('feedback_type', 0)
|
||||||
|
feedback_content = kwargs.get('feedback_content', '') or None
|
||||||
|
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) or None
|
||||||
|
# WeChat Work returns integer reason codes, but FeedbackEvent expects strings
|
||||||
|
if inaccurate_reasons:
|
||||||
|
inaccurate_reasons = [str(r) for r in inaccurate_reasons]
|
||||||
|
session = kwargs.get('session')
|
||||||
|
|
||||||
|
session_id = None
|
||||||
|
user_id = None
|
||||||
|
message_id = None
|
||||||
|
stream_id = None
|
||||||
|
if session:
|
||||||
|
if session.chat_id:
|
||||||
|
session_id = f'group_{session.chat_id}'
|
||||||
|
elif session.user_id:
|
||||||
|
session_id = f'person_{session.user_id}'
|
||||||
|
user_id = session.user_id
|
||||||
|
message_id = session.msg_id
|
||||||
|
stream_id = session.stream_id
|
||||||
|
|
||||||
|
await self.logger.info(
|
||||||
|
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
|
f'session_id={session_id}, user_id={user_id}, message_id={message_id}'
|
||||||
|
)
|
||||||
|
|
||||||
|
event = platform_events.FeedbackEvent(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=message_id,
|
||||||
|
stream_id=stream_id,
|
||||||
|
source_platform_object=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
|
await self.listeners[platform_events.FeedbackEvent](event, self)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in wecombot feedback callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
|
|||||||
@@ -5,16 +5,25 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: WeComBot
|
en_US: WeComBot
|
||||||
zh_Hans: 企业微信智能机器人
|
zh_Hans: 企业微信智能机器人
|
||||||
|
zh_Hant: 企業微信智慧機器人
|
||||||
description:
|
description:
|
||||||
en_US: WeComBot Adapter
|
en_US: WeComBot Adapter
|
||||||
zh_Hans: 企业微信智能机器人适配器,请查看文档了解使用方式
|
zh_Hans: 企业微信智能机器人,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
||||||
|
zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
|
||||||
icon: wecombot.png
|
icon: wecombot.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/wecombot
|
||||||
|
en: https://link.langbot.app/en/platforms/wecombot
|
||||||
|
ja: https://link.langbot.app/ja/platforms/wecombot
|
||||||
config:
|
config:
|
||||||
- name: BotId
|
- name: BotId
|
||||||
label:
|
label:
|
||||||
en_US: BotId
|
en_US: BotId
|
||||||
zh_Hans: 机器人ID (BotId)
|
zh_Hans: 机器人ID (BotId)
|
||||||
|
zh_Hant: 機器人ID (BotId)
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +31,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Robot Name
|
en_US: Robot Name
|
||||||
zh_Hans: 机器人名称
|
zh_Hans: 机器人名称
|
||||||
|
zh_Hant: 機器人名稱
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,19 +39,39 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Webhook Mode
|
en_US: Enable Webhook Mode
|
||||||
zh_Hans: 启用Webhook模式
|
zh_Hans: 启用Webhook模式
|
||||||
|
zh_Hant: 啟用 Webhook 模式
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
||||||
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
||||||
|
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
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 WeComBot webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到企业微信智能机器人的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到企業微信智慧機器人的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: enable-webhook
|
||||||
|
operator: eq
|
||||||
|
value: true
|
||||||
- name: Secret
|
- name: Secret
|
||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
zh_Hans: 机器人密钥 (Secret)
|
zh_Hans: 机器人密钥 (Secret)
|
||||||
|
zh_Hant: 機器人密鑰 (Secret)
|
||||||
description:
|
description:
|
||||||
en_US: Required for WebSocket long connection mode
|
en_US: Required for WebSocket long connection mode
|
||||||
zh_Hans: 使用 WS 长连接模式时必填
|
zh_Hans: 使用 WS 长连接模式时必填
|
||||||
|
zh_Hant: 使用 WS 長連線模式時必填
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
@@ -49,9 +79,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
zh_Hans: 企业ID
|
zh_Hans: 企业ID
|
||||||
|
zh_Hant: 企業ID
|
||||||
description:
|
description:
|
||||||
en_US: Required for Webhook mode
|
en_US: Required for Webhook mode
|
||||||
zh_Hans: 使用 Webhook 模式时必填
|
zh_Hans: 使用 Webhook 模式时必填
|
||||||
|
zh_Hant: 使用 Webhook 模式時必填
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
@@ -59,9 +91,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌 (Token)
|
zh_Hans: 令牌 (Token)
|
||||||
|
zh_Hant: 令牌 (Token)
|
||||||
description:
|
description:
|
||||||
en_US: Required for Webhook mode
|
en_US: Required for Webhook mode
|
||||||
zh_Hans: 使用 Webhook 模式时必填
|
zh_Hans: 使用 Webhook 模式时必填
|
||||||
|
zh_Hant: 使用 Webhook 模式時必填
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
@@ -69,9 +103,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||||
|
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
|
||||||
description:
|
description:
|
||||||
en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
|
en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
|
||||||
zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
|
zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
|
||||||
|
zh_Hant: 使用 Webhook 模式時必填。WebSocket 模式下可選(用於檔案解密)
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
@@ -79,9 +115,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Stream Reply
|
en_US: Enable Stream Reply
|
||||||
zh_Hans: 启用流式回复
|
zh_Hans: 启用流式回复
|
||||||
|
zh_Hant: 啟用串流回覆
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use streaming mode to reply messages
|
en_US: If enabled, the bot will use streaming mode to reply messages
|
||||||
zh_Hans: 如果启用,机器人将使用流式模式回复消息
|
zh_Hans: 如果启用,机器人将使用流式模式回复消息
|
||||||
|
zh_Hant: 如果啟用,機器人將使用串流模式回覆訊息
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
|
|||||||
@@ -5,16 +5,37 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: WeComCustomerService
|
en_US: WeComCustomerService
|
||||||
zh_Hans: 企业微信客服
|
zh_Hans: 企业微信客服
|
||||||
|
zh_Hant: 企業微信客服
|
||||||
description:
|
description:
|
||||||
en_US: WeComCSAdapter
|
en_US: WeComCSAdapter
|
||||||
zh_Hans: 企业微信客服适配器
|
zh_Hans: 企业微信对外客服机器人,需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||||
|
zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||||
icon: wecom.png
|
icon: wecom.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/wecomcs
|
||||||
|
en: https://link.langbot.app/en/platforms/wecomcs
|
||||||
|
ja: https://link.langbot.app/ja/platforms/wecomcs
|
||||||
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 WeCom Customer Service webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到企业微信客服的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到企業微信客服的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: corpid
|
- name: corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
zh_Hans: 企业ID
|
zh_Hans: 企业ID
|
||||||
|
zh_Hant: 企業ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +43,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
zh_Hans: 密钥
|
zh_Hans: 密钥
|
||||||
|
zh_Hant: 密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +51,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +59,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥
|
zh_Hans: 消息加解密密钥
|
||||||
|
zh_Hant: 訊息加解密密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,9 +67,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
zh_Hans: API 基础 URL
|
zh_Hans: API 基础 URL
|
||||||
|
zh_Hant: API 基礎 URL
|
||||||
description:
|
description:
|
||||||
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
||||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档修改此项
|
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档修改此项
|
||||||
|
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API,可根據文件修改此項
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import io
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import typing
|
import typing
|
||||||
import os
|
import os
|
||||||
@@ -192,6 +195,30 @@ class PluginRuntimeConnector:
|
|||||||
|
|
||||||
return await self.handler.ping()
|
return await self.handler.ping()
|
||||||
|
|
||||||
|
def _extract_deps_metadata(
|
||||||
|
self,
|
||||||
|
file_bytes: bytes,
|
||||||
|
task_context: taskmgr.TaskContext | None,
|
||||||
|
):
|
||||||
|
"""Extract dependency count from requirements.txt inside plugin zip."""
|
||||||
|
if task_context is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.endswith('requirements.txt'):
|
||||||
|
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:
|
||||||
|
pass
|
||||||
|
|
||||||
async def install_plugin(
|
async def install_plugin(
|
||||||
self,
|
self,
|
||||||
install_source: PluginInstallSource,
|
install_source: PluginInstallSource,
|
||||||
@@ -201,23 +228,44 @@ class PluginRuntimeConnector:
|
|||||||
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']
|
||||||
|
self._extract_deps_metadata(file_bytes, task_context)
|
||||||
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']
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
elif install_source == PluginInstallSource.GITHUB:
|
elif install_source == PluginInstallSource.GITHUB:
|
||||||
# download and transfer file
|
# download and transfer file with streaming progress
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
trust_env=True,
|
trust_env=True,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
timeout=20,
|
timeout=60,
|
||||||
) as client:
|
) as client:
|
||||||
response = await client.get(
|
async with client.stream('GET', install_info['asset_url']) as response:
|
||||||
install_info['asset_url'],
|
response.raise_for_status()
|
||||||
)
|
total = int(response.headers.get('content-length', 0))
|
||||||
response.raise_for_status()
|
downloaded = 0
|
||||||
file_bytes = response.content
|
chunks: list[bytes] = []
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('downloading plugin package')
|
||||||
|
task_context.metadata['download_total'] = total
|
||||||
|
task_context.metadata['download_current'] = 0
|
||||||
|
task_context.metadata['download_speed'] = 0
|
||||||
|
|
||||||
|
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||||
|
chunks.append(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
|
||||||
|
if task_context is not None:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
task_context.metadata['download_current'] = downloaded
|
||||||
|
task_context.metadata['download_total'] = total
|
||||||
|
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
||||||
|
|
||||||
|
file_bytes = b''.join(chunks)
|
||||||
|
self._extract_deps_metadata(file_bytes, task_context)
|
||||||
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')
|
||||||
@@ -236,6 +284,11 @@ class PluginRuntimeConnector:
|
|||||||
if task_context is not None:
|
if task_context is not None:
|
||||||
task_context.trace(trace)
|
task_context.trace(trace)
|
||||||
|
|
||||||
|
# Forward structured metadata from runtime
|
||||||
|
metadata = ret.get('metadata', None)
|
||||||
|
if metadata is not None and task_context is not None:
|
||||||
|
task_context.metadata.update(metadata)
|
||||||
|
|
||||||
async def upgrade_plugin(
|
async def upgrade_plugin(
|
||||||
self,
|
self,
|
||||||
plugin_author: str,
|
plugin_author: str,
|
||||||
|
|||||||
@@ -60,7 +60,16 @@ class TelemetryManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
sanitized['query_id'] = str(sanitized.get('query_id', ''))
|
sanitized['query_id'] = str(sanitized.get('query_id', ''))
|
||||||
|
|
||||||
for sfield in ('adapter', 'runner', 'runner_category', 'model_name', 'version', 'error', 'timestamp'):
|
for sfield in (
|
||||||
|
'adapter',
|
||||||
|
'runner',
|
||||||
|
'runner_category',
|
||||||
|
'model_name',
|
||||||
|
'version',
|
||||||
|
'edition',
|
||||||
|
'error',
|
||||||
|
'timestamp',
|
||||||
|
):
|
||||||
v = sanitized.get(sfield)
|
v = sanitized.get(sfield)
|
||||||
sanitized[sfield] = '' if v is None else str(v)
|
sanitized[sfield] = '' if v is None else str(v)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import langbot
|
|||||||
|
|
||||||
semantic_version = f'v{langbot.__version__}'
|
semantic_version = f'v{langbot.__version__}'
|
||||||
|
|
||||||
required_database_version = 24
|
required_database_version = 25
|
||||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||||
|
|
||||||
debug_mode = False
|
debug_mode = False
|
||||||
|
|||||||
@@ -38,28 +38,31 @@ def get_frontend_path() -> str:
|
|||||||
"""
|
"""
|
||||||
Get the path to the frontend build files.
|
Get the path to the frontend build files.
|
||||||
|
|
||||||
Returns the path to web/out directory, handling both:
|
Returns the path to web/dist directory (Vite build output), handling both:
|
||||||
- Development mode: running from source directory
|
- Development mode: running from source directory
|
||||||
- Package mode: installed via pip/uvx
|
- Package mode: installed via pip/uvx
|
||||||
|
- Legacy mode: web/out (Next.js, for backward compatibility)
|
||||||
"""
|
"""
|
||||||
# First, check if we're running from source directory
|
# Check both dist (Vite) and out (legacy Next.js) paths
|
||||||
if _check_if_source_install() and os.path.exists('web/out'):
|
for dirname in ('dist', 'out'):
|
||||||
return 'web/out'
|
web_dir = f'web/{dirname}'
|
||||||
|
|
||||||
# Second, check current directory for web/out (in case user is in source dir)
|
# First, check if we're running from source directory
|
||||||
if os.path.exists('web/out'):
|
if _check_if_source_install() and os.path.exists(web_dir):
|
||||||
return 'web/out'
|
return web_dir
|
||||||
|
|
||||||
# Third, find it relative to the package installation
|
# Second, check current directory
|
||||||
# Get the directory where this file is located
|
if os.path.exists(web_dir):
|
||||||
# paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root
|
return web_dir
|
||||||
pkg_dir = Path(__file__).parent.parent.parent
|
|
||||||
frontend_path = pkg_dir / 'web' / 'out'
|
# Third, find it relative to the package installation
|
||||||
if frontend_path.exists():
|
pkg_dir = Path(__file__).parent.parent.parent
|
||||||
return str(frontend_path)
|
frontend_path = pkg_dir / 'web' / dirname
|
||||||
|
if frontend_path.exists():
|
||||||
|
return str(frontend_path)
|
||||||
|
|
||||||
# Return the default path (will be checked by caller)
|
# Return the default path (will be checked by caller)
|
||||||
return 'web/out'
|
return 'web/dist'
|
||||||
|
|
||||||
|
|
||||||
def get_resource_path(resource: str) -> str:
|
def get_resource_path(resource: str) -> str:
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class VersionManager:
|
|||||||
try:
|
try:
|
||||||
if await self.ap.ver_mgr.is_new_version_available():
|
if await self.ap.ver_mgr.is_new_version_available():
|
||||||
return (
|
return (
|
||||||
'New version available:\n有新版本可用,根据文档更新: \nhttps://docs.langbot.app/zh/deploy/update.html',
|
'New version available:\n有新版本可用,根据文档更新: \nhttps://link.langbot.app/zh/docs/update',
|
||||||
logging.INFO,
|
logging.INFO,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ system:
|
|||||||
edition: community
|
edition: community
|
||||||
recovery_key: ''
|
recovery_key: ''
|
||||||
allow_modify_login_info: true
|
allow_modify_login_info: true
|
||||||
|
disabled_adapters: []
|
||||||
limitation:
|
limitation:
|
||||||
max_bots: -1
|
max_bots: -1
|
||||||
max_pipelines: -1
|
max_pipelines: -1
|
||||||
|
|||||||
@@ -23,30 +23,30 @@ stages:
|
|||||||
label:
|
label:
|
||||||
en_US: Local Agent
|
en_US: Local Agent
|
||||||
zh_Hans: 内置 Agent
|
zh_Hans: 内置 Agent
|
||||||
- name: tbox-app-api
|
|
||||||
label:
|
|
||||||
en_US: Tbox App API
|
|
||||||
zh_Hans: 蚂蚁百宝箱平台 API
|
|
||||||
- name: dify-service-api
|
- name: dify-service-api
|
||||||
label:
|
label:
|
||||||
en_US: Dify Service API
|
en_US: Dify Service API
|
||||||
zh_Hans: Dify 服务 API
|
zh_Hans: Dify 服务 API
|
||||||
- name: dashscope-app-api
|
|
||||||
label:
|
|
||||||
en_US: Aliyun Dashscope App API
|
|
||||||
zh_Hans: 阿里云百炼平台 API
|
|
||||||
- name: n8n-service-api
|
- name: n8n-service-api
|
||||||
label:
|
label:
|
||||||
en_US: n8n Workflow API
|
en_US: n8n Workflow API
|
||||||
zh_Hans: n8n 工作流 API
|
zh_Hans: n8n 工作流 API
|
||||||
- name: langflow-api
|
|
||||||
label:
|
|
||||||
en_US: Langflow API
|
|
||||||
zh_Hans: Langflow API
|
|
||||||
- name: coze-api
|
- name: coze-api
|
||||||
label:
|
label:
|
||||||
en_US: Coze API
|
en_US: Coze API
|
||||||
zh_Hans: 扣子 API
|
zh_Hans: 扣子 API
|
||||||
|
- name: tbox-app-api
|
||||||
|
label:
|
||||||
|
en_US: Tbox App API
|
||||||
|
zh_Hans: 蚂蚁百宝箱平台 API
|
||||||
|
- name: dashscope-app-api
|
||||||
|
label:
|
||||||
|
en_US: Aliyun Dashscope App API
|
||||||
|
zh_Hans: 阿里云百炼平台 API
|
||||||
|
- name: langflow-api
|
||||||
|
label:
|
||||||
|
en_US: Langflow API
|
||||||
|
zh_Hans: Langflow API
|
||||||
- name: local-agent
|
- name: local-agent
|
||||||
label:
|
label:
|
||||||
en_US: Local Agent
|
en_US: Local Agent
|
||||||
@@ -104,28 +104,6 @@ stages:
|
|||||||
field: __system.is_wizard
|
field: __system.is_wizard
|
||||||
operator: neq
|
operator: neq
|
||||||
value: true
|
value: true
|
||||||
- name: tbox-app-api
|
|
||||||
label:
|
|
||||||
en_US: Tbox App API
|
|
||||||
zh_Hans: 蚂蚁百宝箱平台 API
|
|
||||||
description:
|
|
||||||
en_US: Configure the Tbox App API of the pipeline
|
|
||||||
zh_Hans: 配置蚂蚁百宝箱平台 API
|
|
||||||
config:
|
|
||||||
- name: api-key
|
|
||||||
label:
|
|
||||||
en_US: API Key
|
|
||||||
zh_Hans: API 密钥
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: ''
|
|
||||||
- name: app-id
|
|
||||||
label:
|
|
||||||
en_US: App ID
|
|
||||||
zh_Hans: 应用 ID
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: ''
|
|
||||||
- name: dify-service-api
|
- name: dify-service-api
|
||||||
label:
|
label:
|
||||||
en_US: Dify Service API
|
en_US: Dify Service API
|
||||||
@@ -140,6 +118,11 @@ stages:
|
|||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
|
options:
|
||||||
|
- name: 'https://api.dify.ai/v1'
|
||||||
|
label:
|
||||||
|
en_US: Dify Cloud
|
||||||
|
zh_Hans: Dify 云服务
|
||||||
default: 'https://api.dify.ai/v1'
|
default: 'https://api.dify.ai/v1'
|
||||||
- name: base-prompt
|
- name: base-prompt
|
||||||
label:
|
label:
|
||||||
@@ -178,54 +161,6 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: 'your-api-key'
|
default: 'your-api-key'
|
||||||
- name: dashscope-app-api
|
|
||||||
label:
|
|
||||||
en_US: Aliyun Dashscope App API
|
|
||||||
zh_Hans: 阿里云百炼平台 API
|
|
||||||
description:
|
|
||||||
en_US: Configure the Aliyun Dashscope App API of the pipeline
|
|
||||||
zh_Hans: 配置阿里云百炼平台 API
|
|
||||||
config:
|
|
||||||
- name: app-type
|
|
||||||
label:
|
|
||||||
en_US: App Type
|
|
||||||
zh_Hans: 应用类型
|
|
||||||
type: select
|
|
||||||
required: true
|
|
||||||
default: agent
|
|
||||||
options:
|
|
||||||
- name: agent
|
|
||||||
label:
|
|
||||||
en_US: Agent
|
|
||||||
zh_Hans: Agent
|
|
||||||
- name: workflow
|
|
||||||
label:
|
|
||||||
en_US: Workflow
|
|
||||||
zh_Hans: 工作流
|
|
||||||
- name: api-key
|
|
||||||
label:
|
|
||||||
en_US: API Key
|
|
||||||
zh_Hans: API 密钥
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: 'your-api-key'
|
|
||||||
- name: app-id
|
|
||||||
label:
|
|
||||||
en_US: App ID
|
|
||||||
zh_Hans: 应用 ID
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: 'your-app-id'
|
|
||||||
- name: references_quote
|
|
||||||
label:
|
|
||||||
en_US: References Quote
|
|
||||||
zh_Hans: 引用文本
|
|
||||||
description:
|
|
||||||
en_US: The text prompt when the references are included
|
|
||||||
zh_Hans: 包含引用资料时的文本提示
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: '参考资料来自:'
|
|
||||||
- name: n8n-service-api
|
- name: n8n-service-api
|
||||||
label:
|
label:
|
||||||
en_US: n8n Workflow API
|
en_US: n8n Workflow API
|
||||||
@@ -375,6 +310,140 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: 'response'
|
default: 'response'
|
||||||
|
- name: coze-api
|
||||||
|
label:
|
||||||
|
en_US: coze API
|
||||||
|
zh_Hans: 扣子 API
|
||||||
|
description:
|
||||||
|
en_US: Configure the Coze API of the pipeline
|
||||||
|
zh_Hans: 配置Coze API
|
||||||
|
config:
|
||||||
|
- name: api-key
|
||||||
|
label:
|
||||||
|
en_US: API Key
|
||||||
|
zh_Hans: API 密钥
|
||||||
|
description:
|
||||||
|
en_US: The API key for the Coze server
|
||||||
|
zh_Hans: Coze服务器的 API 密钥
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ''
|
||||||
|
- name: bot-id
|
||||||
|
label:
|
||||||
|
en_US: Bot ID
|
||||||
|
zh_Hans: 机器人 ID
|
||||||
|
description:
|
||||||
|
en_US: The ID of the bot to run
|
||||||
|
zh_Hans: 要运行的机器人 ID
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ''
|
||||||
|
- name: api-base
|
||||||
|
label:
|
||||||
|
en_US: API Base URL
|
||||||
|
zh_Hans: API 基础 URL
|
||||||
|
description:
|
||||||
|
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
|
||||||
|
zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com)
|
||||||
|
type: string
|
||||||
|
options:
|
||||||
|
- name: 'https://api.coze.cn'
|
||||||
|
label:
|
||||||
|
en_US: Coze China
|
||||||
|
zh_Hans: Coze 中国版
|
||||||
|
- name: 'https://api.coze.com'
|
||||||
|
label:
|
||||||
|
en_US: Coze Global
|
||||||
|
zh_Hans: Coze 全球版
|
||||||
|
default: "https://api.coze.cn"
|
||||||
|
- name: auto-save-history
|
||||||
|
label:
|
||||||
|
en_US: Auto Save History
|
||||||
|
zh_Hans: 自动保存历史
|
||||||
|
description:
|
||||||
|
en_US: Whether to automatically save conversation history
|
||||||
|
zh_Hans: 是否自动保存对话历史
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
- name: timeout
|
||||||
|
label:
|
||||||
|
en_US: Request Timeout
|
||||||
|
zh_Hans: 请求超时
|
||||||
|
description:
|
||||||
|
en_US: Timeout in seconds for API requests
|
||||||
|
zh_Hans: API 请求超时时间(秒)
|
||||||
|
type: number
|
||||||
|
default: 120
|
||||||
|
- name: tbox-app-api
|
||||||
|
label:
|
||||||
|
en_US: Tbox App API
|
||||||
|
zh_Hans: 蚂蚁百宝箱平台 API
|
||||||
|
description:
|
||||||
|
en_US: Configure the Tbox App API of the pipeline
|
||||||
|
zh_Hans: 配置蚂蚁百宝箱平台 API
|
||||||
|
config:
|
||||||
|
- name: api-key
|
||||||
|
label:
|
||||||
|
en_US: API Key
|
||||||
|
zh_Hans: API 密钥
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ''
|
||||||
|
- name: app-id
|
||||||
|
label:
|
||||||
|
en_US: App ID
|
||||||
|
zh_Hans: 应用 ID
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ''
|
||||||
|
- name: dashscope-app-api
|
||||||
|
label:
|
||||||
|
en_US: Aliyun Dashscope App API
|
||||||
|
zh_Hans: 阿里云百炼平台 API
|
||||||
|
description:
|
||||||
|
en_US: Configure the Aliyun Dashscope App API of the pipeline
|
||||||
|
zh_Hans: 配置阿里云百炼平台 API
|
||||||
|
config:
|
||||||
|
- name: app-type
|
||||||
|
label:
|
||||||
|
en_US: App Type
|
||||||
|
zh_Hans: 应用类型
|
||||||
|
type: select
|
||||||
|
required: true
|
||||||
|
default: agent
|
||||||
|
options:
|
||||||
|
- name: agent
|
||||||
|
label:
|
||||||
|
en_US: Agent
|
||||||
|
zh_Hans: Agent
|
||||||
|
- name: workflow
|
||||||
|
label:
|
||||||
|
en_US: Workflow
|
||||||
|
zh_Hans: 工作流
|
||||||
|
- name: api-key
|
||||||
|
label:
|
||||||
|
en_US: API Key
|
||||||
|
zh_Hans: API 密钥
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: 'your-api-key'
|
||||||
|
- name: app-id
|
||||||
|
label:
|
||||||
|
en_US: App ID
|
||||||
|
zh_Hans: 应用 ID
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: 'your-app-id'
|
||||||
|
- name: references_quote
|
||||||
|
label:
|
||||||
|
en_US: References Quote
|
||||||
|
zh_Hans: 引用文本
|
||||||
|
description:
|
||||||
|
en_US: The text prompt when the references are included
|
||||||
|
zh_Hans: 包含引用资料时的文本提示
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: '参考资料来自:'
|
||||||
- name: langflow-api
|
- name: langflow-api
|
||||||
label:
|
label:
|
||||||
en_US: Langflow API
|
en_US: Langflow API
|
||||||
@@ -443,58 +512,3 @@ stages:
|
|||||||
type: json
|
type: json
|
||||||
required: false
|
required: false
|
||||||
default: '{}'
|
default: '{}'
|
||||||
- name: coze-api
|
|
||||||
label:
|
|
||||||
en_US: coze API
|
|
||||||
zh_Hans: 扣子 API
|
|
||||||
description:
|
|
||||||
en_US: Configure the Coze API of the pipeline
|
|
||||||
zh_Hans: 配置Coze API
|
|
||||||
config:
|
|
||||||
- name: api-key
|
|
||||||
label:
|
|
||||||
en_US: API Key
|
|
||||||
zh_Hans: API 密钥
|
|
||||||
description:
|
|
||||||
en_US: The API key for the Coze server
|
|
||||||
zh_Hans: Coze服务器的 API 密钥
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: ''
|
|
||||||
- name: bot-id
|
|
||||||
label:
|
|
||||||
en_US: Bot ID
|
|
||||||
zh_Hans: 机器人 ID
|
|
||||||
description:
|
|
||||||
en_US: The ID of the bot to run
|
|
||||||
zh_Hans: 要运行的机器人 ID
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: ''
|
|
||||||
- name: api-base
|
|
||||||
label:
|
|
||||||
en_US: API Base URL
|
|
||||||
zh_Hans: API 基础 URL
|
|
||||||
description:
|
|
||||||
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
|
|
||||||
zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com)
|
|
||||||
type: string
|
|
||||||
default: "https://api.coze.cn"
|
|
||||||
- name: auto-save-history
|
|
||||||
label:
|
|
||||||
en_US: Auto Save History
|
|
||||||
zh_Hans: 自动保存历史
|
|
||||||
description:
|
|
||||||
en_US: Whether to automatically save conversation history
|
|
||||||
zh_Hans: 是否自动保存对话历史
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
- name: timeout
|
|
||||||
label:
|
|
||||||
en_US: Request Timeout
|
|
||||||
zh_Hans: 请求超时
|
|
||||||
description:
|
|
||||||
en_US: Timeout in seconds for API requests
|
|
||||||
zh_Hans: API 请求超时时间(秒)
|
|
||||||
type: number
|
|
||||||
default: 120
|
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""
|
||||||
|
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchOperator:
|
||||||
|
"""Test the _match_operator static method."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_class():
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
return RuntimeBot
|
||||||
|
|
||||||
|
def test_eq(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'eq', 'hello') is True
|
||||||
|
assert cls._match_operator('hello', 'eq', 'world') is False
|
||||||
|
|
||||||
|
def test_neq(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'neq', 'world') is True
|
||||||
|
assert cls._match_operator('hello', 'neq', 'hello') is False
|
||||||
|
|
||||||
|
def test_contains(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello world', 'contains', 'world') is True
|
||||||
|
assert cls._match_operator('hello world', 'contains', 'xyz') is False
|
||||||
|
|
||||||
|
def test_not_contains(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello world', 'not_contains', 'xyz') is True
|
||||||
|
assert cls._match_operator('hello world', 'not_contains', 'world') is False
|
||||||
|
|
||||||
|
def test_starts_with(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello world', 'starts_with', 'hello') is True
|
||||||
|
assert cls._match_operator('hello world', 'starts_with', 'world') is False
|
||||||
|
|
||||||
|
def test_regex(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello123', 'regex', r'\d+') is True
|
||||||
|
assert cls._match_operator('hello', 'regex', r'\d+') is False
|
||||||
|
|
||||||
|
def test_regex_invalid_pattern(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'regex', r'[invalid') is False
|
||||||
|
|
||||||
|
def test_unknown_operator(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'unknown_op', 'hello') is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolvePipelineUuid:
|
||||||
|
"""Test the resolve_pipeline_uuid method."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_bot(default_pipeline: str, rules: list):
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
bot_entity = Mock()
|
||||||
|
bot_entity.use_pipeline_uuid = default_pipeline
|
||||||
|
bot_entity.pipeline_routing_rules = rules
|
||||||
|
|
||||||
|
bot = object.__new__(RuntimeBot)
|
||||||
|
bot.bot_entity = bot_entity
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def test_no_rules_returns_default(self):
|
||||||
|
bot = self._make_bot('default-uuid', [])
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_none_rules_returns_default(self):
|
||||||
|
bot = self._make_bot('default-uuid', None)
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_launcher_type_match(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'group',
|
||||||
|
'pipeline_uuid': 'group-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
|
||||||
|
assert uuid == 'group-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_launcher_id_match(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_id',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': '12345',
|
||||||
|
'pipeline_uuid': 'vip-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '12345', 'hi')
|
||||||
|
assert uuid == 'vip-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '99999', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_content_contains(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_content',
|
||||||
|
'operator': 'contains',
|
||||||
|
'value': '紧急',
|
||||||
|
'pipeline_uuid': 'urgent-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '这是紧急消息')
|
||||||
|
assert uuid == 'urgent-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '普通消息')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_content_regex(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_content',
|
||||||
|
'operator': 'regex',
|
||||||
|
'value': r'^/admin\b',
|
||||||
|
'pipeline_uuid': 'admin-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '/admin help')
|
||||||
|
assert uuid == 'admin-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hello /admin')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_has_element_eq(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_has_element',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'Image',
|
||||||
|
'pipeline_uuid': 'image-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
|
||||||
|
assert uuid == 'image-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_has_element_neq(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_has_element',
|
||||||
|
'operator': 'neq',
|
||||||
|
'value': 'Image',
|
||||||
|
'pipeline_uuid': 'text-only-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
|
||||||
|
assert uuid == 'text-only-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_has_element_no_types_provided(self):
|
||||||
|
"""When element types are not provided, should not match."""
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_has_element',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'Image',
|
||||||
|
'pipeline_uuid': 'image-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_first_match_wins(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'group',
|
||||||
|
'pipeline_uuid': 'first-pipeline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'group',
|
||||||
|
'pipeline_uuid': 'second-pipeline',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
|
||||||
|
assert uuid == 'first-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
def test_skip_invalid_rules(self):
|
||||||
|
rules = [
|
||||||
|
{'type': '', 'operator': 'eq', 'value': 'x', 'pipeline_uuid': 'p1'},
|
||||||
|
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': ''},
|
||||||
|
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': 'valid'},
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'valid'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
def test_default_operator_is_eq(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'value': 'person',
|
||||||
|
'pipeline_uuid': 'person-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'person-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
def test_discard_pipeline(self):
|
||||||
|
"""When pipeline_uuid is __discard__, the message should be discarded."""
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_content',
|
||||||
|
'operator': 'contains',
|
||||||
|
'value': 'spam',
|
||||||
|
'pipeline_uuid': RuntimeBot.PIPELINE_DISCARD,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'this is spam')
|
||||||
|
assert uuid == RuntimeBot.PIPELINE_DISCARD
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
@@ -186,6 +186,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.18.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -1832,7 +1846,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.4"
|
version = "4.9.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -1840,6 +1854,7 @@ dependencies = [
|
|||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aioshutil" },
|
{ name = "aioshutil" },
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "alembic" },
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "async-lru" },
|
{ name = "async-lru" },
|
||||||
@@ -1919,6 +1934,7 @@ requires-dist = [
|
|||||||
{ name = "aiohttp", specifier = ">=3.11.18" },
|
{ name = "aiohttp", specifier = ">=3.11.18" },
|
||||||
{ name = "aioshutil", specifier = ">=1.5" },
|
{ name = "aioshutil", specifier = ">=1.5" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
||||||
|
{ name = "alembic", specifier = ">=1.15.0" },
|
||||||
{ name = "anthropic", specifier = ">=0.51.0" },
|
{ name = "anthropic", specifier = ">=0.51.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=23.1.0" },
|
{ name = "argon2-cffi", specifier = ">=23.1.0" },
|
||||||
{ name = "async-lru", specifier = ">=2.0.5" },
|
{ name = "async-lru", specifier = ">=2.0.5" },
|
||||||
@@ -1937,7 +1953,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.3.5" },
|
{ name = "langbot-plugin", specifier = "==0.3.8" },
|
||||||
{ name = "langchain", specifier = ">=0.2.0" },
|
{ name = "langchain", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||||
@@ -1993,7 +2009,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.3.5"
|
version = "0.3.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2011,9 +2027,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/8f/0a22e4461b0893ac2afb1b6aaebafe04c921df6dbbf4b8bd6c83cf6a97b2/langbot_plugin-0.3.5.tar.gz", hash = "sha256:79c7feb08f788f480435de8cdefc3cfed4de2dfb03978a460251b8c9d1c271d3", size = 171927, upload-time = "2026-03-25T13:53:18.334Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b8/d8/7c8ac9516e35d69ead3e934b408e48541f5772eb88fbed19cd216af4b6c2/langbot_plugin-0.3.8.tar.gz", hash = "sha256:e8e420c3b2f167c9635e3e0af46fb452895be9d68ec05bf112ac5f221c3316f3", size = 179803, upload-time = "2026-04-10T11:05:42.791Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/93/fdd4eb54434a358a3917aec74190e2e1b64351a5bb955677f634d29fc4fd/langbot_plugin-0.3.5-py3-none-any.whl", hash = "sha256:4d31f92338e1e2dc343ae00982e4facbe7abae84f4d1c4e1375cdcac9d7155d7", size = 146575, upload-time = "2026-03-25T13:53:16.987Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/63/4a61b67d4886522647e0b60063da155279b943a6b2e6cd004e29aedf67d1/langbot_plugin-0.3.8-py3-none-any.whl", hash = "sha256:2246f343b4735cb4004cf44462ffb47531222c21efeef163a4acd758ebbec2cd", size = 157354, upload-time = "2026-04-10T11:05:41.525Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2409,6 +2425,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.3.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown"
|
name = "markdown"
|
||||||
version = "3.10.1"
|
version = "3.10.1"
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:5300
|
VITE_API_BASE_URL=http://localhost:5300
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
|
/dist/
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Debug LangBot Frontend
|
# Debug LangBot Frontend
|
||||||
|
|
||||||
Please refer to the [Development Guide](https://docs.langbot.app/en/develop/dev-config.html) for more information.
|
Please refer to the [Development Guide](https://link.langbot.app/en/docs/dev-config) for more information.
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "",
|
||||||
|
|||||||
+20
-11
@@ -1,18 +1,27 @@
|
|||||||
import { dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { FlatCompat } from '@eslint/eslintrc';
|
|
||||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
import tseslint from 'typescript-eslint';
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
eslintPluginPrettierRecommended,
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
sed -i 's/children={<HomePage \/>} />\n <HomePage \/>\n <\/HomeLayout>/g' src/router.tsx
|
||||||
|
# well it's easier to recreate router.tsx
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>LangBot</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Production-grade platform for building agentic IM bots"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /root/.openclaw/workspace/coding/projects/LangBot/web
|
||||||
|
|
||||||
|
# Find and replace next/navigation
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
|
||||||
|
-e "s/import {.*useRouter.*} from 'next\/navigation'/import { useNavigate } from 'react-router-dom'/g" \
|
||||||
|
-e "s/import {.*usePathname.*} from 'next\/navigation'/import { useLocation } from 'react-router-dom'/g" \
|
||||||
|
-e "s/import {.*useSearchParams.*} from 'next\/navigation'/import { useSearchParams } from 'react-router-dom'/g" \
|
||||||
|
-e "s/const router = useRouter()/const navigate = useNavigate()/g" \
|
||||||
|
-e "s/router\.push(/navigate(/g" \
|
||||||
|
-e "s/router\.replace(/navigate(/g" \
|
||||||
|
-e "s/router\.back()/navigate(-1)/g" \
|
||||||
|
-e "s/router\.refresh()/navigate(0)/g" \
|
||||||
|
-e "s/const pathname = usePathname()/const location = useLocation();\n const pathname = location.pathname;/g" \
|
||||||
|
-e "s/usePathname()/useLocation().pathname/g" \
|
||||||
|
{} +
|
||||||
|
|
||||||
|
# Note: useSearchParams returns a tuple in react-router-dom. This might need manual fix depending on usage.
|
||||||
|
|
||||||
|
# Replace next/link
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
|
||||||
|
-e "s/import Link from 'next\/link'/import { Link } from 'react-router-dom'/g" \
|
||||||
|
-e "s/<Link href=/<Link to=/g" \
|
||||||
|
{} +
|
||||||
|
|
||||||
|
# Remove 'use client'
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i "s/'use client';//g" {} +
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i 's/"use client";//g' {} +
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { NextConfig } from 'next';
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
output: 'export',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
Generated
+891
-3449
File diff suppressed because it is too large
Load Diff
+12
-11
@@ -3,16 +3,15 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc && vite build",
|
||||||
"start": "next start",
|
"preview": "vite preview",
|
||||||
"lint": "eslint src",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint src --fix",
|
"format": "prettier --write ."
|
||||||
"lint-staged": "lint-staged"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"next lint --fix",
|
"eslint --fix",
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -34,6 +33,7 @@
|
|||||||
"@radix-ui/react-hover-card": "^1.1.13",
|
"@radix-ui/react-hover-card": "^1.1.13",
|
||||||
"@radix-ui/react-label": "^2.1.6",
|
"@radix-ui/react-label": "^2.1.6",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.4",
|
"@radix-ui/react-select": "^2.2.4",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tailwindcss/postcss": "^4.1.5",
|
"@tailwindcss/postcss": "^4.1.5",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -54,8 +55,6 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lucide-react": "^0.507.0",
|
"lucide-react": "^0.507.0",
|
||||||
"next": "~16.1.5",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
@@ -64,6 +63,7 @@
|
|||||||
"react-i18next": "^15.5.1",
|
"react-i18next": "^15.5.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-photo-view": "^1.2.7",
|
"react-photo-view": "^1.2.7",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
@@ -76,10 +76,10 @@
|
|||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.5",
|
"tailwindcss": "^4.1.5",
|
||||||
"uuidjs": "^5.1.0",
|
"uuidjs": "^5.1.0",
|
||||||
|
"vite": "^8.0.3",
|
||||||
"zod": "^3.24.4"
|
"zod": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/estree": "^1.0.8",
|
"@types/estree": "^1.0.8",
|
||||||
"@types/estree-jsx": "^1.0.5",
|
"@types/estree-jsx": "^1.0.5",
|
||||||
@@ -94,9 +94,10 @@
|
|||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/unist": "^3.0.3",
|
"@types/unist": "^3.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.2.4",
|
|
||||||
"eslint-config-prettier": "^10.1.2",
|
"eslint-config-prettier": "^10.1.2",
|
||||||
"eslint-plugin-prettier": "^5.2.6",
|
"eslint-plugin-prettier": "^5.2.6",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"lint-staged": "^15.5.1",
|
"lint-staged": "^15.5.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"tw-animate-css": "^1.2.9",
|
"tw-animate-css": "^1.2.9",
|
||||||
|
|||||||
Generated
+587
-1352
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, Suspense } from 'react';
|
import { useEffect, useState, useCallback, Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -23,8 +21,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
|||||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||||
|
|
||||||
function SpaceOAuthCallbackContent() {
|
function SpaceOAuthCallbackContent() {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [status, setStatus] = useState<
|
const [status, setStatus] = useState<
|
||||||
@@ -51,7 +49,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
const wizardState = localStorage.getItem('langbot_wizard_state');
|
const wizardState = localStorage.getItem('langbot_wizard_state');
|
||||||
const redirectTo = wizardState ? '/wizard' : '/home';
|
const redirectTo = wizardState ? '/wizard' : '/home';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(redirectTo);
|
navigate(redirectTo);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -64,7 +62,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, t],
|
[navigate, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [bindState, setBindState] = useState<string | null>(null);
|
const [bindState, setBindState] = useState<string | null>(null);
|
||||||
@@ -81,7 +79,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
setStatus('success');
|
setStatus('success');
|
||||||
toast.success(t('account.bindSpaceSuccess'));
|
toast.success(t('account.bindSpaceSuccess'));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/home');
|
navigate('/home');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -96,7 +94,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, t],
|
[navigate, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,7 +144,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelBind = () => {
|
const handleCancelBind = () => {
|
||||||
router.push('/home');
|
navigate('/home');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,7 +152,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
|
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<img
|
<img
|
||||||
src={langbotIcon.src}
|
src={langbotIcon}
|
||||||
alt="LangBot"
|
alt="LangBot"
|
||||||
className="w-16 h-16 mb-4 mx-auto"
|
className="w-16 h-16 mb-4 mx-auto"
|
||||||
/>
|
/>
|
||||||
@@ -217,7 +215,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
<>
|
<>
|
||||||
<AlertCircle className="h-12 w-12 text-red-500" />
|
<AlertCircle className="h-12 w-12 text-red-500" />
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push(isBindMode ? '/home' : '/login')}
|
onClick={() => navigate(isBindMode ? '/home' : '/login')}
|
||||||
className="w-full mt-4"
|
className="w-full mt-4"
|
||||||
>
|
>
|
||||||
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}
|
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
:root {
|
:root {
|
||||||
/* 适用于 Firefox 的滚动条 */
|
/* 适用于 Firefox 的滚动条 */
|
||||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
||||||
@@ -72,10 +74,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@import 'tailwindcss';
|
|
||||||
|
|
||||||
@import 'tw-animate-css';
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -34,7 +32,7 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export default function BotDetailContent({ id }: { id: string }) {
|
export default function BotDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
||||||
|
|
||||||
@@ -105,12 +103,12 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleBotDeleted() {
|
function handleBotDeleted() {
|
||||||
refreshBots();
|
refreshBots();
|
||||||
router.push('/home/bots');
|
navigate('/home/bots');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewBotCreated(newBotId: string) {
|
function handleNewBotCreated(newBotId: string) {
|
||||||
refreshBots();
|
refreshBots();
|
||||||
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
@@ -176,9 +174,11 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
{activeTab === 'config' && (
|
||||||
{t('common.save')}
|
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
||||||
</Button>
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Horizontal Tabs */}
|
{/* Horizontal Tabs */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import i18n from 'i18next';
|
||||||
import {
|
import {
|
||||||
IChooseAdapterEntity,
|
IChooseAdapterEntity,
|
||||||
IPipelineEntity,
|
IPipelineEntity,
|
||||||
@@ -13,15 +14,16 @@ import { UUID } from 'uuidjs';
|
|||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Bot } from '@/app/infra/entities/api';
|
import { Bot } from '@/app/infra/entities/api';
|
||||||
|
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import RoutingRulesEditor from './RoutingRulesEditor';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Copy, Check } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -37,6 +39,7 @@ import {
|
|||||||
SelectContent,
|
SelectContent,
|
||||||
SelectGroup,
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
@@ -49,6 +52,10 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { CustomApiError } from '@/app/infra/entities/common';
|
import { CustomApiError } from '@/app/infra/entities/common';
|
||||||
|
import {
|
||||||
|
groupByCategory,
|
||||||
|
getCategoryLabel,
|
||||||
|
} from '@/app/infra/entities/adapter-categories';
|
||||||
|
|
||||||
const getFormSchema = (t: (key: string) => string) =>
|
const getFormSchema = (t: (key: string) => string) =>
|
||||||
z.object({
|
z.object({
|
||||||
@@ -58,6 +65,28 @@ const getFormSchema = (t: (key: string) => string) =>
|
|||||||
adapter_config: z.record(z.string(), z.any()),
|
adapter_config: z.record(z.string(), z.any()),
|
||||||
enable: z.boolean().optional(),
|
enable: z.boolean().optional(),
|
||||||
use_pipeline_uuid: z.string().optional(),
|
use_pipeline_uuid: z.string().optional(),
|
||||||
|
pipeline_routing_rules: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
type: z.enum([
|
||||||
|
'launcher_type',
|
||||||
|
'launcher_id',
|
||||||
|
'message_content',
|
||||||
|
'message_has_element',
|
||||||
|
]),
|
||||||
|
operator: z.enum([
|
||||||
|
'eq',
|
||||||
|
'neq',
|
||||||
|
'contains',
|
||||||
|
'not_contains',
|
||||||
|
'starts_with',
|
||||||
|
'regex',
|
||||||
|
]),
|
||||||
|
value: z.string(),
|
||||||
|
pipeline_uuid: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function BotForm({
|
export default function BotForm({
|
||||||
@@ -83,6 +112,7 @@ export default function BotForm({
|
|||||||
adapter_config: {},
|
adapter_config: {},
|
||||||
enable: true,
|
enable: true,
|
||||||
use_pipeline_uuid: '',
|
use_pipeline_uuid: '',
|
||||||
|
pipeline_routing_rules: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,6 +129,9 @@ export default function BotForm({
|
|||||||
const [adapterDescriptionList, setAdapterDescriptionList] = useState<
|
const [adapterDescriptionList, setAdapterDescriptionList] = useState<
|
||||||
Record<string, string>
|
Record<string, string>
|
||||||
>({});
|
>({});
|
||||||
|
const [adapterHelpLinks, setAdapterHelpLinks] = useState<
|
||||||
|
Record<string, Record<string, string>>
|
||||||
|
>({});
|
||||||
|
|
||||||
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
|
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
|
||||||
[],
|
[],
|
||||||
@@ -110,29 +143,16 @@ export default function BotForm({
|
|||||||
const [, setIsLoading] = useState<boolean>(false);
|
const [, setIsLoading] = useState<boolean>(false);
|
||||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||||
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||||
const [copied, setCopied] = useState<boolean>(false);
|
|
||||||
const [extraCopied, setExtraCopied] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Watch adapter and adapter_config for filtering
|
// Watch adapter and adapter_config for filtering
|
||||||
const currentAdapter = form.watch('adapter');
|
const currentAdapter = form.watch('adapter');
|
||||||
const currentAdapterConfig = form.watch('adapter_config');
|
const currentAdapterConfig = form.watch('adapter_config');
|
||||||
|
|
||||||
// Derive the filtered config list via useMemo instead of useEffect+setState
|
// Group adapters by category for the Select dropdown
|
||||||
// to avoid creating new array references that would cause DynamicFormComponent
|
const groupedAdapters = useMemo(
|
||||||
// to re-subscribe its form.watch, re-emit values, and trigger an infinite loop.
|
() => groupByCategory(adapterNameList),
|
||||||
// Only depend on the specific field we care about (enable-webhook) rather than
|
[adapterNameList],
|
||||||
// the entire currentAdapterConfig object, which changes on every emission.
|
);
|
||||||
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
|
|
||||||
const filteredDynamicFormConfigList = useMemo(() => {
|
|
||||||
if (currentAdapter === 'lark' && enableWebhook === false) {
|
|
||||||
// Hide encrypt-key field when webhook is disabled
|
|
||||||
return dynamicFormConfigList.filter(
|
|
||||||
(config) => config.name !== 'encrypt-key',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// For non-Lark adapters or when webhook is enabled/undefined, show all fields
|
|
||||||
return dynamicFormConfigList;
|
|
||||||
}, [currentAdapter, enableWebhook, dynamicFormConfigList]);
|
|
||||||
|
|
||||||
// Notify parent when dirty state changes
|
// Notify parent when dirty state changes
|
||||||
const { isDirty } = form.formState;
|
const { isDirty } = form.formState;
|
||||||
@@ -144,43 +164,6 @@ export default function BotForm({
|
|||||||
setBotFormValues();
|
setBotFormValues();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const copyToClipboard = (
|
|
||||||
text: string,
|
|
||||||
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
|
||||||
) => {
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(text)
|
|
||||||
.then(() => {
|
|
||||||
setStatus(true);
|
|
||||||
setTimeout(() => setStatus(false), 2000);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
fallbackCopy(text, setStatus);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
fallbackCopy(text, setStatus);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fallbackCopy = (
|
|
||||||
text: string,
|
|
||||||
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
|
||||||
) => {
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
const successful = document.execCommand('copy');
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
if (successful) {
|
|
||||||
setStatus(true);
|
|
||||||
setTimeout(() => setStatus(false), 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function setBotFormValues() {
|
function setBotFormValues() {
|
||||||
isInitializing.current = true;
|
isInitializing.current = true;
|
||||||
initBotFormComponent().then(() => {
|
initBotFormComponent().then(() => {
|
||||||
@@ -196,6 +179,7 @@ export default function BotForm({
|
|||||||
adapter_config: val.adapter_config,
|
adapter_config: val.adapter_config,
|
||||||
enable: val.enable,
|
enable: val.enable,
|
||||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
||||||
|
pipeline_routing_rules: val.pipeline_routing_rules || [],
|
||||||
});
|
});
|
||||||
handleAdapterSelect(val.adapter);
|
handleAdapterSelect(val.adapter);
|
||||||
|
|
||||||
@@ -241,6 +225,7 @@ export default function BotForm({
|
|||||||
return {
|
return {
|
||||||
label: extractI18nObject(item.label),
|
label: extractI18nObject(item.label),
|
||||||
value: item.name,
|
value: item.name,
|
||||||
|
categories: item.spec.categories,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -255,6 +240,18 @@ export default function BotForm({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
setAdapterHelpLinks(
|
||||||
|
adaptersRes.adapters.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
if (item.spec.help_links) {
|
||||||
|
acc[item.name] = item.spec.help_links;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, Record<string, string>>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
adaptersRes.adapters.forEach((rawAdapter) => {
|
adaptersRes.adapters.forEach((rawAdapter) => {
|
||||||
adapterNameToDynamicConfigMap.set(
|
adapterNameToDynamicConfigMap.set(
|
||||||
rawAdapter.name,
|
rawAdapter.name,
|
||||||
@@ -298,6 +295,7 @@ export default function BotForm({
|
|||||||
adapter_config: bot.adapter_config,
|
adapter_config: bot.adapter_config,
|
||||||
enable: bot.enable ?? true,
|
enable: bot.enable ?? true,
|
||||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
||||||
|
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
|
||||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||||
| string
|
| string
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -342,6 +340,7 @@ export default function BotForm({
|
|||||||
adapter_config: form.getValues().adapter_config,
|
adapter_config: form.getValues().adapter_config,
|
||||||
enable: form.getValues().enable,
|
enable: form.getValues().enable,
|
||||||
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
||||||
|
pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
|
||||||
};
|
};
|
||||||
httpClient
|
httpClient
|
||||||
.updateBot(initBotId, updateBot)
|
.updateBot(initBotId, updateBot)
|
||||||
@@ -384,12 +383,6 @@ export default function BotForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Webhook URL display helper ---
|
|
||||||
const showWebhook =
|
|
||||||
initBotId &&
|
|
||||||
webhookUrl &&
|
|
||||||
(currentAdapter !== 'lark' || enableWebhook !== false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@@ -498,6 +491,12 @@ export default function BotForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Pipeline Routing Rules */}
|
||||||
|
<RoutingRulesEditor
|
||||||
|
form={form}
|
||||||
|
pipelineNameList={pipelineNameList}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -521,48 +520,81 @@ export default function BotForm({
|
|||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">*</span>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<div className="flex items-center gap-2">
|
||||||
onValueChange={(value) => {
|
<Select
|
||||||
field.onChange(value);
|
onValueChange={(value) => {
|
||||||
handleAdapterSelect(value);
|
field.onChange(value);
|
||||||
}}
|
handleAdapterSelect(value);
|
||||||
value={field.value}
|
}}
|
||||||
>
|
value={field.value}
|
||||||
<SelectTrigger className="w-[240px]">
|
>
|
||||||
{field.value ? (
|
<SelectTrigger className="w-[240px]">
|
||||||
<div className="flex items-center gap-2">
|
{field.value ? (
|
||||||
<img
|
<div className="flex items-center gap-2">
|
||||||
src={httpClient.getAdapterIconURL(field.value)}
|
<img
|
||||||
alt=""
|
src={httpClient.getAdapterIconURL(field.value)}
|
||||||
className="h-5 w-5 rounded"
|
alt=""
|
||||||
|
className="h-5 w-5 rounded"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{adapterNameList.find(
|
||||||
|
(a) => a.value === field.value,
|
||||||
|
)?.label ?? field.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t('bots.selectAdapter')}
|
||||||
/>
|
/>
|
||||||
<span>
|
)}
|
||||||
{adapterNameList.find(
|
</SelectTrigger>
|
||||||
(a) => a.value === field.value,
|
<SelectContent>
|
||||||
)?.label ?? field.value}
|
{groupedAdapters.map((group) => (
|
||||||
</span>
|
<SelectGroup
|
||||||
</div>
|
key={group.categoryId ?? 'uncategorized'}
|
||||||
) : (
|
>
|
||||||
<SelectValue placeholder={t('bots.selectAdapter')} />
|
{group.categoryId && (
|
||||||
)}
|
<SelectLabel>
|
||||||
</SelectTrigger>
|
{getCategoryLabel(t, group.categoryId)}
|
||||||
<SelectContent>
|
</SelectLabel>
|
||||||
<SelectGroup>
|
)}
|
||||||
{adapterNameList.map((item) => (
|
{group.items.map((item) => (
|
||||||
<SelectItem key={item.value} value={item.value}>
|
<SelectItem key={item.value} value={item.value}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src={httpClient.getAdapterIconURL(item.value)}
|
src={httpClient.getAdapterIconURL(
|
||||||
alt=""
|
item.value,
|
||||||
className="h-5 w-5 rounded"
|
)}
|
||||||
/>
|
alt=""
|
||||||
<span>{item.label}</span>
|
className="h-5 w-5 rounded"
|
||||||
</div>
|
/>
|
||||||
</SelectItem>
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
))}
|
))}
|
||||||
</SelectGroup>
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
{currentAdapter &&
|
||||||
|
(() => {
|
||||||
|
const docUrl = getAdapterDocUrl(
|
||||||
|
adapterHelpLinks[currentAdapter],
|
||||||
|
i18n.language,
|
||||||
|
);
|
||||||
|
return docUrl ? (
|
||||||
|
<a
|
||||||
|
href={docUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t('bots.viewAdapterDocs')}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{currentAdapter && adapterDescriptionList[currentAdapter] && (
|
{currentAdapter && adapterDescriptionList[currentAdapter] && (
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -574,75 +606,19 @@ export default function BotForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Webhook URL: shown after adapter is selected (edit mode only) */}
|
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||||
{showWebhook && (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={webhookUrl}
|
|
||||||
readOnly
|
|
||||||
className="flex-1 bg-muted"
|
|
||||||
onClick={(e) => {
|
|
||||||
(e.target as HTMLInputElement).select();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => copyToClipboard(webhookUrl, setCopied)}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{extraWebhookUrl && (
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Input
|
|
||||||
value={extraWebhookUrl}
|
|
||||||
readOnly
|
|
||||||
className="flex-1 bg-muted"
|
|
||||||
onClick={(e) => {
|
|
||||||
(e.target as HTMLInputElement).select();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
copyToClipboard(extraWebhookUrl, setExtraCopied)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{extraCopied ? (
|
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FormDescription>
|
|
||||||
{extraWebhookUrl
|
|
||||||
? t('bots.webhookUrlHintEither')
|
|
||||||
: t('bots.webhookUrlHint')}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showDynamicForm && filteredDynamicFormConfigList.length > 0 && (
|
|
||||||
<DynamicFormComponent
|
<DynamicFormComponent
|
||||||
itemConfigList={filteredDynamicFormConfigList}
|
itemConfigList={dynamicFormConfigList}
|
||||||
initialValues={currentAdapterConfig}
|
initialValues={currentAdapterConfig}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
form.setValue('adapter_config', values, {
|
form.setValue('adapter_config', values, {
|
||||||
shouldDirty: !isInitializing.current,
|
shouldDirty: !isInitializing.current,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
systemContext={{
|
||||||
|
webhook_url: webhookUrl,
|
||||||
|
extra_webhook_url: extraWebhookUrl,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface IChooseAdapterEntity {
|
export interface IChooseAdapterEntity {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
categories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPipelineEntity {
|
export interface IPipelineEntity {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user