Compare commits

...

33 Commits

Author SHA1 Message Date
RockChinQ
d3d366b569 feat: update langbot-plugin to version 0.3.7 2026-04-06 17:09:26 +08:00
RockChinQ
db68c5d0c9 feat(human-takeover): add human customer service takeover feature
Add ability for admin operators to take over user conversation sessions
from the AI bot, manually reply as the bot, and release sessions back to
AI processing.

Backend:
- New HumanTakeoverSession DB model and migration (v26)
- HumanTakeoverService with in-memory cache for hot-path performance
- REST API endpoints for takeover/release/send-message/list/detail
- Message interception in botmgr.py (after webhook, before pipeline)

Frontend:
- Takeover/release controls in BotSessionMonitor chat header
- Operator message input bar with visual distinction (orange theme)
- Taken-over session indicators in session list
- 3-second auto-refresh polling during active takeover
- Full i18n coverage across all 7 locales
2026-04-04 23:47:46 +08:00
Junyan Qin
8f1317b39e feat(i18n): add routing rules translations for es-ES, ja-JP, th-TH, vi-VN, zh-Hant 2026-04-04 00:01:27 +08:00
Typer_Body
77a0de5ef0 Feat: bot message routing (#2100)
* refactor: pipeline routing rules - add routed_by_rule bypass and diagnostic logging

- Add routing rules editor (RoutingRulesEditor component)
- Add routed_by_rule bypass logic in response rules
- Add diagnostic logging for pipeline routing
- Database migration for bot pipeline routing rules
- Extract RoutingRulesEditor component from BotForm
- Revert log levels to debug

* feat: add message_has_element routing rule type

Support routing by message element type (Image, Voice, File, Forward,
Face, At, AtAll, Quote) with eq/neq operators.

* test: add unit tests for pipeline routing rules

20 tests covering _match_operator (eq/neq/contains/not_contains/
starts_with/regex/invalid) and resolve_pipeline_uuid (launcher_type/
launcher_id/message_content/message_has_element/first-match-wins/
skip-invalid/default-operator).

* fix(web): add missing 'message_has_element' to routing rule type validation

The Zod schema and TypeScript type for PipelineRoutingRule.type were
missing the 'message_has_element' variant, causing silent form validation
failure when saving routing rules with this type.

* feat: add pipeline discard functionality and localization support

* feat(web): improve drag-and-drop with DragOverlay, add discard monitoring and pipeline icons

- Add DragOverlay for smooth cursor-following drag in routing rules editor
- Remove transition to eliminate redundant swap animation on drop
- Record discarded messages in monitoring system via _record_discarded_message
- Display pipeline name (Workflow icon) and runner name (Play icon) on session monitor messages
- Show discard badge on discarded messages in session monitor
- Add i18n translations for discarded/userMessage/botMessage

* fix: ensure discarded messages appear in session monitor and improve icons

- Create/update monitoring session for discarded messages so they show in
  the bot session monitor (was only inserting message rows, not sessions)
- Use human-readable 'Discarded' as pipeline_name instead of '__discard__'
- Change runner icon from Play to Bot for better AI Agent semantics

* fix: merge discarded messages into same session and remove session-level pipeline name

- Use LauncherTypes enum for session_id in discarded messages to match
  the format used by monitoring_helper (fixes duplicate sessions)
- Don't overwrite session pipeline info on discard — a session can have
  messages from multiple pipelines
- Remove pipeline_name from session list and chat header since it's
  now shown per-message and a session is no longer single-pipeline

* fix(web): only show save button on config tab in bot detail page

* fix(web): scroll to bottom after messages render in session monitor

---------

Co-authored-by: RockChinQ <rockchinq@gmail.com>
2026-04-03 23:56:58 +08:00
Junyan Chin
875227a2fe feat: add tools API endpoint and tools-selector form type (#2103)
* feat: add tools API endpoint and tools-selector form type

Backend:
- Add GET /api/v1/tools — list all available tools (plugin + MCP)
- Add GET /api/v1/tools/<tool_name> — get specific tool details

Frontend:
- Add TOOLS_SELECTOR form type for plugin config forms
- Multi-select dialog with tool name and description
- Add PluginTool entity type and API client methods

* fix: remove unused quart import, fix prettier formatting

* style: ruff format tools.py

* chore: bump langbot-plugin to 0.3.7
2026-04-03 17:45:10 +08:00
Junyan Chin
2317392ee5 refactor(web): migrate from Next.js to Vite + React Router (#2102)
* refactor(web): migrate from Next.js to Vite + React Router

* fix: update build pipelines for Vite migration (out → dist)

- Dockerfile: npm run build → npx vite build, web/out → web/dist
- pyproject.toml: package-data web/out/** → web/dist/**
- paths.py: support both web/dist (Vite) and web/out (legacy) with fallback

* fix: remove .next from git tracking, add to .gitignore

1334 cached files from web/.next/ were accidentally committed.
Added .next/ to both root and web/.gitignore.

* fix: update build process to use Vite and correct output directory

* fix: update pnpm-lock.yaml and eslint config for Vite migration

* style: fix prettier formatting issues

* fix: add eslint-plugin-react-hooks for Vite migration

* fix: remove undefined eslint rule reference, downgrade react-hooks plugin to v5

* fix(web): clean up remaining Next.js artifacts in Vite migration

- Add vite-env.d.ts for import.meta.env and asset type declarations
- Remove dead layout.tsx (providers already in main.tsx)
- Fix useSearchParams destructuring to [searchParams] tuple (11 locations)
- Replace process.env.NEXT_PUBLIC_* with import.meta.env.VITE_*
- Fix langbotIcon.src to langbotIcon (Vite returns URL string)
- Fix Link href to Link to for react-router-dom
- Fix navigate({ scroll: false }) to { preventScrollReset: true }
- Fix [router] dependency arrays to [navigate]
- Remove Next.js plugin from tsconfig, set rsc: false in components.json
- Replace next lint with eslint in lint-staged

* feat: add tools API endpoint and tools-selector form type

Backend:
- Add GET /api/v1/tools — list all available tools (plugin + MCP)
- Add GET /api/v1/tools/<tool_name> — get specific tool details

Frontend:
- Add TOOLS_SELECTOR form type for plugin config forms
- Multi-select dialog with tool name and description
- Add PluginTool entity type and API client methods

* Revert "feat: add tools API endpoint and tools-selector form type"

This reverts commit 3c637fc563.
2026-04-03 17:09:17 +08:00
fdc310
c7efa4dd7f feat: add wecombot ws on_feedback (#2098)
* feat: add wecombot ws on_feedback

* feat:lark on_feedback but bug

* feat: Add lark feedback processing function and event handling logic
2026-04-03 15:03:41 +08:00
RockChinQ
e701daa8e0 style: fix ruff formatting in botmgr.py 2026-04-02 14:27:46 +08:00
RockChinQ
1ae99199b2 feat: support env var override for list config values
List-type config values can now be set via environment variables using
comma-separated strings. For example:
  SYSTEM__DISABLED_ADAPTERS=aiocqhttp,dingtalk

Previously list and dict types were both skipped; now only dict is skipped.
2026-04-02 13:59:07 +08:00
RockChinQ
7c067a1cb3 feat: support disabled_adapters list in system config
Adds 'system.disabled_adapters' config option (list of adapter names).
Disabled adapters are excluded from both the adapter registry and API
responses, preventing users from creating bots with those adapters.

Example config:
  system:
    disabled_adapters:
      - aiocqhttp
      - dingtalk
2026-04-02 13:59:07 +08:00
Guanchao Wang
478bc62576 Merge pull request #2096 from langbot-app/fix/wecomaibot_downfile_url
fix:Modify the file logic. After receiving it, instead of downloading…
2026-04-02 09:55:48 +08:00
fdc310
a740eb8ee9 fix:Modify the file logic. After receiving it, instead of downloading and converting it to base64, concatenate the aeskey to the end of the link and provide it for the plugin to handle. 2026-03-31 20:07:20 +08:00
Junyan Qin
f8aedd02b3 fix: update version to 4.9.5 and langbot-plugin to 0.3.6 in project files 2026-03-31 09:30:09 +08:00
Junyan Qin
ea638cab80 feat: add help links for message platform adapters in YAML and update documentation retrieval logic 2026-03-31 00:29:24 +08:00
Junyan Qin
7129dd536e style(web): change adapter doc button to link style with external link icon 2026-03-31 00:08:37 +08:00
Junyan Qin
1b1cc7769b style(web): move adapter doc link to icon button beside selector with tooltip 2026-03-31 00:06:15 +08:00
Junyan Qin
44b8354dfd fix(deps): update langbot-plugin version to 0.3.6 2026-03-30 23:59:55 +08:00
Junyan Qin
55ec9d11ae fix(web): add missing feedback i18n translations for zh-Hant, ja-JP, th-TH, vi-VN, es-ES 2026-03-30 23:56:40 +08:00
Junyan Qin
5b3d3801b5 refactor: clean up Dockerfile and .gitignore by removing unused entries 2026-03-30 23:46:12 +08:00
Typer_Body
9f1ea75d09 Update API base URL to localhost 2026-03-30 23:34:34 +08:00
6mvp6
6e37aae636 feat(wecom): add user feedback support for WeChat Work AI Bot (#2078)
* feat(wecom): add user feedback support for WeChat Work AI Bot

This commit implements user feedback functionality (like/dislike) for
WeChat Work AI Bot conversations, including:

Backend changes:
- Add feedback_id and stream_id fields to WecomBotEvent
- Implement feedback event handling in WecomBotClient (api.py)
- Add StreamSessionManager._feedback_index for feedback_id lookup
- Add on_feedback decorator for custom feedback handlers
- Create MonitoringFeedback entity for database persistence
- Add dbm025 migration for monitoring_feedback table
- Implement FeedbackMonitor helper class
- Update all platform adapters with ap parameter support
- Update botmgr to pass bot_info for monitoring context

Frontend changes:
- Add FeedbackCard and FeedbackList components
- Add useFeedbackData hook for feedback data fetching
- Add feedback tab to monitoring page
- Add feedback types and interfaces
- Add i18n translations (zh-Hans, en-US)

Other changes:
- Update Dockerfile with Chinese mirror for faster builds
- Update docker-compose.yaml with network configuration
- Update .gitignore for docker data and backup files

Note: Known issues that need future improvement:
- feedback_type=3 (cancel) is recorded but not properly handled
- Duplicate feedback records are not deduplicated

* chore: remove unnecessary migration for new table will be created automatically

* chore: ruff format

* chore: prettier

* feat: add feedback handling support across multiple platform adapters

* fix(web): remove unused imports and variables in monitoring module

---------

Co-authored-by: 6mvp6 <13727783693@163.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-03-30 20:23:52 +08:00
RockChinQ
921d12f596 feat: add adapter documentation link button
Add 'View Docs' button that links to the corresponding adapter's
documentation page via link.langbot.app short links.

Appears in:
- Wizard adapter selection cards (Step 0)
- Wizard bot config card header (Step 1)
- Bot create/edit form (adapter config section)

Supports all 7 languages (en/zh-Hans/zh-Hant/ja/th/vi/es).
Doc links auto-resolve to the correct language based on UI locale.
2026-03-30 16:06:54 +08:00
RockChinQ
6bf6deaefd style: fix prettier formatting in i18n locale files 2026-03-30 10:55:20 +08:00
RockChinQ
1201949f2c refactor: replace docs.langbot.app URLs with link.langbot.app short links
All documentation URLs now go through Cloudflare Bulk Redirects
(link.langbot.app) so future doc path changes won't break
already-released versions.

Short link format: link.langbot.app/{lang}/docs/{topic}
Supported languages: zh, en, ja
2026-03-30 10:53:21 +08:00
Typer_Body
1c419e3591 Optimize the plugin system (#2090)
* Optimize the plugin system

* feat: enhance plugin installation process and improve task management

* fix: linter err

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-03-29 23:58:34 +08:00
Junyan Qin
b0a9be77b0 feat(web): move Quick Start to account menu and update i18n references 2026-03-29 00:49:02 +08:00
Junyan Qin
e02ade5a30 feat: add preset selection options and update translations for select preset 2026-03-29 00:32:26 +08:00
Junyan Qin
1a51ba8e7e fix(market): add request plugin CTA to empty search results 2026-03-28 22:16:23 +08:00
Junyan Qin
e7b22d6ebf fix: i18n issues 2026-03-28 20:55:43 +08:00
Junyan Qin
dddfa8ac79 chore: add more language supports 2026-03-28 20:48:36 +08:00
Junyan Qin
99e2976826 feat(i18n): add zh_Hant and ja_JP translations to all adapter YAML files
- Add zh_Hant (Traditional Chinese) to all 17 adapter YAML metadata and config fields
- Add ja_JP translations to global adapters (Telegram, Discord, Slack, Lark, LINE)
- Fix buggy zh_Hant in line.yaml and slack.yaml (contained simplified Chinese)
- Add zh_Hant field to backend I18nString model
- Add adapter category grouping with locale-aware ordering
- Add webhook Cloud CTA for community edition users
- Fix wizard progress not clearing on skip/complete
2026-03-28 19:41:27 +08:00
Junyan Chin
71e44f0e54 Feat/space cta optimization (#2089)
* feat(wizard): persist wizard progress to backend for session resumption

Store wizard step, selected adapter, created bot UUID, and runner
selection in the metadata table. On revisit, the wizard restores
progress and verifies the bot still exists. Progress is cleared
automatically when the wizard is completed or skipped.

* feat(dynamic-form): optimize LLM model selection with space login CTA and improve localization strings

* feat(web): add LangBot Cloud CTA for webhook URL fields in community edition

Show a subtle hint below webhook URL fields prompting users about
LangBot Cloud's public endpoint, only visible in community edition.
Covers all 8 webhook-based adapters with i18n support (4 locales).
2026-03-28 17:24:39 +08:00
Junyan Chin
4c904c2375 Fix/frontend optimizations (#2088)
* fix(web): auto-redirect to wizard on first visit and change sidebar icons to blue

* refactor(wizard): use backend metadata table instead of localStorage for wizard completion state

- Add wizard_completed field to system info API (read from metadata table)
- Add POST /api/v1/system/wizard/completed endpoint to mark wizard done
- Frontend home layout checks systemInfo.wizard_completed for auto-redirect
- Wizard calls markWizardCompleted API on skip/finish
- Ensures consistent behavior across all browsers on the same instance

* fix(wizard): update systemInfo in memory before navigation to prevent redirect loop

* fix(monitoring): prevent horizontal overflow and unify empty state styles

* fix(wizard): use Object.assign for systemInfo and await wizard completion API

- Replace systemInfo reassignment with Object.assign in all 3 locations
  to preserve object identity across module imports
- Await markWizardCompleted() POST in wizard skip/finish handlers
  instead of fire-and-forget to ensure backend persistence
- Always re-fetch systemInfo in home layout to get latest
  wizard_completed state from backend

* fix(wizard): prevent redirect loop by blocking navigation on failed status save

- Refactor wizard_completed (boolean) to wizard_status (string: none/skipped/completed)
- Remove ALL localStorage usage from wizard page (form state persistence)
- Replace AlertDialogAction with Button so skip dialog stays open during POST
- Add loading spinners for skip and complete actions
- If POST fails, show error toast and keep dialog/button active for retry
- If POST succeeds, update in-memory state and navigate

* fix(wizard): fix row[0].value bug causing GET /info to always return wizard_status=none

conn.execute(select(Entity)) returns Row with raw column values, not ORM
entities. row[0] is the key column (a string), so row[0].value raises
AttributeError which was silently swallowed by except-pass, making the
GET endpoint always return wizard_status=none regardless of DB state.

* fix(wizard): replace AlertDialog with Dialog for skip confirmation to remove slide animation

* chore: optimize toast in wizard

* fix(wizard): set default token value for Telegram adapter and initialize adapter config in wizard

* feat(web): move webhook URL to dynamic form system, add market category filter, fix layout overflow

- Add 'webhook-url' dynamic form field type rendered as read-only input
  with copy button, defined in adapter YAML specs instead of hardcoded
  in BotForm. Supports show_if conditions for optional-webhook adapters.
- Remove hardcoded webhook display logic from BotForm.tsx, pass webhook
  URLs via systemContext to DynamicFormComponent.
- Fetch webhook URLs after bot creation in wizard and pass to Step 1.
- Support ?category= query param on /home/market page for filtering by
  component type (mirrors langbot-space behavior).
- Link 'install knowledge engine' hint to /home/market?category=KnowledgeEngine.
- Fix SidebarInset missing min-w-0 causing content overflow when sidebar
  is expanded.
- Add vertical divider between plugin detail config and readme panels.
- Fix infinite re-render loop in DynamicFormComponent by memoizing
  editableItems array.

* fix: lint

* fix(web): change systemInfo to const to satisfy prefer-const lint rule

* fix: update adapter descriptions for clarity and usage requirements
2026-03-28 15:50:32 +08:00
213 changed files with 14595 additions and 8971 deletions

View File

@@ -1,5 +1,5 @@
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]: "
labels: ["bug?"]
body:

View File

@@ -1,5 +1,5 @@
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]: "
labels: ["bug?"]
body:

View File

@@ -43,10 +43,10 @@ jobs:
run: |
cd /tmp/langbot_build_web/web
npm install
npm run build
npx vite build
- name: Package Output
run: |
cp -r /tmp/langbot_build_web/web/out ./web
cp -r /tmp/langbot_build_web/web/dist ./web
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:

View File

@@ -29,8 +29,8 @@ jobs:
npm install -g pnpm
pnpm install
pnpm build
mkdir -p ../src/langbot/web/out
cp -r out ../src/langbot/web/
mkdir -p ../src/langbot/web/dist
cp -r dist ../src/langbot/web/
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v6

3
.gitignore vendored
View File

@@ -52,3 +52,6 @@ src/langbot/web/
/dist
/build
*.egg-info
# Next.js build cache (legacy)
web/.next/

View File

@@ -4,7 +4,7 @@ WORKDIR /app
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
@@ -12,7 +12,7 @@ WORKDIR /app
COPY . .
COPY --from=node /app/web/out ./web/out
COPY --from=node /app/web/dist ./web/dist
RUN apt update \
&& apt install gcc -y \

View File

@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Website</a>
<a href="https://docs.langbot.app/en/insight/features">Features</a>
<a href="https://docs.langbot.app/en/insight/guide">Docs</a>
<a href="https://docs.langbot.app/en/tags/readme">API</a>
<a href="https://link.langbot.app/en/docs/features">Features</a>
<a href="https://link.langbot.app/en/docs/guide">Docs</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">Plugin Market</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.
- **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
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](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 | ✅ |
| [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)
---

View File

@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官网</a>
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">文档</a>
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文档</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">插件市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
@@ -45,7 +45,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
---
@@ -76,7 +76,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](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) | 聚合平台 | ✅ |
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
[→ 查看完整集成列表](https://docs.langbot.app/zh/insight/features.html)
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
### TTS语音合成

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Inicio</a>
<a href="https://docs.langbot.app/en/insight/features.html">Características</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Documentación</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API</a>
<a href="https://link.langbot.app/en/docs/features">Características</a>
<a href="https://link.langbot.app/en/docs/guide">Documentación</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://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.
- **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
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](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 | ✅ |
| [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)
---

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Accueil</a>
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Documentation</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API</a>
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a>
<a href="https://link.langbot.app/en/docs/guide">Documentation</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://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.
- **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
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](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 | ✅ |
| [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)
---

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">ホーム</a>
<a href="https://docs.langbot.app/ja/insight/features.html">機能</a>
<a href="https://docs.langbot.app/ja/insight/guide.html">ドキュメント</a>
<a href="https://docs.langbot.app/ja/tags/readme.html">API</a>
<a href="https://link.langbot.app/ja/docs/features">機能</a>
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a>
<a href="https://link.langbot.app/ja/docs/api">API</a>
<a href="https://space.langbot.app">プラグインマーケット</a>
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
@@ -44,7 +44,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
- **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
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](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/) | ゲートウェイ | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
[→ すべての統合を表示](https://docs.langbot.app/en/insight/features.html)
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
---

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">홈</a>
<a href="https://docs.langbot.app/en/insight/features.html">기능</a>
<a href="https://docs.langbot.app/en/insight/guide.html">문서</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API</a>
<a href="https://link.langbot.app/en/docs/features">기능</a>
<a href="https://link.langbot.app/en/docs/guide">문서</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">플러그인 마켓</a>
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
@@ -44,7 +44,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
[→ 모든 기능 자세히 보기](https://docs.langbot.app/en/insight/features.html)
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
---
@@ -75,7 +75,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](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/) | 게이트웨이 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
[→ 모든 통합 보기](https://docs.langbot.app/en/insight/features.html)
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
---

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Главная</a>
<a href="https://docs.langbot.app/en/insight/features.html">Возможности</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Документация</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API</a>
<a href="https://link.langbot.app/en/docs/features">Возможности</a>
<a href="https://link.langbot.app/en/docs/guide">Документация</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">Магазин плагинов</a>
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
@@ -44,7 +44,7 @@ LangBot — это **платформа с открытым исходным к
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
[→ Подробнее обо всех возможностях](https://docs.langbot.app/en/insight/features.html)
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
---
@@ -75,7 +75,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](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 | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
[→ Смотреть все интеграции](https://docs.langbot.app/en/insight/features.html)
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
---

View File

@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官網</a>
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">文件</a>
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文件</a>
<a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://space.langbot.app">外掛市場</a>
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
@@ -46,7 +46,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
---
@@ -77,7 +77,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](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://docs.langbot.app/zh/insight/features.html)
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
---

View File

@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Trang chủ</a>
<a href="https://docs.langbot.app/en/insight/features.html">Tính năng</a>
<a href="https://docs.langbot.app/en/insight/guide.html">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/features">Tính năng</a>
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">Chợ Plugin</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.
- **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
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](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 | ✅ |
| [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)
---

View File

@@ -312,7 +312,7 @@ spec:
### 参考资源
- [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/)
---
@@ -625,5 +625,5 @@ spec:
### References
- [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/)

View File

@@ -34,4 +34,4 @@ services:
networks:
langbot_network:
driver: bridge
driver: bridge

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.9.4"
version = "4.9.5"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -64,7 +64,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.5",
"langbot-plugin==0.3.7",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",
@@ -111,7 +111,7 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**"] }
[dependency-groups]
dev = [

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.9.4'
__version__ = '4.9.5'

View File

@@ -64,6 +64,9 @@ class StreamSession:
# 缓存最近一次片段,处理重试或超时兜底
last_chunk: Optional[StreamChunk] = None
# 反馈 ID用于接收用户点赞/点踩反馈
feedback_id: Optional[str] = None
class StreamSessionManager:
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
@@ -74,6 +77,7 @@ class StreamSessionManager:
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
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]:
if not msg_id:
@@ -83,6 +87,32 @@ class StreamSessionManager:
def get_session(self, stream_id: str) -> Optional[StreamSession]:
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]:
"""根据企业微信回调创建或获取会话。
@@ -404,10 +434,10 @@ async def parse_wecom_bot_message(
}
if voice_info.get('content'):
message_data['content'] = voice_info.get('content')
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
if voice_base64:
message_data['voice']['base64'] = voice_base64
# 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)
# if voice_base64:
# message_data['voice']['base64'] = voice_base64
elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url')
@@ -419,10 +449,12 @@ async def parse_wecom_bot_message(
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
if video_base64:
video_data['base64'] = video_base64
# if (video_data.get('filesize') or 0) <= max_inline_file_size:
# video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
# if video_base64:
# video_data['base64'] = video_base64
# 应为需要解密但是目前暂时不能下载到内部进行解密所以先将下载链接拼接aeskey返回给用户由插件去处理该链接的下载和解密逻辑
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
message_data['video'] = video_data
elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {}
@@ -436,12 +468,15 @@ async def parse_wecom_bot_message(
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
if file_bytes:
file_data['base64'] = _bytes_to_data_uri(file_bytes)
if dl_filename and not file_data.get('filename'):
file_data['filename'] = dl_filename
# if (file_data.get('filesize') or 0) <= max_inline_file_size:
# file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
# if file_bytes:
# file_data['base64'] = _bytes_to_data_uri(file_bytes)
# if dl_filename and not file_data.get('filename'):
# file_data['filename'] = dl_filename
# 应为需要解密但是目前暂时不能下载到内部进行解密所以先将下载链接拼接aeskey返回给用户由插件去处理该链接的下载和解密逻辑
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
message_data['file'] = file_data
elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {})
@@ -597,14 +632,27 @@ class WecomBotClient:
self.stream_sessions = StreamSessionManager(logger=logger)
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
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:
stream_id: 企业微信会话 ID。
content: 推送的文本内容。
finish: 是否为最终片段。
feedback_id: 反馈 ID用于接收用户点赞/点踩反馈。
Returns:
dict[str, Any]: 可直接加密返回的 payload。
@@ -612,13 +660,16 @@ class WecomBotClient:
Example:
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
"""
stream_payload = {
'id': stream_id,
'finish': finish,
'content': content,
}
if feedback_id:
stream_payload['feedback'] = {'id': feedback_id}
return {
'msgtype': 'stream',
'stream': {
'id': stream_id,
'finish': finish,
'content': content,
},
'stream': stream_payload,
}
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
@@ -674,9 +725,14 @@ class WecomBotClient:
"""
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)
if message_data:
message_data['stream_id'] = session.stream_id
message_data['feedback_id'] = feedback_id
try:
event = wecombotevent.WecomBotEvent(message_data)
except Exception:
@@ -685,7 +741,7 @@ class WecomBotClient:
if is_new:
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)
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
@@ -810,11 +866,78 @@ class WecomBotClient:
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':
return await self._handle_post_followup_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}'
)
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())
else:
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
except Exception:
await self.logger.error(traceback.format_exc())
return await self._encrypt_and_reply({}, nonce)
async def get_message(self, msg_json):
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
@@ -883,6 +1006,15 @@ class WecomBotClient:
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):
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
if data:

View File

@@ -133,3 +133,17 @@ class WecomBotEvent(dict):
AI Bot ID
"""
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', '')

View File

@@ -20,7 +20,7 @@ from typing import Any, Callable, Optional
import aiohttp
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
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
# Dedup: skip sending when content hasn't changed
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 ──────────────────────────────────────────────────
@@ -164,12 +170,27 @@ class WecomBotWsClient:
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(
self,
req_id: str,
stream_id: str,
content: str,
finish: bool = False,
feedback_id: str = '',
) -> Optional[dict]:
"""Send a streaming reply frame.
@@ -178,17 +199,22 @@ class WecomBotWsClient:
stream_id: The stream ID for this streaming session.
content: The content to send (supports Markdown).
finish: Whether this is the final chunk.
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
Returns:
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 = {
'msgtype': 'stream',
'stream': {
'id': stream_id,
'finish': finish,
'content': content,
},
'stream': stream_payload,
}
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)
if not is_final and content == self._stream_last_content.get(msg_id):
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
if is_final:
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return True
except Exception:
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', '')
if msg_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['req_id'] = req_id
@@ -454,7 +501,7 @@ class WecomBotWsClient:
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
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:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
@@ -479,14 +526,54 @@ class WecomBotWsClient:
if 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)
# Dispatch to event-specific handlers
if event_type in self._message_handlers:
for handler in self._message_handlers[event_type]:
await handler(event)
# Also dispatch to generic 'event' handlers
if 'event' in self._message_handlers:
for handler in self._message_handlers['event']:
await handler(event)

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
import quart
from .. import group
@group.group_class('human-takeover', '/api/v1/human-takeover')
class HumanTakeoverRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_sessions():
"""Get list of takeover sessions, optionally filtered by bot UUID."""
bot_uuid = quart.request.args.get('botUuid')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
sessions, total = await self.ap.human_takeover_service.get_active_sessions(
bot_uuid=bot_uuid if bot_uuid else None,
limit=limit,
offset=offset,
)
return self.success(
data={
'sessions': sessions,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/sessions/<session_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_session_detail(session_id: str):
"""Get detail for a specific takeover session."""
detail = await self.ap.human_takeover_service.get_session_detail(session_id)
if not detail:
return self.success(data={'found': False, 'session_id': session_id})
return self.success(data={'found': True, 'session': detail})
@self.route('/sessions/<session_id>/takeover', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def takeover_session(session_id: str, user_email: str = None):
"""Take over a conversation session."""
data = await quart.request.get_json(silent=True) or {}
bot_uuid = data.get('bot_uuid')
if not bot_uuid:
return self.fail(-1, 'bot_uuid is required')
platform = data.get('platform')
user_id = data.get('user_id')
user_name = data.get('user_name')
try:
result = await self.ap.human_takeover_service.takeover_session(
session_id=session_id,
bot_uuid=bot_uuid,
taken_by=user_email or data.get('taken_by'),
platform=platform,
user_id=user_id,
user_name=user_name,
)
return self.success(data=result)
except ValueError as e:
return self.fail(-1, str(e))
@self.route('/sessions/<session_id>/release', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def release_session(session_id: str):
"""Release a taken-over session back to AI pipeline."""
try:
result = await self.ap.human_takeover_service.release_session(session_id)
return self.success(data=result)
except ValueError as e:
return self.fail(-1, str(e))
@self.route('/sessions/<session_id>/message', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def send_message(session_id: str, user_email: str = None):
"""Send a message from the operator to the user."""
data = await quart.request.get_json(silent=True) or {}
message_text = data.get('message')
if not message_text:
return self.fail(-1, 'message is required')
operator_name = user_email or data.get('operator_name', 'Operator')
try:
result = await self.ap.human_takeover_service.send_message(
session_id=session_id,
message_text=message_text,
operator_name=operator_name,
)
return self.success(data=result)
except ValueError as e:
return self.fail(-1, str(e))
except RuntimeError as e:
return self.fail(-2, str(e))

View File

@@ -456,6 +456,31 @@ class MonitoringRouterGroup(group.RouterGroup):
'platform',
'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:
return self.error(message=f'Invalid export type: {export_type}', code=400)
@@ -486,3 +511,63 @@ class MonitoringRouterGroup(group.RouterGroup):
)
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,
}
)

View File

@@ -265,6 +265,8 @@ class PluginsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Missing asset_url parameter')
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
ctx.metadata['install_source'] = 'github'
install_info = {
'asset_url': asset_url,
'owner': owner,
@@ -295,12 +297,17 @@ class PluginsRouterGroup(group.RouterGroup):
data = await quart.request.json
plugin_author = data.get('plugin_author', '')
plugin_name = data.get('plugin_name', '')
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(
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
kind='plugin-operation',
name='plugin-install-marketplace',
label=f'Installing plugin from marketplace ...{data}',
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
context=ctx,
)
@@ -323,11 +330,13 @@ class PluginsRouterGroup(group.RouterGroup):
}
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(
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
kind='plugin-operation',
name='plugin-install-local',
label=f'Installing plugin from local ...{file.filename}',
label=f'Installing plugin from local {file.filename}',
context=ctx,
)

View File

@@ -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}')

View File

@@ -1,7 +1,11 @@
import json
import quart
import sqlalchemy
from .. import group
from .....utils import constants
from .....entity.persistence.metadata import Metadata
@group.group_class('system', '/api/v1/system')
@@ -9,6 +13,24 @@ class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
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(
data={
'version': constants.semantic_version,
@@ -27,17 +49,83 @@ class SystemRouterGroup(group.RouterGroup):
'disable_models_service', False
),
'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)
async def _() -> str:
task_type = quart.request.args.get('type')
task_kind = quart.request.args.get('kind')
if task_type == '':
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)
async def _(task_id: str) -> str:

View File

@@ -0,0 +1,314 @@
from __future__ import annotations
import uuid
import datetime
import json
import logging
import sqlalchemy
from ....core import app
from ....entity.persistence import human_takeover as persistence_human_takeover
import langbot_plugin.api.entities.builtin.platform.message as platform_message
class HumanTakeoverService:
"""Human takeover service.
Manages operator takeover of user conversation sessions, bypassing
the normal AI pipeline. Uses an in-memory cache for fast synchronous
lookups on the hot message path, backed by database persistence.
"""
ap: app.Application
# In-memory cache: session_id -> HumanTakeoverSession record id
# Only contains sessions with status='active'
_active_sessions: dict[str, str]
logger: logging.Logger
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self._active_sessions = {}
self.logger = logging.getLogger('human-takeover')
async def initialize(self) -> None:
"""Load active takeover sessions from DB into memory cache."""
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).where(
persistence_human_takeover.HumanTakeoverSession.status == 'active'
)
)
rows = result.all()
for row in rows:
session = row[0] if isinstance(row, tuple) else row
self._active_sessions[session.session_id] = session.id
self.logger.info(f'Loaded {len(self._active_sessions)} active takeover sessions from DB')
except Exception as e:
self.logger.warning(f'Failed to load active takeover sessions: {e}')
def is_taken_over(self, session_id: str) -> bool:
"""Check if a session is currently under human takeover.
This is a synchronous in-memory lookup for performance, since it
is called on every incoming message (hot path).
"""
return session_id in self._active_sessions
async def takeover_session(
self,
session_id: str,
bot_uuid: str,
taken_by: str | None = None,
platform: str | None = None,
user_id: str | None = None,
user_name: str | None = None,
) -> dict:
"""Take over a conversation session.
Args:
session_id: The session to take over (e.g. 'person_123' or 'group_456').
bot_uuid: UUID of the bot whose session is being taken over.
taken_by: Email/username of the admin performing the takeover.
platform: Platform name.
user_id: The end-user's ID in the session.
user_name: The end-user's display name.
Returns:
Dict with the created takeover session record.
Raises:
ValueError: If the session is already taken over.
"""
if self.is_taken_over(session_id):
raise ValueError(f'Session {session_id} is already taken over')
record_id = str(uuid.uuid4())
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
record_data = {
'id': record_id,
'session_id': session_id,
'bot_uuid': bot_uuid,
'status': 'active',
'taken_by': taken_by,
'taken_at': now,
'released_at': None,
'platform': platform,
'user_id': user_id,
'user_name': user_name,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_human_takeover.HumanTakeoverSession).values(record_data)
)
# Update in-memory cache
self._active_sessions[session_id] = record_id
self.logger.info(f'Session {session_id} taken over by {taken_by}')
return record_data
async def release_session(self, session_id: str) -> dict:
"""Release a taken-over session back to AI pipeline processing.
Args:
session_id: The session to release.
Returns:
Dict with the updated takeover session record.
Raises:
ValueError: If the session is not currently taken over.
"""
if not self.is_taken_over(session_id):
raise ValueError(f'Session {session_id} is not currently taken over')
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_human_takeover.HumanTakeoverSession)
.where(
sqlalchemy.and_(
persistence_human_takeover.HumanTakeoverSession.session_id == session_id,
persistence_human_takeover.HumanTakeoverSession.status == 'active',
)
)
.values(status='released', released_at=now)
)
# Remove from in-memory cache
self._active_sessions.pop(session_id, None)
self.logger.info(f'Session {session_id} released back to AI pipeline')
return {
'session_id': session_id,
'status': 'released',
'released_at': now.isoformat(),
}
async def send_message(
self,
session_id: str,
message_text: str,
operator_name: str | None = None,
) -> dict:
"""Send a message from the operator to the user via the platform adapter.
Args:
session_id: The taken-over session ID (e.g. 'person_123' or 'group_456').
message_text: The text message to send.
operator_name: Name of the operator sending the message.
Returns:
Dict with send result info.
Raises:
ValueError: If the session is not currently taken over.
RuntimeError: If the bot or adapter cannot be found.
"""
if not self.is_taken_over(session_id):
raise ValueError(f'Session {session_id} is not currently taken over')
# Look up the takeover record to get bot_uuid
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).where(
sqlalchemy.and_(
persistence_human_takeover.HumanTakeoverSession.session_id == session_id,
persistence_human_takeover.HumanTakeoverSession.status == 'active',
)
)
)
row = result.first()
if not row:
raise RuntimeError(f'Active takeover record not found for session {session_id}')
takeover_record = row[0] if isinstance(row, tuple) else row
bot_uuid = takeover_record.bot_uuid
# Get the runtime bot
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
if not runtime_bot:
raise RuntimeError(f'Bot {bot_uuid} not found or not running')
# Parse session_id to determine target_type and target_id
# Format: 'person_{id}' or 'group_{id}'
if session_id.startswith('person_'):
target_type = 'person'
target_id = session_id[len('person_') :]
elif session_id.startswith('group_'):
target_type = 'group'
target_id = session_id[len('group_') :]
else:
raise ValueError(f'Invalid session_id format: {session_id}')
# Build message chain
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
# Send via adapter
await runtime_bot.adapter.send_message(target_type, target_id, message_chain)
# Record the operator message in monitoring
bot_name = runtime_bot.bot_entity.name or bot_uuid
try:
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
except Exception:
message_content = message_text
await self.ap.monitoring_service.record_message(
bot_id=bot_uuid,
bot_name=bot_name,
pipeline_id='__human_takeover__',
pipeline_name='Human Takeover',
message_content=message_content,
session_id=session_id,
status='success',
level='info',
platform=takeover_record.platform,
user_id=operator_name or 'operator',
user_name=operator_name or 'Operator',
role='operator',
)
self.logger.info(f'Operator message sent to session {session_id}: {message_text[:50]}...')
return {
'session_id': session_id,
'message_sent': True,
}
async def get_active_sessions(
self,
bot_uuid: str | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get list of active (or all) takeover sessions.
Args:
bot_uuid: Optional filter by bot UUID.
limit: Maximum number of results.
offset: Pagination offset.
Returns:
Tuple of (list of session dicts, total count).
"""
conditions = []
if bot_uuid:
conditions.append(persistence_human_takeover.HumanTakeoverSession.bot_uuid == bot_uuid)
# Count
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_human_takeover.HumanTakeoverSession.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
# Fetch records
query = sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).order_by(
persistence_human_takeover.HumanTakeoverSession.taken_at.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()
sessions = []
for row in rows:
session = row[0] if isinstance(row, tuple) else row
sessions.append(
self.ap.persistence_mgr.serialize_model(persistence_human_takeover.HumanTakeoverSession, session)
)
return sessions, total
async def get_session_detail(self, session_id: str) -> dict | None:
"""Get detail for a specific takeover session.
Args:
session_id: The session ID to look up.
Returns:
Session dict or None if not found.
"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession)
.where(persistence_human_takeover.HumanTakeoverSession.session_id == session_id)
.order_by(persistence_human_takeover.HumanTakeoverSession.taken_at.desc())
)
row = result.first()
if not row:
return None
session = row[0] if isinstance(row, tuple) else row
return self.ap.persistence_mgr.serialize_model(persistence_human_takeover.HumanTakeoverSession, session)

View File

@@ -1183,3 +1183,261 @@ class MonitoringService:
}
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
record_id = str(uuid.uuid4())
record_data = {
'id': record_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'feedback_id': feedback_id,
'feedback_type': feedback_type,
'feedback_content': feedback_content,
'inaccurate_reasons': json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None,
'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,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
)
return record_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
]

View File

@@ -31,6 +31,7 @@ from ..api.http.service import mcp as mcp_service
from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_service
from ..api.http.service import human_takeover as human_takeover_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
@@ -153,6 +154,8 @@ class Application:
monitoring_service: monitoring_service.MonitoringService = None
human_takeover_service: human_takeover_service.HumanTakeoverService = None
def __init__(self):
pass

View File

@@ -28,6 +28,7 @@ from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...api.http.service import monitoring as monitoring_service
from ...api.http.service import human_takeover as human_takeover_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
@@ -164,6 +165,10 @@ class BuildAppStage(stage.BootingStage):
monitoring_service_inst = monitoring_service.MonitoringService(ap)
ap.monitoring_service = monitoring_service_inst
human_takeover_service_inst = human_takeover_service.HumanTakeoverService(ap)
await human_takeover_service_inst.initialize()
ap.human_takeover_service = human_takeover_service_inst
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
await asyncio.sleep(3)
await plugin_connector_inst.initialize()

View File

@@ -80,8 +80,12 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
if i == len(keys) - 1:
# At the final key
if key in current:
if isinstance(current[key], (dict, list)):
# Skip dict and list types
if isinstance(current[key], list):
# 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
else:
# Valid scalar value - convert and set it

View File

@@ -17,9 +17,13 @@ class TaskContext:
log: str
"""Log"""
metadata: dict
"""Structured metadata for progress reporting"""
def __init__(self):
self.current_action = 'default'
self.log = ''
self.metadata = {}
def _log(self, msg: str):
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}')
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
def new() -> TaskContext:
@@ -211,9 +215,14 @@ class AsyncTaskManager:
def get_tasks_dict(
self,
type: str = None,
kind: str = None,
) -> dict:
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,
}

View File

@@ -17,11 +17,23 @@ class I18nString(pydantic.BaseModel):
"""英文"""
zh_Hans: typing.Optional[str] = None
"""中文"""
"""简体中文"""
zh_Hant: 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:
"""转换为字典"""
dic = {}
@@ -29,8 +41,16 @@ class I18nString(pydantic.BaseModel):
dic['en_US'] = self.en_US
if self.zh_Hans is not None:
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:
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

View File

@@ -16,6 +16,7 @@ class Bot(Base):
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
use_pipeline_name = 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())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,

View File

@@ -0,0 +1,36 @@
import sqlalchemy
from .base import Base
class HumanTakeoverSession(Base):
"""Human takeover session records.
Tracks which conversation sessions are currently under human operator control,
bypassing the normal AI pipeline processing.
"""
__tablename__ = 'human_takeover_sessions'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
"""Corresponds to monitoring_sessions.session_id, format: 'person_{id}' or 'group_{id}'"""
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
"""UUID of the bot whose session is being taken over"""
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='active', index=True)
"""Takeover status: 'active' or 'released'"""
taken_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Email/username of the admin who took over the session"""
taken_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
"""Timestamp when the takeover started"""
released_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
"""Timestamp when the takeover was released (null if still active)"""
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)

View File

@@ -106,3 +106,26 @@ class MonitoringEmbeddingCall(Base):
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
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

View File

@@ -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)

View File

@@ -0,0 +1,36 @@
import sqlalchemy
from .. import migration
@migration.migration_class(26)
class DBMigrateHumanTakeoverSessions(migration.DBMigration):
"""Create human_takeover_sessions table for human operator takeover support"""
async def upgrade(self):
sql_text = sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS human_takeover_sessions (
id VARCHAR(255) PRIMARY KEY,
session_id VARCHAR(255) NOT NULL UNIQUE,
bot_uuid VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'active',
taken_by VARCHAR(255),
taken_at DATETIME NOT NULL,
released_at DATETIME,
platform VARCHAR(255),
user_id VARCHAR(255),
user_name VARCHAR(255)
)
""")
await self.ap.persistence_mgr.execute_async(sql_text)
# Create indexes
for idx_sql in [
'CREATE INDEX IF NOT EXISTS idx_hts_session_id ON human_takeover_sessions (session_id)',
'CREATE INDEX IF NOT EXISTS idx_hts_bot_uuid ON human_takeover_sessions (bot_uuid)',
'CREATE INDEX IF NOT EXISTS idx_hts_status ON human_takeover_sessions (status)',
]:
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(idx_sql))
async def downgrade(self):
sql_text = sqlalchemy.text('DROP TABLE IF EXISTS human_takeover_sessions')
await self.ap.persistence_mgr.execute_async(sql_text)

View File

@@ -37,6 +37,7 @@ class PendingMessage:
message_chain: platform_message.MessageChain
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
pipeline_uuid: typing.Optional[str]
routed_by_rule: bool = False
timestamp: float = field(default_factory=time.time)
@@ -125,6 +126,7 @@ class MessageAggregator:
message_chain: platform_message.MessageChain,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
routed_by_rule: bool = False,
) -> None:
"""Add a message to the aggregation buffer
@@ -145,6 +147,7 @@ class MessageAggregator:
message_chain=message_chain,
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
)
return
@@ -159,6 +162,7 @@ class MessageAggregator:
message_chain=message_chain,
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
)
force_flush = False
@@ -217,6 +221,7 @@ class MessageAggregator:
message_chain=msg.message_chain,
adapter=msg.adapter,
pipeline_uuid=msg.pipeline_uuid,
routed_by_rule=msg.routed_by_rule,
)
return
@@ -231,6 +236,7 @@ class MessageAggregator:
message_chain=merged_msg.message_chain,
adapter=merged_msg.adapter,
pipeline_uuid=merged_msg.pipeline_uuid,
routed_by_rule=merged_msg.routed_by_rule,
)
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:

View File

@@ -63,6 +63,14 @@ class Controller:
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
if pipeline:
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:
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()

View File

@@ -323,6 +323,9 @@ class RuntimePipeline:
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
if event_ctx.is_prevented_default():
self.ap.logger.debug(
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
)
return
self.ap.logger.debug(f'Processing query {query.query_id}')

View File

@@ -41,6 +41,7 @@ class QueryPool:
message_chain: platform_message.MessageChain,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
routed_by_rule: bool = False,
) -> pipeline_query.Query:
async with self.condition:
query_id = self.query_id_counter
@@ -52,7 +53,7 @@ class QueryPool:
sender_id=sender_id,
message_event=message_event,
message_chain=message_chain,
variables={},
variables={'_routed_by_rule': routed_by_rule},
resp_messages=[],
resp_message_chain=[],
adapter=adapter,

View File

@@ -61,6 +61,9 @@ class ChatMessageHandler(handler.MessageHandler):
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
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)
else:
if event_ctx.event.user_message_alter is not None:

View File

@@ -37,6 +37,10 @@ class GroupRespondRuleCheckStage(stage.PipelineStage):
if query.launcher_type.value != 'group': # 只处理群消息
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']
use_rule = rules

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import asyncio
import json
import re
import traceback
import sqlalchemy
@@ -9,6 +11,7 @@ from ..core import app, entities as core_entities, taskmgr
from ..discover import engine
from ..entity.persistence import bot as persistence_bot
from ..entity.persistence import pipeline as persistence_pipeline
from ..entity.errors import platform as platform_errors
@@ -51,6 +54,148 @@ class RuntimeBot:
self.task_context = taskmgr.TaskContext()
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 on_friend_message(
event: platform_events.FriendMessage,
@@ -75,6 +220,47 @@ class RuntimeBot:
# Only add to query pool if no webhook requested to skip pipeline
if not skip_pipeline:
# Check if session is under human takeover
person_session_id = f'person_{event.sender.id}'
if (
hasattr(self.ap, 'human_takeover_service')
and self.ap.human_takeover_service
and self.ap.human_takeover_service.is_taken_over(person_session_id)
):
# Session is taken over: record message to monitoring then stop
await self.logger.info(
f'Person message intercepted by human takeover for session {person_session_id}'
)
try:
if hasattr(event.message_chain, 'model_dump'):
msg_content = json.dumps(event.message_chain.model_dump(), ensure_ascii=False)
else:
msg_content = str(event.message_chain)
sender_name = None
if hasattr(event, 'sender') and hasattr(event.sender, 'nickname'):
sender_name = event.sender.nickname
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='__human_takeover__',
pipeline_name='Human Takeover',
message_content=msg_content,
session_id=person_session_id,
status='success',
level='info',
platform=adapter.__class__.__name__,
user_id=str(event.sender.id),
user_name=sender_name,
role='user',
)
await self.ap.monitoring_service.update_session_activity(person_session_id)
except Exception as e:
await self.logger.error(f'Failed to record takeover message: {e}')
return
launcher_id = event.sender.id
if hasattr(adapter, 'get_launcher_id'):
@@ -82,6 +268,23 @@ class RuntimeBot:
if 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(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.PERSON,
@@ -90,7 +293,8 @@ class RuntimeBot:
message_event=event,
message_chain=event.message_chain,
adapter=adapter,
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
)
else:
await self.logger.info('Pipeline skipped for person message due to webhook response')
@@ -118,6 +322,50 @@ class RuntimeBot:
# Only add to query pool if no webhook requested to skip pipeline
if not skip_pipeline:
# Check if session is under human takeover
group_session_id = f'group_{event.group.id}'
if (
hasattr(self.ap, 'human_takeover_service')
and self.ap.human_takeover_service
and self.ap.human_takeover_service.is_taken_over(group_session_id)
):
# Session is taken over: record message to monitoring then stop
await self.logger.info(
f'Group message intercepted by human takeover for session {group_session_id}'
)
try:
if hasattr(event.message_chain, 'model_dump'):
msg_content = json.dumps(event.message_chain.model_dump(), ensure_ascii=False)
else:
msg_content = str(event.message_chain)
sender_name = None
if hasattr(event, 'sender'):
if hasattr(event.sender, 'member_name'):
sender_name = event.sender.member_name
elif hasattr(event.sender, 'nickname'):
sender_name = event.sender.nickname
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='__human_takeover__',
pipeline_name='Human Takeover',
message_content=msg_content,
session_id=group_session_id,
status='success',
level='info',
platform=adapter.__class__.__name__,
user_id=str(event.sender.id),
user_name=sender_name,
role='user',
)
await self.ap.monitoring_service.update_session_activity(group_session_id)
except Exception as e:
await self.logger.error(f'Failed to record takeover message: {e}')
return
launcher_id = event.group.id
if hasattr(adapter, 'get_launcher_id'):
@@ -125,6 +373,23 @@ class RuntimeBot:
if 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(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.GROUP,
@@ -133,7 +398,8 @@ class RuntimeBot:
message_event=event,
message_chain=event.message_chain,
adapter=adapter,
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
)
else:
await self.logger.info('Pipeline skipped for group message due to webhook response')
@@ -141,6 +407,50 @@ class RuntimeBot:
self.adapter.register_listener(platform_events.FriendMessage, on_friend_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 exception_wrapper():
try:
@@ -196,12 +506,20 @@ class PlatformManager:
# delete all 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')
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
for component in self.adapter_components:
if component.metadata.name in disabled_adapters:
continue
adapter_dict[component.metadata.name] = component.get_python_component_class()
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
websocket_adapter_class = self.adapter_dict['websocket']
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)

View File

@@ -5,19 +5,29 @@ metadata:
label:
en_US: OneBot v11
zh_Hans: OneBot v11
zh_Hant: OneBot v11
description:
en_US: OneBot v11 Adapter
zh_Hans: OneBot v11 适配器,请查看文档了解使用方式
en_US: OneBot v11 Adapter, used for QQ bots
zh_Hans: OneBot v11 适配器,用于接入 QQ 机器人协议端,请查看文档了解使用方式
zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式
icon: onebot.png
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:
- name: host
label:
en_US: Host
zh_Hans: 主机
zh_Hant: 主機
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
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
zh_Hant: OneBot v11 監聽的反向 WS 主機,除非你知道自己在做什麼,否則請填 0.0.0.0
type: string
required: true
default: 0.0.0.0
@@ -25,9 +35,11 @@ spec:
label:
en_US: Port
zh_Hans: 端口
zh_Hant: 連接埠
description:
en_US: Port
zh_Hans: 监听的端口
zh_Hant: 監聽的連接埠
type: integer
required: true
default: 2280
@@ -35,9 +47,11 @@ spec:
label:
en_US: Access Token
zh_Hans: 访问令牌
zh_Hant: 存取令牌
description:
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
zh_Hant: 自訂的與協定端的連線令牌,若協定端未設定,則不填
type: string
required: false
default: ""

View File

@@ -5,16 +5,25 @@ metadata:
label:
en_US: DingTalk
zh_Hans: 钉钉
zh_Hant: 釘釘
description:
en_US: DingTalk Adapter
zh_Hans: 钉钉适配器,请查看文档了解使用方式
zh_Hant: 釘釘適配器,請查看文件了解使用方式
icon: dingtalk.svg
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:
- name: client_id
label:
en_US: Client ID
zh_Hans: 客户端ID
zh_Hant: 用戶端ID
type: string
required: true
default: ""
@@ -22,6 +31,7 @@ spec:
label:
en_US: Client Secret
zh_Hans: 客户端密钥
zh_Hant: 用戶端密鑰
type: string
required: true
default: ""
@@ -29,6 +39,7 @@ spec:
label:
en_US: Robot Code
zh_Hans: 机器人代码
zh_Hant: 機器人代碼
type: string
required: true
default: ""
@@ -36,6 +47,7 @@ spec:
label:
en_US: Robot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
type: string
required: true
default: ""
@@ -43,6 +55,7 @@ spec:
label:
en_US: Markdown Card
zh_Hans: 是否使用 Markdown 卡片
zh_Hant: 是否使用 Markdown 卡片
type: boolean
required: false
default: true
@@ -50,9 +63,11 @@ spec:
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用钉钉卡片流式回复模式
zh_Hant: 啟用釘釘卡片串流回覆模式
description:
en_US: If enabled, the bot will use the stream of lark reply mode
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
type: boolean
required: true
default: false
@@ -60,6 +75,7 @@ spec:
label:
en_US: Card Auto Layout
zh_Hans: 卡片宽屏自动布局
zh_Hant: 卡片寬螢幕自動佈局
type: boolean
required: false
default: false
@@ -67,6 +83,7 @@ spec:
label:
en_US: card template id
zh_Hans: 卡片模板ID
zh_Hant: 卡片範本ID
type: string
required: true
default: "填写你的卡片template_id"

View File

@@ -5,16 +5,38 @@ metadata:
label:
en_US: Discord
zh_Hans: Discord
zh_Hant: Discord
ja_JP: Discord
th_TH: Discord
vi_VN: Discord
es_ES: Discord
description:
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
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:
- name: client_id
label:
en_US: Client 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
required: true
default: ""
@@ -22,6 +44,11 @@ spec:
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
ja_JP: トークン
th_TH: โทเค็น
vi_VN: Mã thông báo
es_ES: Token
type: string
required: true
default: ""

View File

@@ -5,16 +5,25 @@ metadata:
label:
en_US: KOOK
zh_Hans: KOOK
zh_Hant: KOOK
description:
en_US: KOOK Adapter (formerly KaiHeiLa)
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息
icon: kook.png
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:
- name: token
label:
en_US: Bot Token
zh_Hans: 机器人令牌
zh_Hant: 機器人令牌
type: string
required: true
default: ""

View File

@@ -797,8 +797,65 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
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 = (
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']
@@ -1088,6 +1145,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'size': 'medium',
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}],
'margin': '0px 0px 0px 0px',
}
],
@@ -1111,6 +1169,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'size': 'medium',
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}],
'margin': '0px 0px 0px 0px',
}
],
@@ -1472,6 +1531,52 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if event.__class__ in self.listeners:
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:
try:
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')

View File

@@ -5,16 +5,30 @@ metadata:
label:
en_US: Lark
zh_Hans: 飞书
zh_Hant: 飛書
ja_JP: Lark
description:
en_US: Lark Adapter
zh_Hans: 飞书适配器,请查看文档了解使用方式
en_US: Lark Adapter, supports both long connection and Webhook modes. Please refer to the documentation for usage details.
zh_Hans: 飞书适配器,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
zh_Hant: 飛書適配器,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。
icon: lark.svg
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:
- name: app_id
label:
en_US: App ID
zh_Hans: 应用ID
zh_Hant: 應用ID
ja_JP: アプリ ID
type: string
required: true
default: ""
@@ -22,6 +36,8 @@ spec:
label:
en_US: App Secret
zh_Hans: 应用密钥
zh_Hant: 應用密鑰
ja_JP: アプリシークレット
type: string
required: true
default: ""
@@ -29,9 +45,13 @@ spec:
label:
en_US: Bot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
ja_JP: ボット名
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
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常接收訊息
ja_JP: Lark のボット名と一致する必要があります。一致しない場合、グループ内でメッセージを受信できません
type: string
required: true
default: ""
@@ -39,29 +59,63 @@ spec:
label:
en_US: Enable Webhook Mode
zh_Hans: 启用Webhook模式
zh_Hant: 啟用 Webhook 模式
ja_JP: Webhook モードを有効化
description:
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_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
ja_JP: 有効にすると、ボットは Webhook モードでメッセージを受信します。無効の場合は WS 長期接続モードを使用します
type: boolean
required: true
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
label:
en_US: Encrypt Key
zh_Hans: 加密密钥
zh_Hant: 加密密鑰
ja_JP: 暗号化キー
description:
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
zh_Hant: 僅在啟用 Webhook 模式時有效,請填寫加密密鑰
ja_JP: Webhook モードが有効な場合にのみ有効です。暗号化キーを入力してください
type: string
required: true
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: enable-stream-reply
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用飞书流式回复模式
zh_Hant: 啟用飛書串流回覆模式
ja_JP: ストリーミング返信モードを有効化
description:
en_US: If enabled, the bot will use the stream of lark reply mode
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
zh_Hant: 如果啟用,將使用飛書串流方式來回覆內容
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
type: boolean
required: true
default: false
@@ -69,28 +123,40 @@ spec:
label:
en_US: App Type
zh_Hans: 应用类型
zh_Hant: 應用類型
ja_JP: アプリタイプ
description:
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
type: select
options:
- name: self
label:
en_US: Self-built Application
zh_Hans: 自建应用
zh_Hant: 自建應用
ja_JP: カスタムアプリ
- name: isv
label:
en_US: Store Application
zh_Hans: 商店应用
zh_Hant: 商店應用
ja_JP: ストアアプリ
required: false
default: self
- name: bot_added_welcome
label:
en_US: Bot Welcome Message
zh_Hans: 机器人进群欢迎语
zh_Hant: 機器人進群歡迎語
ja_JP: ボット参加時のウェルカムメッセージ
description:
en_US: Welcome message when the bot is added to a group, supports Markdown format
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
zh_Hant: 機器人進群歡迎語,支援 Markdown 格式
ja_JP: ボットがグループに追加された際のウェルカムメッセージ。Markdown 形式に対応しています
type: text
required: false
default: ""

View File

@@ -5,20 +5,56 @@ metadata:
label:
en_US: LINE
zh_Hans: LINE
zh_Hant: LINE
th_TH: LINE
vi_VN: LINE
es_ES: LINE
description:
en_US: LINE Adapter
zh_Hans: LINE适配器请查看文档了解使用方式
ja_JP: LINEアダプター、ドキュメントを参照してください
zh_Hant: LINE適配器,請查看文檔了解使用方式
en_US: LINE Adapter, requires a public URL to receive LINE message pushes, please refer to the documentation for usage details
zh_Hans: LINE适配器需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式
zh_Hant: LINE 適配器,需要公網地址以接收 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
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:
- 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
label:
en_US: Channel access token
zh_Hans: 频道访问令牌
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
required: true
default: ""
@@ -27,12 +63,18 @@ spec:
en_US: Channel secret
zh_Hans: 消息密钥
ja_JP: チャンネルシークレット
zh_Hant: 息密
zh_Hant: 息密
th_TH: รหัสลับช่อง
vi_VN: Khóa bí mật kênh
es_ES: Secreto del canal
description:
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
zh_Hans: 请填写加密密钥
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
required: true
default: ""

View File

@@ -5,23 +5,44 @@ metadata:
label:
en_US: Official Account
zh_Hans: 微信公众号
zh_Hant: 微信公眾號
description:
en_US: Official Account Adapter
zh_Hans: 微信公众号适配器,请查看文档了解使用方式
zh_Hans: 微信公众号适配器,需要公网地址以接收消息推送,请查看文档了解使用方式
zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: officialaccount.png
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:
- 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
label:
en_US: Token
zh_Hans: 令牌
type: string
zh_Hant: 令牌
required: true
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥
zh_Hant: 訊息加解密密鑰
type: string
required: true
default: ""
@@ -29,6 +50,7 @@ spec:
label:
en_US: App ID
zh_Hans: 应用ID
zh_Hant: 應用ID
type: string
required: true
default: ""
@@ -36,6 +58,7 @@ spec:
label:
en_US: App Secret
zh_Hans: 应用密钥
zh_Hant: 應用密鑰
type: string
required: true
default: ""
@@ -43,6 +66,7 @@ spec:
label:
en_US: Mode
zh_Hans: 接入模式
zh_Hant: 接入模式
type: string
required: true
default: "drop"
@@ -50,6 +74,7 @@ spec:
label:
en_US: Loading Message
zh_Hans: 加载消息
zh_Hant: 載入訊息
type: string
required: true
default: "AI正在思考中请发送任意内容获取回复。"
@@ -57,9 +82,11 @@ spec:
label:
en_US: API Base URL
zh_Hans: API 基础 URL
zh_Hant: API 基礎 URL
description:
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API可根据文档修改此项
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取微信公眾號 API可根據文件修改此項
type: string
required: false
default: "https://api.weixin.qq.com"

View File

@@ -4,20 +4,31 @@ metadata:
name: openclaw-weixin
label:
en_US: OpenClaw WeChat
zh_Hans: OpenClaw 微信
zh_Hans: 个人微信机器人
zh_Hant: 個人微信機器人
description:
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
zh_Hans: OpenClaw 微信适配器,通过扫码登录支持个人微信
zh_Hans: 微信官方个人助手,扫码即可登录使用
zh_Hant: 微信官方個人助手,掃碼即可登入使用
icon: wechat.png
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:
- name: base_url
label:
en_US: API Base URL
zh_Hans: API 基础地址
zh_Hant: API 基礎地址
description:
en_US: The base URL of the OpenClaw WeChat backend API
zh_Hans: OpenClaw 微信后端 API 的基础地址
zh_Hant: OpenClaw 微信後端 API 的基礎地址
type: string
required: true
default: "https://ilinkai.weixin.qq.com"
@@ -25,9 +36,11 @@ spec:
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
description:
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
required: false
default: ""
@@ -35,9 +48,11 @@ spec:
label:
en_US: Account ID
zh_Hans: 账号标识
zh_Hant: 帳號標識
description:
en_US: A label for this WeChat account (used for display purposes)
zh_Hans: 此微信账号的标识(用于显示)
zh_Hant: 此微信帳號的標識(用於顯示)
type: string
required: false
default: "openclaw-weixin"
@@ -45,9 +60,11 @@ spec:
label:
en_US: Poll Timeout (seconds)
zh_Hans: 轮询超时(秒)
zh_Hant: 輪詢逾時(秒)
description:
en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration
zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长
zh_Hant: getUpdates 長輪詢逾時時間,伺服端最多持有請求的時長
type: integer
required: false
default: 35

View File

@@ -5,16 +5,37 @@ metadata:
label:
en_US: QQ Official API
zh_Hans: QQ 官方 API
zh_Hant: QQ 官方 API
description:
en_US: QQ Official API (Webhook)
zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式
zh_Hans: QQ 官方 API (Webhook)需要公网地址以接收消息推送,请查看文档了解使用方式
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: qqofficial.svg
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:
- 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
label:
en_US: App ID
zh_Hans: 应用ID
zh_Hant: 應用ID
type: string
required: true
default: ""
@@ -22,6 +43,7 @@ spec:
label:
en_US: Secret
zh_Hans: 密钥
zh_Hant: 密鑰
type: string
required: true
default: ""
@@ -29,6 +51,7 @@ spec:
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
type: string
required: true
default: ""

View File

@@ -5,36 +5,70 @@ metadata:
label:
en_US: Satori
zh_Hans: Satori
zh_Hant: Satori
th_TH: Satori
vi_VN: Satori
es_ES: Satori
description:
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
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:
- name: platform
label:
en_US: Platform
zh_Hans: 平台名称
zh_Hant: 平台名稱
th_TH: ชื่อแพลตฟอร์ม
vi_VN: Tên nền tảng
es_ES: Nombre de la plataforma
type: string
required: true
default: "llonebot"
description:
en_US: The platform name (e.g., 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
label:
en_US: Host
zh_Hans: 主机地址
zh_Hant: 主機地址
th_TH: ที่อยู่โฮสต์
vi_VN: Địa chỉ máy chủ
es_ES: Dirección del host
type: string
required: true
default: "127.0.0.1"
description:
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_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
label:
en_US: Port
zh_Hans: 监听端口
zh_Hant: 監聽連接埠
th_TH: พอร์ต
vi_VN: Cổng
es_ES: Puerto
type: integer
required: true
default: 5600
@@ -42,6 +76,10 @@ spec:
label:
en_US: Satori API Endpoint
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
required: true
default: "http://localhost:5600/v1"
@@ -49,6 +87,10 @@ spec:
label:
en_US: Satori WebSocket Endpoint
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
required: true
default: "ws://localhost:5600/v1/events"
@@ -56,6 +98,10 @@ spec:
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
th_TH: โทเค็น
vi_VN: Mã thông báo
es_ES: Token
type: string
required: true
default: ""

View File

@@ -5,16 +5,58 @@ metadata:
label:
en_US: Slack
zh_Hans: Slack
zh_Hant: Slack
ja_JP: Slack
th_TH: Slack
vi_VN: Slack
es_ES: Slack
description:
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
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:
- 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
label:
en_US: Bot Token
zh_Hans: 机器人令牌
zh_Hant: 機器人令牌
ja_JP: ボットトークン
th_TH: โทเค็นบอท
vi_VN: Mã thông báo Bot
es_ES: Token del bot
type: string
required: true
default: ""
@@ -22,6 +64,11 @@ spec:
label:
en_US: signing_secret
zh_Hans: 密钥
zh_Hant: 密鑰
ja_JP: 署名シークレット
th_TH: คีย์ลายเซ็น
vi_VN: Khóa ký
es_ES: Secreto de firma
type: string
required: true
default: ""

View File

@@ -5,23 +5,50 @@ metadata:
label:
en_US: Telegram
zh_Hans: 电报
zh_Hant: Telegram
ja_JP: Telegram
th_TH: Telegram
vi_VN: Telegram
es_ES: Telegram
description:
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
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:
- name: token
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
ja_JP: トークン
th_TH: โทเค็น
vi_VN: Mã thông báo
es_ES: Token
type: string
required: true
default: ""
default: "token_from_botfather"
- name: markdown_card
label:
en_US: Markdown Card
zh_Hans: 是否使用 Markdown 卡片
zh_Hant: 是否使用 Markdown 卡片
ja_JP: Markdown カードを使用
th_TH: การ์ด Markdown
vi_VN: Thẻ Markdown
es_ES: Tarjeta Markdown
type: boolean
required: false
default: true
@@ -29,9 +56,19 @@ spec:
label:
en_US: Enable Stream Reply Mode
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:
en_US: If enabled, the bot will use the stream of telegram reply mode
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
required: true
default: false

View File

@@ -5,11 +5,21 @@ metadata:
label:
en_US: "WebSocket Chat"
zh_Hans: "WebSocket 聊天"
zh_Hant: "WebSocket 聊天"
th_TH: "แชท WebSocket"
vi_VN: "Trò chuyện WebSocket"
es_ES: "Chat WebSocket"
description:
en_US: "WebSocket adapter for bidirectional real-time communication"
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: ""
spec:
categories:
- protocol
config: []
execution:
python:

View File

@@ -4,17 +4,26 @@ metadata:
name: wechatpad
label:
en_US: WeChatPad
zh_CN: WeChatPad个人微信ipad
zh_Hans: WeChatPad个人微信ipad
zh_Hant: WeChatPad個人微信iPad
description:
en_US: WeChatPad Adapter
zh_CN: WeChatPad 适配器
zh_Hans: WeChatPad 适配器基于WeChatPad的个人微信解决方案请查看文档了解使用方式
zh_Hant: WeChatPad 適配器,基於 WeChatPad 的個人微信解決方案,請查看文件了解使用方式
icon: wechatpad.png
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:
- name: wechatpad_url
label:
en_US: WeChatPad ERL
zh_CN: WeChatPad URL
zh_Hant: WeChatPad URL
type: string
required: true
default: ""
@@ -22,6 +31,7 @@ spec:
label:
en_US: WeChatPad_Ws
zh_CN: WeChatPad_Ws
zh_Hant: WeChatPad_Ws
type: string
required: true
default: ""
@@ -29,6 +39,7 @@ spec:
label:
en_US: Admin_Key
zh_CN: 管理员密匙
zh_Hant: 管理員密鑰
type: string
required: true
default: ""
@@ -36,6 +47,7 @@ spec:
label:
en_US: Token
zh_CN: 令牌
zh_Hant: 令牌
type: string
required: true
default: ""
@@ -43,6 +55,7 @@ spec:
label:
en_US: wxid
zh_CN: wxid
zh_Hant: wxid
type: string
required: true
default: ""

View File

@@ -5,16 +5,38 @@ metadata:
label:
en_US: WeCom
zh_Hans: 企业微信
zh_Hant: 企業微信
description:
en_US: WeCom Adapter
zh_Hans: 企业微信适配器,请查看文档了解使用方式
zh_Hans: 企业微信内部机器人,请查看文档了解使用方式
zh_Hant: 企業微信內部機器人,請查看文件了解使用方式
icon: wecom.png
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:
- 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
label:
en_US: Corpid
zh_Hans: 企业ID
zh_Hant: 企業ID
type: string
required: true
default: ""
@@ -22,6 +44,7 @@ spec:
label:
en_US: Secret
zh_Hans: 密钥 (Secret)
zh_Hant: 密鑰 (Secret)
type: string
required: true
default: ""
@@ -29,6 +52,7 @@ spec:
label:
en_US: Token
zh_Hans: 令牌 (Token)
zh_Hant: 令牌 (Token)
type: string
required: true
default: ""
@@ -36,6 +60,7 @@ spec:
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey)
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
type: string
required: true
default: ""
@@ -43,9 +68,11 @@ spec:
label:
en_US: API Base URL
zh_Hans: API 基础 URL
zh_Hant: API 基礎 URL
description:
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API可根据文档填写此项
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API可根據文件填寫此項
type: string
required: false
default: "https://qyapi.weixin.qq.com/cgi-bin"

View File

@@ -311,6 +311,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.bot.on_message('single')(self.on_message)
elif event_type == platform_events.GroupMessage:
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:
print(traceback.format_exc())
@@ -318,6 +321,50 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""设置 bot UUID用于生成 webhook URL"""
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
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):
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:

View File

@@ -5,16 +5,25 @@ metadata:
label:
en_US: WeComBot
zh_Hans: 企业微信智能机器人
zh_Hant: 企業微信智慧機器人
description:
en_US: WeComBot Adapter
zh_Hans: 企业微信智能机器人适配器,请查看文档了解使用方式
zh_Hans: 企业微信智能机器人,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
icon: wecombot.png
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:
- name: BotId
label:
en_US: BotId
zh_Hans: 机器人ID (BotId)
zh_Hant: 機器人ID (BotId)
type: string
required: true
default: ""
@@ -22,6 +31,7 @@ spec:
label:
en_US: Robot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
type: string
required: true
default: ""
@@ -29,19 +39,39 @@ spec:
label:
en_US: Enable Webhook Mode
zh_Hans: 启用Webhook模式
zh_Hant: 啟用 Webhook 模式
description:
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
type: boolean
required: true
default: false
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your 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
label:
en_US: Secret
zh_Hans: 机器人密钥 (Secret)
zh_Hant: 機器人密鑰 (Secret)
description:
en_US: Required for WebSocket long connection mode
zh_Hans: 使用 WS 长连接模式时必填
zh_Hant: 使用 WS 長連線模式時必填
type: string
required: false
default: ""
@@ -49,9 +79,11 @@ spec:
label:
en_US: Corpid
zh_Hans: 企业ID
zh_Hant: 企業ID
description:
en_US: Required for Webhook mode
zh_Hans: 使用 Webhook 模式时必填
zh_Hant: 使用 Webhook 模式時必填
type: string
required: false
default: ""
@@ -59,9 +91,11 @@ spec:
label:
en_US: Token
zh_Hans: 令牌 (Token)
zh_Hant: 令牌 (Token)
description:
en_US: Required for Webhook mode
zh_Hans: 使用 Webhook 模式时必填
zh_Hant: 使用 Webhook 模式時必填
type: string
required: false
default: ""
@@ -69,9 +103,11 @@ spec:
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey)
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
description:
en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
zh_Hant: 使用 Webhook 模式時必填。WebSocket 模式下可選(用於檔案解密)
type: string
required: false
default: ""
@@ -79,9 +115,11 @@ spec:
label:
en_US: Enable Stream Reply
zh_Hans: 启用流式回复
zh_Hant: 啟用串流回覆
description:
en_US: If enabled, the bot will use streaming mode to reply messages
zh_Hans: 如果启用,机器人将使用流式模式回复消息
zh_Hant: 如果啟用,機器人將使用串流模式回覆訊息
type: boolean
required: false
default: true

View File

@@ -5,16 +5,37 @@ metadata:
label:
en_US: WeComCustomerService
zh_Hans: 企业微信客服
zh_Hant: 企業微信客服
description:
en_US: WeComCSAdapter
zh_Hans: 企业微信客服适配器
zh_Hans: 企业微信对外客服机器人,需要公网地址以接收消息推送,请查看文档了解使用方式
zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: wecom.png
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:
- 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
label:
en_US: Corpid
zh_Hans: 企业ID
zh_Hant: 企業ID
type: string
required: true
default: ""
@@ -22,6 +43,7 @@ spec:
label:
en_US: Secret
zh_Hans: 密钥
zh_Hant: 密鑰
type: string
required: true
default: ""
@@ -29,6 +51,7 @@ spec:
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
type: string
required: true
default: ""
@@ -36,6 +59,7 @@ spec:
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥
zh_Hant: 訊息加解密密鑰
type: string
required: true
default: ""
@@ -43,9 +67,11 @@ spec:
label:
en_US: API Base URL
zh_Hans: API 基础 URL
zh_Hant: API 基礎 URL
description:
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API可根据文档修改此项
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API可根據文件修改此項
type: string
required: false
default: "https://qyapi.weixin.qq.com/cgi-bin"

View File

@@ -2,6 +2,9 @@
from __future__ import annotations
import asyncio
import io
import time
import zipfile
from typing import Any
import typing
import os
@@ -192,6 +195,30 @@ class PluginRuntimeConnector:
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(
self,
install_source: PluginInstallSource,
@@ -201,23 +228,44 @@ class PluginRuntimeConnector:
if install_source == PluginInstallSource.LOCAL:
# transfer file before install
file_bytes = install_info['plugin_file']
self._extract_deps_metadata(file_bytes, task_context)
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
del install_info['plugin_file']
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
elif install_source == PluginInstallSource.GITHUB:
# download and transfer file
# download and transfer file with streaming progress
try:
async with httpx.AsyncClient(
trust_env=True,
follow_redirects=True,
timeout=20,
timeout=60,
) as client:
response = await client.get(
install_info['asset_url'],
)
response.raise_for_status()
file_bytes = response.content
async with client.stream('GET', install_info['asset_url']) as response:
response.raise_for_status()
total = int(response.headers.get('content-length', 0))
downloaded = 0
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')
install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
@@ -236,6 +284,11 @@ class PluginRuntimeConnector:
if task_context is not None:
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(
self,
plugin_author: str,

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}'
required_database_version = 24
required_database_version = 26
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False

View File

@@ -38,28 +38,31 @@ def get_frontend_path() -> str:
"""
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
- Package mode: installed via pip/uvx
- Legacy mode: web/out (Next.js, for backward compatibility)
"""
# First, check if we're running from source directory
if _check_if_source_install() and os.path.exists('web/out'):
return 'web/out'
# Check both dist (Vite) and out (legacy Next.js) paths
for dirname in ('dist', 'out'):
web_dir = f'web/{dirname}'
# Second, check current directory for web/out (in case user is in source dir)
if os.path.exists('web/out'):
return 'web/out'
# First, check if we're running from source directory
if _check_if_source_install() and os.path.exists(web_dir):
return web_dir
# Third, find it relative to the package installation
# Get the directory where this file is located
# paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root
pkg_dir = Path(__file__).parent.parent.parent
frontend_path = pkg_dir / 'web' / 'out'
if frontend_path.exists():
return str(frontend_path)
# Second, check current directory
if os.path.exists(web_dir):
return web_dir
# Third, find it relative to the package installation
pkg_dir = Path(__file__).parent.parent.parent
frontend_path = pkg_dir / 'web' / dirname
if frontend_path.exists():
return str(frontend_path)
# Return the default path (will be checked by caller)
return 'web/out'
return 'web/dist'
def get_resource_path(resource: str) -> str:

View File

@@ -203,7 +203,7 @@ class VersionManager:
try:
if await self.ap.ver_mgr.is_new_version_available():
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,
)

View File

@@ -20,6 +20,7 @@ system:
edition: community
recovery_key: ''
allow_modify_login_info: true
disabled_adapters: []
limitation:
max_bots: -1
max_pipelines: -1

View File

@@ -23,30 +23,30 @@ stages:
label:
en_US: Local Agent
zh_Hans: 内置 Agent
- name: tbox-app-api
label:
en_US: Tbox App API
zh_Hans: 蚂蚁百宝箱平台 API
- name: dify-service-api
label:
en_US: Dify Service API
zh_Hans: Dify 服务 API
- name: dashscope-app-api
label:
en_US: Aliyun Dashscope App API
zh_Hans: 阿里云百炼平台 API
- name: n8n-service-api
label:
en_US: n8n Workflow API
zh_Hans: n8n 工作流 API
- name: langflow-api
label:
en_US: Langflow API
zh_Hans: Langflow API
- name: coze-api
label:
en_US: Coze 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
label:
en_US: Local Agent
@@ -104,28 +104,6 @@ stages:
field: __system.is_wizard
operator: neq
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
label:
en_US: Dify Service API
@@ -140,6 +118,11 @@ stages:
zh_Hans: 基础 URL
type: string
required: true
options:
- name: 'https://api.dify.ai/v1'
label:
en_US: Dify Cloud
zh_Hans: Dify 云服务
default: 'https://api.dify.ai/v1'
- name: base-prompt
label:
@@ -178,54 +161,6 @@ stages:
type: string
required: true
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
label:
en_US: n8n Workflow API
@@ -375,6 +310,140 @@ stages:
type: string
required: false
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
label:
en_US: Langflow API
@@ -442,59 +511,4 @@ stages:
zh_Hans: 可选的流程调整参数
type: json
required: false
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
default: '{}'

View File

View File

@@ -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

10
uv.lock generated
View File

@@ -1832,7 +1832,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.9.4"
version = "4.9.5"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -1937,7 +1937,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.3.5" },
{ name = "langbot-plugin", specifier = "==0.3.7" },
{ name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
{ name = "lark-oapi", specifier = ">=1.4.15" },
@@ -1993,7 +1993,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.3.5"
version = "0.3.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2011,9 +2011,9 @@ dependencies = [
{ name = "watchdog" },
{ 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/12/31/8dc7106cb65004a01e363308343c5a95e35f1722f26c87853e6e12c6fee1/langbot_plugin-0.3.7.tar.gz", hash = "sha256:bc0dea6b1c515d9fc8c3ab14af74bdf3e006d7e20c097b6cb5034f5af4a73cc9", size = 179764, upload-time = "2026-04-03T09:43:17.343Z" }
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/a9/51/1982c199bd4efbfa3c327c95cca7e4ab502610251567000b348c72bca1b1/langbot_plugin-0.3.7-py3-none-any.whl", hash = "sha256:2e2b9e99163ceb14da28b8ce7c4cbc6990dea15684ec78976bc015e5378feea2", size = 157324, upload-time = "2026-04-03T09:43:15.782Z" },
]
[[package]]

View File

@@ -1 +1 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:5300
VITE_API_BASE_URL=http://localhost:5300

1
web/.gitignore vendored
View File

@@ -14,6 +14,7 @@
/coverage
# next.js
/dist/
/.next/
/out/

View File

@@ -1,3 +1,3 @@
# 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.

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",

View File

@@ -1,18 +1,27 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
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,
{
ignores: ['dist/**', 'node_modules/**'],
},
];
export default eslintConfig;

2
web/fix_router.sh Normal file
View File

@@ -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

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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>

29
web/migrate.sh Executable file
View File

@@ -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' {} +

View File

@@ -1,8 +0,0 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
output: 'export',
};
export default nextConfig;

4340
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,16 +3,15 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"lint-staged": "lint-staged"
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write ."
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"next lint --fix",
"eslint --fix",
"prettier --write"
]
},
@@ -34,6 +33,7 @@
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-label": "^2.1.6",
"@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-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.8",
@@ -45,6 +45,7 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "^6.0.1",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -54,8 +55,6 @@
"input-otp": "^1.4.2",
"lodash": "^4.17.23",
"lucide-react": "^0.507.0",
"next": "~16.1.5",
"next-themes": "^0.4.6",
"postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "19.2.1",
@@ -64,6 +63,7 @@
"react-i18next": "^15.5.1",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
"react-router-dom": "^7.14.0",
"react-syntax-highlighter": "^16.1.0",
"recharts": "2.15.4",
"rehype-autolink-headings": "^7.1.0",
@@ -76,10 +76,10 @@
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
"uuidjs": "^5.1.0",
"vite": "^8.0.3",
"zod": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/debug": "^4.1.12",
"@types/estree": "^1.0.8",
"@types/estree-jsx": "^1.0.5",
@@ -94,9 +94,10 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/unist": "^3.0.3",
"eslint": "^9",
"eslint-config-next": "15.2.4",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3",
"tw-animate-css": "^1.2.9",

5592
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,5 @@
'use client';
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 { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -23,8 +21,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
import langbotIcon from '@/app/assets/langbot-logo.webp';
function SpaceOAuthCallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
const [status, setStatus] = useState<
@@ -51,7 +49,7 @@ function SpaceOAuthCallbackContent() {
const wizardState = localStorage.getItem('langbot_wizard_state');
const redirectTo = wizardState ? '/wizard' : '/home';
setTimeout(() => {
router.push(redirectTo);
navigate(redirectTo);
}, 1000);
} catch (err) {
setStatus('error');
@@ -64,7 +62,7 @@ function SpaceOAuthCallbackContent() {
}
}
},
[router, t],
[navigate, t],
);
const [bindState, setBindState] = useState<string | null>(null);
@@ -81,7 +79,7 @@ function SpaceOAuthCallbackContent() {
setStatus('success');
toast.success(t('account.bindSpaceSuccess'));
setTimeout(() => {
router.push('/home');
navigate('/home');
}, 1000);
} catch (err) {
setStatus('error');
@@ -96,7 +94,7 @@ function SpaceOAuthCallbackContent() {
setIsProcessing(false);
}
},
[router, t],
[navigate, t],
);
useEffect(() => {
@@ -146,7 +144,7 @@ function SpaceOAuthCallbackContent() {
};
const handleCancelBind = () => {
router.push('/home');
navigate('/home');
};
return (
@@ -154,7 +152,7 @@ function SpaceOAuthCallbackContent() {
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
<CardHeader className="text-center">
<img
src={langbotIcon.src}
src={langbotIcon}
alt="LangBot"
className="w-16 h-16 mb-4 mx-auto"
/>
@@ -217,7 +215,7 @@ function SpaceOAuthCallbackContent() {
<>
<AlertCircle className="h-12 w-12 text-red-500" />
<Button
onClick={() => router.push(isBindMode ? '/home' : '/login')}
onClick={() => navigate(isBindMode ? '/home' : '/login')}
className="w-full mt-4"
>
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}

View File

@@ -1,3 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
/* 适用于 Firefox 的滚动条 */
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
@@ -72,9 +74,7 @@
}
}
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));

View File

@@ -1,7 +1,5 @@
'use client';
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 { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
@@ -34,7 +32,7 @@ import { toast } from 'sonner';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const router = useRouter();
const navigate = useNavigate();
const { t } = useTranslation();
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
@@ -105,12 +103,12 @@ export default function BotDetailContent({ id }: { id: string }) {
function handleBotDeleted() {
refreshBots();
router.push('/home/bots');
navigate('/home/bots');
}
function handleNewBotCreated(newBotId: string) {
refreshBots();
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
}
function confirmDelete() {
@@ -176,9 +174,11 @@ export default function BotDetailContent({ id }: { id: string }) {
</div>
)}
</div>
<Button type="submit" form="bot-form" disabled={!formDirty}>
{t('common.save')}
</Button>
{activeTab === 'config' && (
<Button type="submit" form="bot-form" disabled={!formDirty}>
{t('common.save')}
</Button>
)}
</div>
{/* Horizontal Tabs */}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import i18n from 'i18next';
import {
IChooseAdapterEntity,
IPipelineEntity,
@@ -13,15 +14,16 @@ import { UUID } from 'uuidjs';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
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 { useForm } from 'react-hook-form';
import { z } from 'zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Copy, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
@@ -37,6 +39,7 @@ import {
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
@@ -49,6 +52,10 @@ import {
} from '@/components/ui/card';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { CustomApiError } from '@/app/infra/entities/common';
import {
groupByCategory,
getCategoryLabel,
} from '@/app/infra/entities/adapter-categories';
const getFormSchema = (t: (key: string) => string) =>
z.object({
@@ -58,6 +65,28 @@ const getFormSchema = (t: (key: string) => string) =>
adapter_config: z.record(z.string(), z.any()),
enable: z.boolean().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({
@@ -83,6 +112,7 @@ export default function BotForm({
adapter_config: {},
enable: true,
use_pipeline_uuid: '',
pipeline_routing_rules: [],
},
});
@@ -99,6 +129,9 @@ export default function BotForm({
const [adapterDescriptionList, setAdapterDescriptionList] = useState<
Record<string, string>
>({});
const [adapterHelpLinks, setAdapterHelpLinks] = useState<
Record<string, Record<string, string>>
>({});
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
[],
@@ -110,29 +143,16 @@ export default function BotForm({
const [, setIsLoading] = useState<boolean>(false);
const [webhookUrl, setWebhookUrl] = 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
const currentAdapter = form.watch('adapter');
const currentAdapterConfig = form.watch('adapter_config');
// Derive the filtered config list via useMemo instead of useEffect+setState
// to avoid creating new array references that would cause DynamicFormComponent
// to re-subscribe its form.watch, re-emit values, and trigger an infinite loop.
// Only depend on the specific field we care about (enable-webhook) rather than
// 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]);
// Group adapters by category for the Select dropdown
const groupedAdapters = useMemo(
() => groupByCategory(adapterNameList),
[adapterNameList],
);
// Notify parent when dirty state changes
const { isDirty } = form.formState;
@@ -144,43 +164,6 @@ export default function BotForm({
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() {
isInitializing.current = true;
initBotFormComponent().then(() => {
@@ -196,6 +179,7 @@ export default function BotForm({
adapter_config: val.adapter_config,
enable: val.enable,
use_pipeline_uuid: val.use_pipeline_uuid || '',
pipeline_routing_rules: val.pipeline_routing_rules || [],
});
handleAdapterSelect(val.adapter);
@@ -241,6 +225,7 @@ export default function BotForm({
return {
label: extractI18nObject(item.label),
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) => {
adapterNameToDynamicConfigMap.set(
rawAdapter.name,
@@ -298,6 +295,7 @@ export default function BotForm({
adapter_config: bot.adapter_config,
enable: bot.enable ?? true,
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
webhook_full_url: runtimeValues?.webhook_full_url as
| string
| undefined,
@@ -342,6 +340,7 @@ export default function BotForm({
adapter_config: form.getValues().adapter_config,
enable: form.getValues().enable,
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
};
httpClient
.updateBot(initBotId, updateBot)
@@ -384,12 +383,6 @@ export default function BotForm({
}
}
// --- Webhook URL display helper ---
const showWebhook =
initBotId &&
webhookUrl &&
(currentAdapter !== 'lark' || enableWebhook !== false);
return (
<Form {...form}>
<form
@@ -498,6 +491,12 @@ export default function BotForm({
</FormItem>
)}
/>
{/* Pipeline Routing Rules */}
<RoutingRulesEditor
form={form}
pipelineNameList={pipelineNameList}
/>
</CardContent>
</Card>
)}
@@ -521,48 +520,81 @@ export default function BotForm({
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-[240px]">
{field.value ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(field.value)}
alt=""
className="h-5 w-5 rounded"
<div className="flex items-center gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-[240px]">
{field.value ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(field.value)}
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(
(a) => a.value === field.value,
)?.label ?? field.value}
</span>
</div>
) : (
<SelectValue placeholder={t('bots.selectAdapter')} />
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{adapterNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(item.value)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{item.label}</span>
</div>
</SelectItem>
)}
</SelectTrigger>
<SelectContent>
{groupedAdapters.map((group) => (
<SelectGroup
key={group.categoryId ?? 'uncategorized'}
>
{group.categoryId && (
<SelectLabel>
{getCategoryLabel(t, group.categoryId)}
</SelectLabel>
)}
{group.items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectGroup>
</SelectContent>
</Select>
</SelectContent>
</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>
{currentAdapter && adapterDescriptionList[currentAdapter] && (
<FormDescription>
@@ -574,75 +606,19 @@ export default function BotForm({
)}
/>
{/* Webhook URL: shown after adapter is selected (edit mode only) */}
{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 && (
{showDynamicForm && dynamicFormConfigList.length > 0 && (
<DynamicFormComponent
itemConfigList={filteredDynamicFormConfigList}
itemConfigList={dynamicFormConfigList}
initialValues={currentAdapterConfig}
onSubmit={(values) => {
form.setValue('adapter_config', values, {
shouldDirty: !isInitializing.current,
});
}}
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
}}
/>
)}
</CardContent>

View File

@@ -1,6 +1,7 @@
export interface IChooseAdapterEntity {
label: string;
value: string;
categories?: string[];
}
export interface IPipelineEntity {

View File

@@ -0,0 +1,480 @@
'use client';
import { useTranslation } from 'react-i18next';
import { UseFormReturn } from 'react-hook-form';
import {
PipelineRoutingRule,
RoutingRuleOperator,
} from '@/app/infra/entities/api';
import { Ban, GripVertical, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FormLabel } from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DndContext,
DragOverlay,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useRef, useMemo, useState } from 'react';
export const PIPELINE_DISCARD = '__discard__';
interface PipelineOption {
value: string;
label: string;
emoji?: string;
}
interface RoutingRulesEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: UseFormReturn<any>;
pipelineNameList: PipelineOption[];
}
const OPERATORS_BY_TYPE: Record<
PipelineRoutingRule['type'],
{ value: RoutingRuleOperator; labelKey: string }[]
> = {
launcher_type: [
{ value: 'eq', labelKey: 'bots.operatorEq' },
{ value: 'neq', labelKey: 'bots.operatorNeq' },
],
launcher_id: [
{ value: 'eq', labelKey: 'bots.operatorEq' },
{ value: 'neq', labelKey: 'bots.operatorNeq' },
{ value: 'contains', labelKey: 'bots.operatorContains' },
{ value: 'not_contains', labelKey: 'bots.operatorNotContains' },
{ value: 'regex', labelKey: 'bots.operatorRegex' },
],
message_content: [
{ value: 'eq', labelKey: 'bots.operatorEq' },
{ value: 'neq', labelKey: 'bots.operatorNeq' },
{ value: 'contains', labelKey: 'bots.operatorContains' },
{ value: 'not_contains', labelKey: 'bots.operatorNotContains' },
{ value: 'starts_with', labelKey: 'bots.operatorStartsWith' },
{ value: 'regex', labelKey: 'bots.operatorRegex' },
],
message_has_element: [
{ value: 'eq', labelKey: 'bots.operatorHas' },
{ value: 'neq', labelKey: 'bots.operatorNotHas' },
],
};
function getValuePlaceholder(
t: (key: string) => string,
rule: PipelineRoutingRule,
): string {
if (rule.type === 'launcher_id')
return t('bots.ruleValueLauncherIdPlaceholder');
if (rule.type === 'message_has_element')
return t('bots.ruleValueElementPlaceholder');
if (rule.operator === 'regex') return t('bots.ruleValueRegexpPlaceholder');
return t('bots.ruleValueMessagePlaceholder');
}
/* ── Static rule row (used in DragOverlay) ─────────────────────────── */
interface RuleRowContentProps {
rule: PipelineRoutingRule;
index: number;
pipelineNameList: PipelineOption[];
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
removeRule: (index: number) => void;
dragHandleProps?: Record<string, unknown>;
isOverlay?: boolean;
}
function RuleRowContent({
rule,
index,
pipelineNameList,
updateRule,
removeRule,
dragHandleProps,
isOverlay,
}: RuleRowContentProps) {
const { t } = useTranslation();
const operatorsForType =
OPERATORS_BY_TYPE[rule.type] || OPERATORS_BY_TYPE.message_content;
const isDiscard = rule.pipeline_uuid === PIPELINE_DISCARD;
return (
<div
className={`flex items-center gap-2 mt-2 p-3 border rounded-md bg-muted/30 ${
isOverlay ? 'shadow-lg ring-2 ring-primary/20 bg-background' : ''
}`}
>
{/* Drag handle */}
<button
type="button"
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none"
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
</button>
{/* Field selector */}
<Select
value={rule.type}
onValueChange={(val) => {
updateRule(index, {
type: val as PipelineRoutingRule['type'],
operator: 'eq',
value: '',
});
}}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="launcher_type">
{t('bots.ruleTypeLauncherType')}
</SelectItem>
<SelectItem value="launcher_id">
{t('bots.ruleTypeLauncherId')}
</SelectItem>
<SelectItem value="message_content">
{t('bots.ruleTypeMessageContent')}
</SelectItem>
<SelectItem value="message_has_element">
{t('bots.ruleTypeMessageHasElement')}
</SelectItem>
</SelectContent>
</Select>
{/* Operator selector */}
<Select
value={rule.operator || 'eq'}
onValueChange={(val) => {
updateRule(index, { operator: val as RoutingRuleOperator });
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operatorsForType.map((op) => (
<SelectItem key={op.value} value={op.value}>
{t(op.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value input */}
{rule.type === 'launcher_type' ? (
<Select
value={rule.value}
onValueChange={(val) => updateRule(index, { value: val })}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder={t('bots.ruleValuePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="person">
{t('bots.sessionTypePerson')}
</SelectItem>
<SelectItem value="group">{t('bots.sessionTypeGroup')}</SelectItem>
</SelectContent>
</Select>
) : rule.type === 'message_has_element' ? (
<Select
value={rule.value}
onValueChange={(val) => updateRule(index, { value: val })}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t('bots.ruleValueElementPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Image">{t('bots.elementImage')}</SelectItem>
<SelectItem value="Voice">{t('bots.elementVoice')}</SelectItem>
<SelectItem value="File">{t('bots.elementFile')}</SelectItem>
<SelectItem value="Forward">{t('bots.elementForward')}</SelectItem>
<SelectItem value="Face">{t('bots.elementFace')}</SelectItem>
<SelectItem value="At">{t('bots.elementAt')}</SelectItem>
<SelectItem value="AtAll">{t('bots.elementAtAll')}</SelectItem>
<SelectItem value="Quote">{t('bots.elementQuote')}</SelectItem>
</SelectContent>
</Select>
) : (
<Input
className="flex-1"
placeholder={getValuePlaceholder(t, rule)}
value={rule.value}
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
)}
<span className="text-sm text-muted-foreground shrink-0"></span>
{/* Pipeline selector */}
<Select
value={rule.pipeline_uuid}
onValueChange={(val) => updateRule(index, { pipeline_uuid: val })}
>
<SelectTrigger className="w-[200px]">
{rule.pipeline_uuid ? (
isDiscard ? (
<div className="flex items-center gap-2 text-destructive">
<Ban className="h-3.5 w-3.5 shrink-0" />
<span>{t('bots.pipelineDiscard')}</span>
</div>
) : (
(() => {
const p = pipelineNameList.find(
(p) => p.value === rule.pipeline_uuid,
);
return (
<div className="flex items-center gap-2">
{p?.emoji && (
<span className="text-sm shrink-0">{p.emoji}</span>
)}
<span>{p?.label ?? rule.pipeline_uuid}</span>
</div>
);
})()
)
) : (
<SelectValue placeholder={t('bots.selectPipeline')} />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value={PIPELINE_DISCARD}>
<div className="flex items-center gap-2 text-destructive">
<Ban className="h-3.5 w-3.5 shrink-0" />
<span>{t('bots.pipelineDiscard')}</span>
</div>
</SelectItem>
<SelectSeparator />
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
{item.emoji && (
<span className="text-sm shrink-0">{item.emoji}</span>
)}
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeRule(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
);
}
/* ── Sortable rule row ─────────────────────────────────────────────── */
interface SortableRuleRowProps {
id: string;
rule: PipelineRoutingRule;
index: number;
pipelineNameList: PipelineOption[];
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
removeRule: (index: number) => void;
}
function SortableRuleRow({
id,
rule,
index,
pipelineNameList,
updateRule,
removeRule,
}: SortableRuleRowProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
// No transition — items reorder visually during drag via transform;
// on drop the data updates and transform resets, so animating would
// cause a redundant "swap" flicker.
opacity: isDragging ? 0.3 : undefined,
};
return (
<div ref={setNodeRef} style={style}>
<RuleRowContent
rule={rule}
index={index}
pipelineNameList={pipelineNameList}
updateRule={updateRule}
removeRule={removeRule}
dragHandleProps={{ ...attributes, ...listeners }}
/>
</div>
);
}
/* ── Main editor ───────────────────────────────────────────────────── */
export default function RoutingRulesEditor({
form,
pipelineNameList,
}: RoutingRulesEditorProps) {
const { t } = useTranslation();
const [activeId, setActiveId] = useState<string | null>(null);
const rules: PipelineRoutingRule[] =
form.watch('pipeline_routing_rules') || [];
// Stable unique ids for sortable items.
// We keep a running counter so newly added rules always get fresh ids.
const nextId = useRef(0);
const idsRef = useRef<string[]>([]);
const sortableIds = useMemo(() => {
// Grow the id list to match rules length (newly added items get new ids).
while (idsRef.current.length < rules.length) {
idsRef.current.push(`rule-${nextId.current++}`);
}
// Shrink if rules were removed from the end.
if (idsRef.current.length > rules.length) {
idsRef.current = idsRef.current.slice(0, rules.length);
}
return idsRef.current;
}, [rules.length]);
const updateRules = (newRules: PipelineRoutingRule[]) => {
form.setValue('pipeline_routing_rules', newRules, { shouldDirty: true });
};
const addRule = () => {
updateRules([
...rules,
{
type: 'launcher_type',
operator: 'eq',
value: '',
pipeline_uuid: '',
},
]);
};
const updateRule = (index: number, patch: Partial<PipelineRoutingRule>) => {
const updated = [...rules];
updated[index] = { ...updated[index], ...patch };
updateRules(updated);
};
const removeRule = (index: number) => {
const updated = [...rules];
updated.splice(index, 1);
// Also remove the corresponding sortable id so indices stay in sync.
idsRef.current.splice(index, 1);
updateRules(updated);
};
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortableIds.indexOf(active.id as string);
const newIndex = sortableIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
updateRules(arrayMove(rules, oldIndex, newIndex));
};
const activeIndex = activeId ? sortableIds.indexOf(activeId) : -1;
const activeRule = activeIndex >= 0 ? rules[activeIndex] : null;
return (
<div className="mt-6">
<div className="flex items-center justify-between mb-2">
<div>
<FormLabel>{t('bots.routingRules')}</FormLabel>
<p className="text-sm text-muted-foreground mt-1">
{t('bots.routingRulesDescription')}
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addRule}>
<Plus className="h-4 w-4 mr-1" />
{t('bots.addRoutingRule')}
</Button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortableIds}
strategy={verticalListSortingStrategy}
>
{rules.map((rule, index) => (
<SortableRuleRow
key={sortableIds[index]}
id={sortableIds[index]}
rule={rule}
index={index}
pipelineNameList={pipelineNameList}
updateRule={updateRule}
removeRule={removeRule}
/>
))}
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeRule ? (
<RuleRowContent
rule={activeRule}
index={activeIndex}
pipelineNameList={pipelineNameList}
updateRule={updateRule}
removeRule={removeRule}
isOverlay
/>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}

View File

@@ -1,5 +1,3 @@
'use client';
import { useState } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import { httpClient } from '@/app/infra/http/HttpClient';

View File

@@ -1,5 +1,3 @@
'use client';
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -15,7 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { ChevronDownIcon, ExternalLink } from 'lucide-react';
import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation';
import { useNavigate } from 'react-router-dom';
export function BotLogListComponent({
botId,
@@ -32,7 +30,7 @@ export function BotLogListComponent({
hideToolbar?: boolean;
}) {
const { t } = useTranslation();
const router = useRouter();
const navigate = useNavigate();
const manager = useRef(new BotLogManager(botId)).current;
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
const [autoFlush, setAutoFlush] = useState(true);
@@ -231,7 +229,7 @@ export function BotLogListComponent({
variant="outline"
size="sm"
className="gap-1"
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
onClick={() => navigate(`/home/monitoring?botId=${botId}`)}
>
<ExternalLink className="size-3.5" />
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>

View File

@@ -1,5 +1,3 @@
'use client';
import React, {
useState,
useEffect,
@@ -12,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { Copy, Check } from 'lucide-react';
import { Ban, Bot, Copy, Check, Workflow, UserCheck, Send } from 'lucide-react';
import {
MessageChainComponent,
Plain,
@@ -21,6 +19,7 @@ import {
Quote,
Voice,
} from '@/app/infra/entities/message';
import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/RoutingRulesEditor';
interface SessionInfo {
session_id: string;
@@ -78,6 +77,16 @@ const BotSessionMonitor = forwardRef<
const [copiedUserId, setCopiedUserId] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
// Human takeover state
const [isTakenOver, setIsTakenOver] = useState(false);
const [takeoverLoading, setTakeoverLoading] = useState(false);
const [operatorMessage, setOperatorMessage] = useState('');
const [sendingMessage, setSendingMessage] = useState(false);
// Track which sessions are taken over for showing badges in the list
const [takenOverSessions, setTakenOverSessions] = useState<Set<string>>(
new Set(),
);
const parseSessionType = (sessionId: string): string | null => {
const idx = sessionId.indexOf('_');
if (idx === -1) return null;
@@ -110,6 +119,24 @@ const BotSessionMonitor = forwardRef<
}
}, [botId]);
// Load active takeover sessions to know which ones show a badge
const loadTakeoverStatus = useCallback(async () => {
try {
const response = await httpClient.getHumanTakeoverSessions({
botUuid: botId,
});
const activeIds = new Set<string>();
for (const session of response.sessions ?? []) {
if (session.status === 'active') {
activeIds.add(session.session_id);
}
}
setTakenOverSessions(activeIds);
} catch {
// Silently ignore — takeover feature may not be available
}
}, [botId]);
useImperativeHandle(
ref,
() => ({
@@ -134,29 +161,131 @@ const BotSessionMonitor = forwardRef<
}
}, []);
// Check takeover status for selected session
const checkTakeoverStatus = useCallback(
async (sessionId: string) => {
try {
const response =
await httpClient.getHumanTakeoverSessionDetail(sessionId);
const isActive =
response.found && response.session?.status === 'active';
setIsTakenOver(isActive);
} catch {
setIsTakenOver(false);
}
},
[],
);
useEffect(() => {
loadSessions();
}, [loadSessions]);
loadTakeoverStatus();
}, [loadSessions, loadTakeoverStatus]);
useEffect(() => {
if (selectedSessionId) {
loadMessages(selectedSessionId);
checkTakeoverStatus(selectedSessionId);
} else {
setMessages([]);
setIsTakenOver(false);
}
}, [selectedSessionId, loadMessages]);
}, [selectedSessionId, loadMessages, checkTakeoverStatus]);
// Auto-refresh messages when session is taken over (polling)
useEffect(() => {
if (!selectedSessionId || !isTakenOver) return;
const interval = setInterval(() => {
loadMessages(selectedSessionId);
}, 3000);
return () => clearInterval(interval);
}, [selectedSessionId, isTakenOver, loadMessages]);
useEffect(() => {
const container = messagesContainerRef.current;
if (container) {
const viewport = container.querySelector(
'[data-radix-scroll-area-viewport]',
);
const scrollTarget = viewport || container;
scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
if (messages.length === 0) return;
// Wait for DOM to render the new messages before scrolling
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) {
const viewport = container.querySelector(
'[data-radix-scroll-area-viewport]',
);
const scrollTarget = viewport || container;
scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
});
}, [messages]);
const handleTakeover = async () => {
if (!selectedSessionId || !selectedSession) return;
if (!confirm(t('bots.sessionMonitor.takeoverConfirm'))) return;
setTakeoverLoading(true);
try {
await httpClient.takeoverSession(selectedSessionId, {
bot_uuid: botId,
platform: selectedSession.platform ?? undefined,
user_id: selectedSession.user_id ?? undefined,
user_name: selectedSession.user_name ?? undefined,
});
setIsTakenOver(true);
setTakenOverSessions((prev) => new Set(prev).add(selectedSessionId));
} catch (error) {
console.error('Takeover failed:', error);
alert(t('bots.sessionMonitor.takeoverFailed'));
} finally {
setTakeoverLoading(false);
}
};
const handleRelease = async () => {
if (!selectedSessionId) return;
if (!confirm(t('bots.sessionMonitor.releaseConfirm'))) return;
setTakeoverLoading(true);
try {
await httpClient.releaseSession(selectedSessionId);
setIsTakenOver(false);
setTakenOverSessions((prev) => {
const next = new Set(prev);
next.delete(selectedSessionId);
return next;
});
} catch (error) {
console.error('Release failed:', error);
alert(t('bots.sessionMonitor.releaseFailed'));
} finally {
setTakeoverLoading(false);
}
};
const handleSendMessage = async () => {
if (!selectedSessionId || !operatorMessage.trim()) return;
setSendingMessage(true);
try {
await httpClient.sendTakeoverMessage(
selectedSessionId,
operatorMessage.trim(),
);
setOperatorMessage('');
// Reload messages to show the sent one
await loadMessages(selectedSessionId);
} catch (error) {
console.error('Send message failed:', error);
alert(t('bots.sessionMonitor.sendFailed'));
} finally {
setSendingMessage(false);
}
};
const handleMessageKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const parseMessageChain = (content: string): MessageChainComponent[] => {
try {
const parsed = JSON.parse(content);
@@ -170,11 +299,16 @@ const BotSessionMonitor = forwardRef<
};
const isUserMessage = (msg: SessionMessage): boolean => {
if (msg.role === 'operator') return false;
if (msg.role === 'assistant') return false;
if (msg.role === 'user') return true;
return !msg.runner_name;
};
const isOperatorMessage = (msg: SessionMessage): boolean => {
return msg.role === 'operator';
};
const renderMessageComponent = (
component: MessageChainComponent,
index: number,
@@ -240,7 +374,7 @@ const BotSessionMonitor = forwardRef<
key={index}
className="inline-flex items-center gap-1 text-muted-foreground text-xs"
>
🎙 [Voice]
[Voice]
</span>
);
}
@@ -274,7 +408,7 @@ const BotSessionMonitor = forwardRef<
const file = component as MessageChainComponent & { name?: string };
return (
<span key={index} className="text-muted-foreground text-xs">
📎 {file.name || 'File'}
[{file.name || 'File'}]
</span>
);
}
@@ -334,6 +468,22 @@ const BotSessionMonitor = forwardRef<
(s) => s.session_id === selectedSessionId,
);
const getMessageRoleLabel = (msg: SessionMessage): string => {
if (isOperatorMessage(msg)) {
return t('bots.sessionMonitor.operatorMessage', {
defaultValue: 'Operator',
});
}
if (isUserMessage(msg)) {
return t('bots.sessionMonitor.userMessage', {
defaultValue: 'User',
});
}
return t('bots.sessionMonitor.botMessage', {
defaultValue: 'Assistant',
});
};
return (
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
{/* Left Panel: Session List */}
@@ -352,6 +502,9 @@ const BotSessionMonitor = forwardRef<
<div className="p-1.5">
{sessions.map((session) => {
const isSelected = selectedSessionId === session.session_id;
const sessionTakenOver = takenOverSessions.has(
session.session_id,
);
return (
<button
key={session.session_id}
@@ -388,12 +541,16 @@ const BotSessionMonitor = forwardRef<
{abbreviateId(session.user_id)}
</span>
)}
{session.is_active && (
{sessionTakenOver && (
<span className="flex items-center gap-0.5 text-orange-600 dark:text-orange-400">
<UserCheck className="w-3 h-3" />
</span>
)}
{session.is_active && !sessionTakenOver && (
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
</span>
)}
<span className="truncate">{session.pipeline_name}</span>
</div>
</button>
);
@@ -413,56 +570,92 @@ const BotSessionMonitor = forwardRef<
<>
{/* Chat Header */}
<div className="px-4 py-2.5 border-b shrink-0">
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{selectedSession?.user_name ||
selectedSession?.user_id ||
selectedSessionId.slice(0, 20)}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{selectedSession?.user_name ||
selectedSession?.user_id ||
selectedSessionId.slice(0, 20)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)}
{selectedSession?.platform && (
<>
{parseSessionType(selectedSessionId) && <span>·</span>}
<span>{selectedSession.platform}</span>
</>
)}
{selectedSession?.user_id && (
<>
<span>·</span>
<span className="font-mono">
{selectedSession.user_id}
</span>
<button
type="button"
onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')}
>
{copiedUserId ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</>
)}
{isTakenOver ? (
<>
<span>·</span>
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400">
<UserCheck className="w-3 h-3" />
{t('bots.sessionMonitor.takenOver', {
defaultValue: 'Taken Over',
})}
</span>
</>
) : (
selectedSession?.is_active && (
<>
<span>·</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
Active
</span>
</>
)
)}
</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)}
{selectedSession?.platform && (
<>
{parseSessionType(selectedSessionId) && <span>·</span>}
<span>{selectedSession.platform}</span>
</>
)}
{selectedSession?.user_id && (
<>
<span>·</span>
<span className="font-mono">
{selectedSession.user_id}
</span>
<button
type="button"
onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')}
>
{copiedUserId ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</>
)}
{selectedSession?.pipeline_name && (
<>
<span>·</span>
<span>{selectedSession.pipeline_name}</span>
</>
)}
{selectedSession?.is_active && (
<>
<span>·</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
Active
</span>
</>
{/* Takeover / Release button */}
<div className="flex-shrink-0">
{isTakenOver ? (
<button
type="button"
onClick={handleRelease}
disabled={takeoverLoading}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50 transition-colors disabled:opacity-50"
>
<UserCheck className="w-3.5 h-3.5" />
{t('bots.sessionMonitor.releaseBtn', {
defaultValue: 'Release',
})}
</button>
) : (
<button
type="button"
onClick={handleTakeover}
disabled={takeoverLoading}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50"
>
<UserCheck className="w-3.5 h-3.5" />
{t('bots.sessionMonitor.takeoverBtn', {
defaultValue: 'Take Over',
})}
</button>
)}
</div>
</div>
@@ -485,6 +678,10 @@ const BotSessionMonitor = forwardRef<
) : (
messages.map((msg) => {
const isUser = isUserMessage(msg);
const isOperator = isOperatorMessage(msg);
const isDiscarded =
msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD;
return (
<div
key={msg.id}
@@ -498,34 +695,59 @@ const BotSessionMonitor = forwardRef<
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
isUser
? 'bg-primary/10 rounded-br-sm'
: 'bg-muted rounded-bl-sm',
: isOperator
? 'bg-orange-100/80 dark:bg-orange-900/30 rounded-bl-sm'
: 'bg-muted rounded-bl-sm',
msg.status === 'error' && 'ring-1 ring-red-400/50',
isDiscarded && 'opacity-60',
)}
>
{renderMessageContent(msg)}
{/* Role label + timestamp */}
{/* Role label + pipeline + timestamp */}
<div
className={cn(
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
)}
>
<span>
{isUser
? t('bots.sessionMonitor.userMessage', {
defaultValue: 'User',
})
: t('bots.sessionMonitor.botMessage', {
defaultValue: 'Assistant',
})}
<span
className={cn(
isOperator &&
'text-orange-600 dark:text-orange-400 font-medium',
)}
>
{getMessageRoleLabel(msg)}
</span>
<span className="tabular-nums">
{formatTime(msg.timestamp)}
</span>
{isDiscarded ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<Ban className="w-3 h-3" />
{t('bots.sessionMonitor.discarded', {
defaultValue: 'Discarded',
})}
</span>
) : msg.pipeline_name &&
msg.pipeline_name !== 'Human Takeover' ? (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Workflow className="w-3 h-3" />
{msg.pipeline_name}
</span>
) : null}
{isOperator && (
<span className="inline-flex items-center gap-0.5 text-orange-600/70 dark:text-orange-400/70">
<UserCheck className="w-3 h-3" />
{t('bots.sessionMonitor.humanTakeover', {
defaultValue: 'Human Takeover',
})}
</span>
)}
{msg.status === 'error' && (
<span className="text-red-500">error</span>
)}
{msg.runner_name && (
<span className="opacity-70">
<span className="inline-flex items-center gap-0.5 opacity-70">
<Bot className="w-3 h-3" />
{msg.runner_name}
</span>
)}
@@ -537,6 +759,33 @@ const BotSessionMonitor = forwardRef<
)}
</div>
</ScrollArea>
{/* Operator Message Input (only shown when session is taken over) */}
{isTakenOver && (
<div className="px-4 py-3 border-t shrink-0">
<div className="flex items-center gap-2">
<input
type="text"
value={operatorMessage}
onChange={(e) => setOperatorMessage(e.target.value)}
onKeyDown={handleMessageKeyDown}
placeholder={t('bots.sessionMonitor.sendMessage', {
defaultValue: 'Send message as operator...',
})}
disabled={sendingMessage}
className="flex-1 h-9 px-3 rounded-md border bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
/>
<button
type="button"
onClick={handleSendMessage}
disabled={sendingMessage || !operatorMessage.trim()}
className="inline-flex items-center justify-center h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:pointer-events-none"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
)}
</>
)}
</div>

View File

@@ -1,12 +1,10 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import BotDetailContent from './BotDetailContent';
export default function BotConfigPage() {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
if (detailId) {

View File

@@ -1,5 +1,3 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';

View File

@@ -1,11 +1,9 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Check, Trash2, Plus } from 'lucide-react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import {
Dialog,
DialogContent,
@@ -67,9 +65,10 @@ export default function ApiIntegrationDialog({
onOpenChange,
}: ApiIntegrationDialogProps) {
const { t } = useTranslation();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState('apikeys');
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
@@ -94,7 +93,9 @@ export default function ApiIntegrationDialog({
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showApiIntegrationSettings');
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
}
}, [open]);
@@ -108,7 +109,7 @@ export default function ApiIntegrationDialog({
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
router.replace(newUrl, { scroll: false });
navigate(newUrl, { preventScrollReset: true });
}
onOpenChange(newOpen);
};

View File

@@ -11,10 +11,14 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Copy, Check, Globe } from 'lucide-react';
import { systemInfo } from '@/app/infra/http';
/**
* Resolve the value referenced by a `show_if.field` string.
@@ -40,6 +44,106 @@ function resolveShowIfValue(
return externalDependentValues?.[field];
}
/**
* Display-only component for webhook URL fields.
* Rendered outside of react-hook-form binding since the value is
* read-only and comes from systemContext, not user input.
*/
function WebhookUrlField({
label,
description,
url,
extraUrl,
}: {
label: string;
description?: string;
url: string;
extraUrl?: string;
}) {
const [copied, setCopied] = useState(false);
const [extraCopied, setExtraCopied] = useState(false);
const { t } = useTranslation();
const handleCopy = (text: string, setter: (v: boolean) => void) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setter(true);
setTimeout(() => setter(false), 2000);
})
.catch(() => {});
}
};
return (
<FormItem>
<FormLabel>{label}</FormLabel>
<div className="flex items-center gap-2">
<Input
value={url}
readOnly
className="flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleCopy(url, setCopied)}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{extraUrl && (
<div className="flex items-center gap-2 mt-2">
<Input
value={extraUrl}
readOnly
className="flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleCopy(extraUrl, setExtraCopied)}
>
{extraCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{systemInfo.edition === 'community' && (
<div className="flex items-start gap-2.5 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 mt-1 max-w-2xl">
<Globe className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground leading-relaxed">
{t('bots.webhookSaasHint')}{' '}
<a
href="https://space.langbot.app/cloud?utm_source=local_webui&utm_medium=webhook_alert&utm_campaign=saas_conversion"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline font-medium"
>
{t('bots.webhookSaasLink')}
</a>
</p>
</div>
)}
</FormItem>
);
}
export default function DynamicFormComponent({
itemConfigList,
onSubmit,
@@ -99,9 +203,16 @@ export default function DynamicFormComponent({
return value;
};
// Filter out display-only field types (e.g. webhook-url) that should not
// participate in form state, validation, or value emission.
const editableItems = useMemo(
() => itemConfigList.filter((item) => item.type !== 'webhook-url'),
[itemConfigList],
);
// 根据 itemConfigList 动态生成 zod schema
const formSchema = z.object(
itemConfigList.reduce(
editableItems.reduce(
(acc, item) => {
let fieldSchema;
switch (item.type) {
@@ -138,6 +249,9 @@ export default function DynamicFormComponent({
case 'bot-selector':
fieldSchema = z.string();
break;
case 'tools-selector':
fieldSchema = z.array(z.string());
break;
case 'model-fallback-selector':
fieldSchema = z.object({
primary: z.string(),
@@ -179,7 +293,7 @@ export default function DynamicFormComponent({
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: itemConfigList.reduce((acc, item) => {
defaultValues: editableItems.reduce((acc, item) => {
// 优先使用 initialValues如果没有则使用默认值
const rawValue = initialValues?.[item.name] ?? item.default;
return {
@@ -207,7 +321,7 @@ export default function DynamicFormComponent({
if (initialValues && hasRealChange) {
// 合并默认值和初始值
const mergedValues = itemConfigList.reduce(
const mergedValues = editableItems.reduce(
(acc, item) => {
const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
@@ -222,7 +336,7 @@ export default function DynamicFormComponent({
previousInitialValues.current = initialValues;
}
}, [initialValues, form, itemConfigList]);
}, [initialValues, form, editableItems]);
// Get reactive form values for conditional rendering
const watchedValues = form.watch();
@@ -238,7 +352,7 @@ export default function DynamicFormComponent({
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const formValues = form.getValues();
const initialFinalValues = itemConfigList.reduce(
const initialFinalValues = editableItems.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
@@ -258,7 +372,7 @@ export default function DynamicFormComponent({
const subscription = form.watch(() => {
const formValues = form.getValues();
const finalValues = itemConfigList.reduce(
const finalValues = editableItems.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
@@ -269,7 +383,7 @@ export default function DynamicFormComponent({
previousInitialValues.current = finalValues as Record<string, object>;
});
return () => subscription.unsubscribe();
}, [form, itemConfigList]);
}, [form, editableItems]);
return (
<Form {...form}>
@@ -307,6 +421,29 @@ export default function DynamicFormComponent({
// All fields are disabled when editing (creation_settings are immutable)
const isFieldDisabled = !!isEditing;
// Webhook URL fields are display-only; render outside of form binding
if (config.type === 'webhook-url') {
const webhookUrl = (systemContext?.webhook_url as string) || '';
const extraWebhookUrl =
(systemContext?.extra_webhook_url as string) || '';
if (!webhookUrl) return null;
return (
<WebhookUrlField
key={config.id}
label={extractI18nObject(config.label)}
description={
config.description
? extractI18nObject(config.description)
: undefined
}
url={webhookUrl}
extraUrl={extraWebhookUrl || undefined}
/>
);
}
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
return (

View File

@@ -23,6 +23,7 @@ import {
Bot,
KnowledgeBase,
EmbeddingModel,
PluginTool,
} from '@/app/infra/entities/api';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -37,7 +38,29 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X, Eye, Wrench, Trash2 } from 'lucide-react';
import {
Plus,
X,
Eye,
Wrench,
Trash2,
Sparkles,
Info,
Settings,
ChevronDown,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
export default function DynamicFormItemComponent({
config,
@@ -53,10 +76,34 @@ export default function DynamicFormItemComponent({
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [bots, setBots] = useState<Bot[]>([]);
const [tools, setTools] = useState<PluginTool[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
const [tempSelectedToolNames, setTempSelectedToolNames] = useState<string[]>(
[],
);
const { t } = useTranslation();
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const fetchLlmModels = () => {
httpClient
.getProviderLLMModels()
.then((resp) => {
setLlmModels(resp.models);
})
.catch((err) => {
toast.error(t('models.getModelListError') + err.msg);
});
};
const handleModelsDialogChange = (open: boolean) => {
setModelsDialogOpen(open);
if (!open) {
fetchLlmModels();
}
};
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -88,26 +135,35 @@ export default function DynamicFormItemComponent({
}
};
// Whether to show Space login CTA in model selectors
const showSpaceLoginCTA =
!systemInfo.disable_models_service && userInfo?.account_type !== 'space';
const handleSpaceLogin = () => {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
httpClient
.getSpaceAuthorizeUrl(redirectUri, token)
.then((response) => {
window.location.href = response.authorize_url;
})
.catch(() => {
toast.error(t('common.spaceLoginFailed'));
});
} catch {
toast.error(t('common.spaceLoginFailed'));
}
};
useEffect(() => {
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
httpClient
.getProviderLLMModels()
.then((resp) => {
let models = resp.models;
// Filter out space-chat-completions models when not logged in with space account or when models service is disabled
if (
systemInfo.disable_models_service ||
userInfo?.account_type !== 'space'
) {
models = models.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
}
setLlmModels(models);
})
.catch((err) => {
toast.error(t('models.getModelListError') + err.msg);
});
fetchLlmModels();
}
}, [config.type]);
@@ -126,23 +182,7 @@ export default function DynamicFormItemComponent({
useEffect(() => {
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
httpClient
.getProviderLLMModels()
.then((resp) => {
let models = resp.models;
if (
systemInfo.disable_models_service ||
userInfo?.account_type !== 'space'
) {
models = models.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
}
setLlmModels(models);
})
.catch((err) => {
toast.error('Failed to get LLM model list: ' + err.msg);
});
fetchLlmModels();
}
}, [config.type]);
@@ -175,6 +215,21 @@ export default function DynamicFormItemComponent({
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.TOOLS_SELECTOR) {
httpClient
.getTools()
.then((resp) => {
setTools(resp.tools);
})
.catch((err) => {
toast.error(
t('tools.getToolListError', 'Failed to get tools: ') + err.msg,
);
});
}
}, [config.type]);
switch (config.type) {
case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT:
@@ -188,6 +243,40 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.STRING:
if (config.options && config.options.length > 0) {
return (
<div className="flex items-center gap-1.5 max-w-md">
<Input className="flex-1" {...field} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
type="button"
className="h-9 w-9 shrink-0 text-muted-foreground"
>
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{config.options.map((option) => (
<DropdownMenuItem
key={option.name}
onClick={() => field.onChange(option.name)}
>
<div className="flex flex-col gap-0.5">
<span>{extractI18nObject(option.label)}</span>
<span className="text-xs text-muted-foreground">
{option.name}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
return <Input className="max-w-md" {...field} />;
case DynamicFormItemType.TEXT:
@@ -249,7 +338,11 @@ export default function DynamicFormItemComponent({
<SelectContent>
<SelectGroup>
{config.options?.map((option) => (
<SelectItem key={option.name} value={option.name}>
<SelectItem
key={option.name}
value={option.name}
description={option.name}
>
{extractI18nObject(option.label)}
</SelectItem>
))}
@@ -259,8 +352,16 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.LLM_MODEL_SELECTOR:
// Group models by provider
const groupedModels = llmModels.reduce(
// Separate space models from regular models
const spaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
);
const regularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
// Group regular models by provider
const groupedModels = regularModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
@@ -271,33 +372,180 @@ export default function DynamicFormItemComponent({
{} as Record<string, LLMModel[]>,
);
// Group space models by provider (for logged-in users)
const groupedSpaceModels = spaceModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, LLMModel[]>,
);
// Hardcoded preview model names for CTA when no space models are synced
const previewModelNames = [
'gpt-4o',
'claude-sonnet-4-20250514',
'deepseek-chat',
'gemini-2.5-flash',
'qwen-plus',
];
return (
<div className="max-w-md">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModels).map(([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
<div className="max-w-md flex items-center gap-1.5">
<div className="flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModels).map(([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
))}
{/* Space models section */}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{/* Preview models (first 3 visible, rest blurred) */}
{(spaceModels.length > 0
? spaceModels.map((m) => m.name)
: previewModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(spaceModels.length > 0
? spaceModels.map((m) => m.name)
: previewModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
{/* Login overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
) : !systemInfo.disable_models_service ? (
// User is logged into Space — show space models normally
Object.entries(groupedSpaceModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
),
)
) : null}
</SelectContent>
</Select>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">{t('models.title')}</TooltipContent>
</Tooltip>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
</div>
);
@@ -338,8 +586,16 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Group models by provider
const groupedModelsForFallback = llmModels.reduce(
// Separate space models from regular models
const fbSpaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
);
const fbRegularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
// Group regular models by provider
const groupedModelsForFallback = fbRegularModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
@@ -350,6 +606,27 @@ export default function DynamicFormItemComponent({
{} as Record<string, LLMModel[]>,
);
// Group space models by provider (for logged-in users)
const fbGroupedSpaceModels = fbSpaceModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, LLMModel[]>,
);
// Hardcoded preview model names for CTA
const fbPreviewModelNames = [
'gpt-4o',
'claude-sonnet-4-20250514',
'deepseek-chat',
'gemini-2.5-flash',
'qwen-plus',
];
const rawModelValue = field.value;
const modelValue: { primary: string; fallbacks: string[] } =
rawModelValue != null &&
@@ -406,6 +683,112 @@ export default function DynamicFormItemComponent({
</SelectGroup>
),
)}
{/* Space models section */}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{/* Preview models (first 3 visible, rest blurred) */}
{(fbSpaceModels.length > 0
? fbSpaceModels.map((m) => m.name)
: fbPreviewModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(fbSpaceModels.length > 0
? fbSpaceModels.map((m) => m.name)
: fbPreviewModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
{/* Login overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
) : !systemInfo.disable_models_service ? (
// User is logged into Space — show space models normally
Object.entries(fbGroupedSpaceModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
),
)
) : null}
</SelectContent>
</Select>
);
@@ -448,11 +831,35 @@ export default function DynamicFormItemComponent({
<p className="text-xs text-muted-foreground mb-1">
{t('models.fallback.primary')}
</p>
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
t('models.selectModel'),
)}
<div className="flex items-center gap-1.5">
<div className="flex-1">
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
t('models.selectModel'),
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('models.title')}
</TooltipContent>
</Tooltip>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
</div>
</div>
{/* Fallback models */}
@@ -775,6 +1182,139 @@ export default function DynamicFormItemComponent({
</Select>
);
case DynamicFormItemType.TOOLS_SELECTOR:
return (
<>
<div className="space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
{field.value.map((toolName: string) => {
const currentTool = tools.find(
(tool) => tool.name === toolName,
);
return (
<div
key={toolName}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex items-center gap-2 flex-1">
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-medium">{toolName}</div>
{currentTool?.human_desc && (
<div className="text-sm text-muted-foreground truncate">
{currentTool.human_desc}
</div>
)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newValue = field.value.filter(
(name: string) => name !== toolName,
);
field.onChange(newValue);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
) : (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('tools.noToolSelected', 'No tools selected')}
</p>
</div>
)}
</div>
<Button
type="button"
onClick={() => {
setTempSelectedToolNames(field.value || []);
setToolsDialogOpen(true);
}}
variant="outline"
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
{t('tools.addTool', 'Add Tool')}
</Button>
<Dialog open={toolsDialogOpen} onOpenChange={setToolsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{t('tools.selectTools', 'Select Tools')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{tools.map((tool) => {
const isSelected = tempSelectedToolNames.includes(tool.name);
return (
<div
key={tool.name}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
setTempSelectedToolNames((prev) =>
prev.includes(tool.name)
? prev.filter((name) => name !== tool.name)
: [...prev, tool.name],
);
}}
>
<Checkbox
checked={isSelected}
aria-label={`Select ${tool.name}`}
/>
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium">{tool.name}</div>
{tool.human_desc && (
<div className="text-sm text-muted-foreground">
{tool.human_desc}
</div>
)}
</div>
</div>
);
})}
{tools.length === 0 && (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('tools.noToolsAvailable', 'No tools available')}
</p>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setToolsDialogOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
field.onChange(tempSelectedToolNames);
setToolsDialogOpen(false);
}}
>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
case DynamicFormItemType.PROMPT_EDITOR: {
// Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default

Some files were not shown because too many files have changed in this diff Show More