Compare commits

...

35 Commits

Author SHA1 Message Date
Rock Chin
cc4d8838eb fix: update langbot-plugin version to 0.3.8 2026-04-11 17:12:20 +08:00
Rock Chin
fa0a77f09f fix: update langbot-plugin version to 0.3.8 2026-04-11 17:11:09 +08:00
Rock Chin
fd6a7b73d4 chore: update version to 4.9.6 in pyproject.toml, __init__.py, and uv.lock 2026-04-11 17:08:59 +08:00
Rock Chin
bf0848d60b feat: update uv.lock 2026-04-11 17:06:15 +08:00
Guanchao Wang
e06fac2bb7 fix: add filereader for dingtalk,lark (#2122)
* fix: add filereader for dingtalk

* feat: add lark
2026-04-10 16:10:13 +08:00
Guanchao Wang
bec61427a0 Merge pull request #2118 from HaiYangBG1/fix/dingtalk-voice-recognition
fix(dingtalk): use voice recognition text instead of raw audio binary
2026-04-10 10:53:22 +08:00
RockChinQ
5fae7b2eb0 feat: add autogenerate support and CLI entrypoint for alembic
- autogenerate: compare ORM models vs DB schema to generate migrations
- CLI: python -m langbot.pkg.persistence.alembic_runner <command>
  - autogenerate, upgrade, stamp, current
- Reads data/config.yaml for DB connection
2026-04-08 23:50:36 +08:00
RockChinQ
2eebdfe16a ci: add migration test workflow for SQLite and PostgreSQL
Tests alembic upgrade on both databases:
- Stamp baseline on existing schema
- Upgrade to head
- Idempotent re-upgrade
- Fresh DB upgrade from scratch
2026-04-08 23:43:05 +08:00
RockChinQ
9cd3544d59 feat: integrate Alembic for database migrations
Replace manual if-sqlite/if-postgres branching with Alembic:
- Add alembic dependency
- Create programmatic alembic env (no CLI/alembic.ini needed)
- Support async engines via run_sync passthrough
- render_as_batch=True for SQLite ALTER TABLE compatibility
- Auto-stamp baseline on first run (existing DB at version 25)
- Run alembic upgrade head after legacy migrations
- Include sample migration showing schema + data migration patterns
- Add alembic dir to package-data for distribution
2026-04-08 23:33:13 +08:00
haiyangbg
de4d14fee3 fix(dingtalk): use voice recognition text instead of raw audio binary
When DingTalk sends a voice message to the bot, the callback JSON contains
a 'recognition' field with the speech-to-text result (powered by Qwen).

Previously, LangBot only extracted the 'downloadCode' to download the raw
audio binary and passed it as 'file_base64' to LLM APIs, which caused
400 errors since most models don't support this content type.

This patch:
- Extracts the 'recognition' field from DingTalk audio message content
- Uses it as plain text input to the LLM instead of raw audio
- Falls back to audio binary only when no recognition text is available
- Fixes duplicate text issue for audio messages with recognition

Fixes voice messages returning 'Request failed' on all LLM models.
2026-04-08 23:23:27 +08:00
RockChinQ
f29c568381 style: ruff format telemetry.py 2026-04-08 20:38:43 +08:00
RockChinQ
af3f557055 feat: add edition field to telemetry payload
Sends constants.edition (community/saas) with each telemetry event
so Space can distinguish between community and SaaS instances.
2026-04-08 20:28:34 +08:00
RockChinQ
b894842736 fix(user): allow password login and password change for Space accounts with local password set
Previously, Space accounts were unconditionally blocked from password login
and password change based on account_type. Now the check verifies whether
the user actually has a local password set, allowing Space users who have
set a local password to authenticate and change it normally.
2026-04-08 19:02:36 +08:00
Guanchao Wang
e190029e1f Merge pull request #2114 from langbot-app/fix/duplicate-close
Fix/duplicate close
2026-04-08 15:03:58 +08:00
WangCham
e4940a8050 fix: lint error 2026-04-08 15:00:20 +08:00
RockChinQ
617c95ebc4 feat: add marketplace link when no parser available for file upload
Links to /home/market?category=Parser, same pattern as knowledge engine selector.
2026-04-08 02:23:20 +08:00
RockChinQ
1cdd428bcc style: ruff format main.py 2026-04-08 02:10:18 +08:00
RockChinQ
71ac719aee fix: SPA fallback for all frontend routes, not just /home/*
After migrating from Next.js to Vite SPA, routes like /auth/space/callback
returned 404 because the static file server only had SPA fallback for /home/*.
Now all non-API routes fall back to index.html for React Router to handle.
2026-04-08 02:07:31 +08:00
Copilot
4621e6cc9f fix: always show sidebar plus buttons on touch/mobile devices (#2115)
Agent-Logs-Url: https://github.com/langbot-app/LangBot/sessions/e27a4886-fbad-4a7a-8558-67a387852753

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-04-08 01:38:48 +08:00
Guanchao Wang
66087f83e1 Merge pull request #2113 from langbot-app/feat/wecombot-group-msg
feat: add feat for receive files in wecombot
2026-04-07 16:54:35 +08:00
WangCham
25f9330491 fix: ruff error 2026-04-07 16:33:46 +08:00
WangCham
14b1e0d33b feat: add feat for receive files in wecombot 2026-04-07 16:22:36 +08:00
6mvp6
83ccb33fd3 fix(monitoring): WeChat Work feedback recording bugs (#2108)
* fix(monitoring): fix WeChat Work feedback recording bugs

- Fix feedback events silently dropped when stream session expires:
  dispatch feedback handlers regardless of session availability
- Fix IntegrityError on repeated feedback (like→dislike) for same
  message: implement UPSERT logic in record_feedback()
- Fix cancel feedback (type=3) not removing records: add delete logic
- Fix inaccurate_reasons validation error: convert int reason codes
  to strings before creating FeedbackEvent (Pydantic expects List[str])
- Fix feedback timestamps 8 hours off in frontend: use parseUTCTimestamp
  instead of new Date() for UTC timestamp parsing
- Fix StreamSessionManager.cleanup missing _feedback_index cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(monitoring): apply ruff format to wecom feedback files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: 6mvp6 <13727783693@163.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 17:12:43 +08:00
WangCham
05bcf543ba feat: update plugin module 2026-04-06 08:22:50 +08:00
WangCham
7cd063bb5d fix: double close button 2026-04-06 08:22:31 +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
176 changed files with 4890 additions and 5381 deletions

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

171
.github/workflows/test-migrations.yml vendored Normal file
View File

@@ -0,0 +1,171 @@
name: Test Migrations
on:
push:
branches:
- main
- master
- dev
paths:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
jobs:
test-migrations-sqlite:
name: Migrations (SQLite)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Test Alembic upgrade (SQLite)
run: |
uv run python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
async def main():
engine = create_async_engine('sqlite+aiosqlite:///test_migrations.db')
# Create all tables (simulates existing DB)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(engine, '0001_baseline')
rev = await get_alembic_current(engine)
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
print(f'Stamped: {rev}')
# Upgrade to head
await run_alembic_upgrade(engine, 'head')
rev = await get_alembic_current(engine)
print(f'After upgrade: {rev}')
assert rev is not None, 'Expected a revision after upgrade'
# Verify idempotent
await run_alembic_upgrade(engine, 'head')
rev2 = await get_alembic_current(engine)
assert rev2 == rev, f'Expected {rev}, got {rev2}'
print(f'Idempotent check passed: {rev2}')
# Fresh DB: upgrade from scratch
engine2 = create_async_engine('sqlite+aiosqlite:///test_migrations_fresh.db')
async with engine2.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_upgrade(engine2, 'head')
rev3 = await get_alembic_current(engine2)
print(f'Fresh DB upgrade: {rev3}')
assert rev3 is not None
print('All SQLite migration tests passed!')
asyncio.run(main())
"
test-migrations-postgres:
name: Migrations (PostgreSQL)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: langbot
POSTGRES_PASSWORD: langbot
POSTGRES_DB: langbot_test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U langbot"
--health-interval=5s
--health-timeout=5s
--health-retries=5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Test Alembic upgrade (PostgreSQL)
run: |
uv run python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
DB_URL = 'postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test'
async def main():
engine = create_async_engine(DB_URL)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(engine, '0001_baseline')
rev = await get_alembic_current(engine)
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
print(f'Stamped: {rev}')
# Upgrade to head
await run_alembic_upgrade(engine, 'head')
rev = await get_alembic_current(engine)
print(f'After upgrade: {rev}')
assert rev is not None
# Verify idempotent
await run_alembic_upgrade(engine, 'head')
rev2 = await get_alembic_current(engine)
assert rev2 == rev, f'Expected {rev}, got {rev2}'
print(f'Idempotent check passed: {rev2}')
# Fresh DB: drop all and upgrade from scratch
engine2 = create_async_engine(DB_URL.replace('langbot_test', 'langbot_fresh'))
# Create fresh database
from sqlalchemy import text
async with engine.connect() as conn:
await conn.execute(text('COMMIT'))
await conn.execute(text('CREATE DATABASE langbot_fresh'))
async with engine2.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_upgrade(engine2, 'head')
rev3 = await get_alembic_current(engine2)
print(f'Fresh DB upgrade: {rev3}')
assert rev3 is not None
print('All PostgreSQL migration tests passed!')
asyncio.run(main())
"

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

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.9.5"
version = "4.9.6"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -39,6 +39,7 @@ dependencies = [
"quart-cors>=0.8.0",
"requests>=2.32.3",
"slack-sdk>=3.35.0",
"alembic>=1.15.0",
"sqlalchemy[asyncio]>=2.0.40",
"sqlmodel>=0.0.24",
"telegramify-markdown>=0.5.1",
@@ -64,7 +65,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.6",
"langbot-plugin==0.3.8",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",
@@ -111,7 +112,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/**", "pkg/persistence/alembic/**"] }
[dependency-groups]
dev = [

View File

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

View File

@@ -182,6 +182,88 @@ class DingTalkClient:
for handler in self._message_handlers[msg_type]:
await handler(event)
async def _parse_quoted_message(self, replied_msg: dict) -> dict:
"""Parse the quoted/replied message and extract its content.
Args:
replied_msg: The repliedMsg object from DingTalk message
Returns:
A dict containing the quoted message info with keys:
- message_id: The original message ID
- msg_type: The message type (text, file, picture, audio, etc.)
- content: The text content (if any)
- file_url: The file download URL (if file type)
- file_name: The file name (if file type)
- picture: The picture base64 (if picture type)
- audio: The audio base64 (if audio type)
"""
quote_info = {
'message_id': replied_msg.get('msgId', ''),
'msg_type': replied_msg.get('msgType', ''),
'sender_id': replied_msg.get('senderId', ''),
}
msg_type = replied_msg.get('msgType', '')
content = replied_msg.get('content', {})
# Handle content as string (JSON) or dict
if isinstance(content, str):
try:
content = json.loads(content)
except (json.JSONDecodeError, TypeError):
content = {}
if msg_type == 'text':
# Text message
if isinstance(content, dict):
quote_info['content'] = content.get('content', '')
else:
quote_info['content'] = str(content)
elif msg_type == 'file':
# File message
download_code = content.get('downloadCode')
file_name = content.get('fileName')
if download_code and file_name:
try:
quote_info['file_url'] = await self.get_file_url(download_code)
quote_info['file_name'] = file_name
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to get quoted file URL: {e}')
elif msg_type == 'picture':
# Picture message
download_code = content.get('downloadCode')
if download_code:
try:
quote_info['picture'] = await self.download_image(download_code)
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to download quoted image: {e}')
elif msg_type == 'audio':
# Audio message
download_code = content.get('downloadCode')
if download_code:
try:
quote_info['audio'] = await self.get_audio_url(download_code)
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to get quoted audio: {e}')
elif msg_type == 'richText':
# Rich text message - extract text content
rich_text = content.get('richText', [])
texts = []
for item in rich_text:
if 'text' in item and item['text'] != '\n':
texts.append(item['text'])
quote_info['content'] = '\n'.join(texts)
return quote_info
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
try:
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
@@ -193,6 +275,15 @@ class DingTalkClient:
elif str(incoming_message.conversation_type) == '2':
message_data['conversation_type'] = 'GroupMessage'
# Check for quoted/replied message
raw_data = incoming_message.to_dict()
text_data = raw_data.get('text', {})
if isinstance(text_data, dict) and text_data.get('isReplyMsg'):
replied_msg = text_data.get('repliedMsg', {})
if replied_msg:
quote_info = await self._parse_quoted_message(replied_msg)
message_data['QuotedMessage'] = quote_info
if incoming_message.message_type == 'richText':
data = incoming_message.rich_text_content.to_dict()
@@ -268,7 +359,25 @@ class DingTalkClient:
message_data['Type'] = 'image'
elif incoming_message.message_type == 'audio':
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
raw_content = incoming_message.to_dict().get('content', {})
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
if isinstance(raw_content, str):
try:
raw_content = json.loads(raw_content)
except (json.JSONDecodeError, TypeError):
raw_content = {}
if self.logger:
await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}')
# 提取钉钉自带的语音转写文字Powered by Qwen
recognition = raw_content.get('recognition', '')
if recognition:
message_data['Content'] = recognition
download_code = raw_content.get('downloadCode')
if download_code:
message_data['Audio'] = await self.get_audio_url(download_code)
message_data['Type'] = 'audio'
elif incoming_message.message_type == 'file':

View File

@@ -47,6 +47,22 @@ class DingTalkEvent(dict):
def conversation(self):
return self.get('conversation_type', '')
@property
def quoted_message(self) -> Optional[Dict[str, Any]]:
"""Get the quoted/replied message info if this is a reply message.
Returns:
A dict containing:
- message_id: The original message ID
- msg_type: The message type (text, file, picture, audio, etc.)
- content: The text content (if any)
- file_url: The file download URL (if file type)
- file_name: The file name (if file type)
- picture: The picture base64 (if picture type)
- audio: The audio base64 (if audio type)
"""
return self.get('QuotedMessage')
def __getattr__(self, key: str) -> Optional[Any]:
"""
允许通过属性访问数据中的任意字段。

View File

@@ -228,6 +228,9 @@ class StreamSessionManager:
msg_id = session.msg_id
if msg_id and self._msg_index.get(msg_id) == stream_id:
self._msg_index.pop(msg_id, None)
# Clean up feedback index for expired sessions
if session.feedback_id:
self._feedback_index.pop(session.feedback_id, None)
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
@@ -434,10 +437,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')
@@ -449,10 +452,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 {}
@@ -466,12 +471,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', {})
@@ -587,6 +595,120 @@ async def parse_wecom_bot_message(
if msg_json.get('aibotid'):
message_data['aibotid'] = msg_json.get('aibotid', '')
# Handle quote (referenced message) - important for group chat file references
quote_info = msg_json.get('quote')
if quote_info:
quote_data: dict[str, Any] = {}
quote_type = quote_info.get('msgtype', '')
quote_data['msgtype'] = quote_type
if quote_type == 'text':
quote_data['content'] = quote_info.get('text', {}).get('content', '')
elif quote_type == 'image':
img_info = quote_info.get('image', {})
img_url = img_info.get('url', '')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data:
quote_data['picurl'] = base64_data
quote_data['images'] = [base64_data]
elif quote_type == 'file':
file_info = quote_info.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['file'] = file_data
elif quote_type == 'voice':
voice_info = quote_info.get('voice', {}) or {}
download_url = voice_info.get('url')
item_aeskey = voice_info.get('aeskey', '')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
quote_data['content'] = voice_info.get('content')
# Same as private chat: append aeskey to url for plugin processing
if download_url and item_aeskey:
voice_data['url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['voice'] = voice_data
elif quote_type == 'video':
video_info = quote_info.get('video', {}) or {}
download_url = video_info.get('url')
item_aeskey = video_info.get('aeskey', '')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
video_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['video'] = video_data
elif quote_type == 'link':
quote_data['link'] = quote_info.get('link', {})
link = quote_data['link']
title = link.get('title', '')
desc = link.get('description') or link.get('digest', '')
quote_data['content'] = '\n'.join(filter(None, [title, desc]))
elif quote_type == 'mixed':
# Handle mixed type in quote (text + images + files etc.)
items = quote_info.get('mixed', {}).get('msg_item', [])
texts = []
images = []
files = []
for item in items:
item_type = item.get('msgtype')
if item_type == 'text':
texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image':
img_info = item.get('image', {})
img_url = img_info.get('url')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
files.append(file_data)
if texts:
quote_data['content'] = ' '.join(texts)
if images:
quote_data['images'] = images
quote_data['picurl'] = images[0]
if files:
quote_data['files'] = files
quote_data['file'] = files[0]
message_data['quote'] = quote_data
return message_data
@@ -898,35 +1020,38 @@ class WecomBotClient:
)
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} 对应的会话')
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈')
# Dispatch feedback event regardless of session availability
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
if self._feedback_callback:
try:
await self._feedback_callback(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
except Exception:
await self.logger.error(traceback.format_exc())

View File

@@ -147,3 +147,10 @@ class WecomBotEvent(dict):
流式消息 ID
"""
return self.get('stream_id', '')
@property
def quote(self):
"""
引用消息信息(群聊中用户引用其他消息时返回)
"""
return self.get('quote', {})

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

@@ -105,23 +105,24 @@ class HTTPController:
):
if os.path.exists(os.path.join(frontend_path, path + '.html')):
path += '.html'
elif path.startswith('home/'):
# SPA fallback for /home/* sub-routes.
# Entity detail views use query params (e.g. /home/bots?id=uuid),
# so the pre-rendered list page is served directly via path + '.html'.
# This fallback handles any remaining unmatched sub-paths.
segments = path.rstrip('/').split('/')
elif not path.startswith('api/'):
# SPA fallback: serve index.html for all non-API, non-static routes
# so that React Router can handle client-side routing (Vite SPA).
# For /home/* sub-routes, first try parent .html files (pre-rendered pages).
if path.startswith('home/'):
segments = path.rstrip('/').split('/')
for i in range(len(segments) - 1, 0, -1):
parent_path = '/'.join(segments[:i]) + '.html'
if os.path.exists(os.path.join(frontend_path, parent_path)):
response = await quart.send_from_directory(
frontend_path, parent_path, mimetype='text/html'
)
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Walk up parent segments looking for matching .html files
for i in range(len(segments) - 1, 0, -1):
parent_path = '/'.join(segments[:i]) + '.html'
if os.path.exists(os.path.join(frontend_path, parent_path)):
response = await quart.send_from_directory(frontend_path, parent_path, mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Final fallback to index.html for /home/* routes
# Fallback to index.html for SPA client-side routing
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'

View File

@@ -1224,30 +1224,83 @@ class MonitoringService:
"""
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,
}
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
# Handle cancel feedback (type=3): delete existing record
if feedback_type == 3:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
)
return None
# Check if record with this feedback_id already exists
existing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
)
existing_row = existing_result.first()
return record_id
if existing_row:
# UPDATE existing record
existing = existing_row[0] if isinstance(existing_row, tuple) else existing_row
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(MonitoringFeedback)
.where(MonitoringFeedback.feedback_id == feedback_id)
.values(
timestamp=now,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=reasons_json,
bot_id=bot_id or existing.bot_id,
bot_name=bot_name or existing.bot_name,
pipeline_id=pipeline_id or existing.pipeline_id,
pipeline_name=pipeline_name or existing.pipeline_name,
session_id=session_id or existing.session_id,
message_id=message_id or existing.message_id,
stream_id=stream_id or existing.stream_id,
user_id=user_id or existing.user_id,
platform=platform or existing.platform,
)
)
return existing.id
else:
# INSERT new record with IntegrityError defense
record_id = str(uuid.uuid4())
record_data = {
'id': record_id,
'timestamp': now,
'feedback_id': feedback_id,
'feedback_type': feedback_type,
'feedback_content': feedback_content,
'inaccurate_reasons': reasons_json,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': user_id,
'platform': platform,
}
try:
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringFeedback).values(record_data))
return record_id
except Exception:
# UNIQUE constraint conflict (concurrent feedback for same feedback_id)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(MonitoringFeedback)
.where(MonitoringFeedback.feedback_id == feedback_id)
.values(
timestamp=now,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=reasons_json,
)
)
return feedback_id
async def get_feedback_stats(
self,

View File

@@ -65,8 +65,8 @@ class UserService:
user_obj = result_list[0]
# Check if this is a Space account
if user_obj.account_type == 'space':
# Check if this user has a local password set
if not user_obj.password:
raise ValueError('请使用 Space 账户登录')
ph = argon2.PasswordHasher()
@@ -108,9 +108,8 @@ class UserService:
if user_obj is None:
raise ValueError('User not found')
# Space accounts cannot change password locally
if user_obj.account_type == 'space':
raise ValueError('Space account cannot change password locally')
if not user_obj.password:
raise ValueError('No local password set, please set a password first')
ph.verify(user_obj.password, current_password)

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

@@ -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,51 @@
"""Alembic environment for LangBot.
This env.py is designed to be called programmatically (not via CLI).
It supports both SQLite and PostgreSQL.
The sync connection is passed via config attributes by the runner.
"""
from __future__ import annotations
from alembic import context
from sqlalchemy.engine import Connection
from langbot.pkg.entity.persistence.base import Base
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode — emit SQL without a live connection."""
url = context.config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={'paramstyle': 'named'},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations with a live sync connection passed via config attributes."""
connection: Connection = context.config.attributes.get('connection')
if connection is None:
raise RuntimeError('connection not provided in alembic config attributes')
context.configure(
connection=connection,
target_metadata=target_metadata,
# render_as_batch=True is critical for SQLite ALTER TABLE support
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
# Alembic script.py.mako — template for auto-generated revisions
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,24 @@
"""baseline: stamp existing schema (db version 25)
This is a no-op migration that marks the starting point for Alembic.
All tables already exist via create_all() + legacy DBMigration system.
Revision ID: 0001_baseline
Revises: None
Create Date: 2026-04-08
"""
revision = '0001_baseline'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# No-op: existing schema is already at database_version=25
# This revision serves as the Alembic baseline.
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,62 @@
"""example: sample migration demonstrating Alembic patterns
This is a SAMPLE showing how to write migrations that work
seamlessly across SQLite and PostgreSQL. Delete or adapt as needed.
Revision ID: 0002_sample
Revises: 0001_baseline
Create Date: 2026-04-08
Patterns demonstrated:
1. Schema change (add column) — works on both DBs via render_as_batch
2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching
"""
revision = '0002_sample'
down_revision = '0001_baseline'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
EXAMPLE: Uncomment to use. This shows the patterns.
# --- Pattern 1: Schema change (add/drop column) ---
# render_as_batch=True in env.py makes this work on SQLite too.
#
# op.add_column('pipelines', sa.Column('description', sa.String(512), server_default=''))
# --- Pattern 2: Data migration (read + modify JSON field) ---
# No if/else for sqlite vs postgres needed!
#
# conn = op.get_bind()
# rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall()
# for row in rows:
# config = json.loads(row[1]) if isinstance(row[1], str) else row[1]
# # Modify the config
# config.setdefault('ai', {}).setdefault('some_new_key', 'default_value')
# conn.execute(
# sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"),
# {"cfg": json.dumps(config), "uuid": row[0]}
# )
# --- Pattern 3: Create a new table ---
#
# op.create_table(
# 'audit_log',
# sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
# sa.Column('action', sa.String(255), nullable=False),
# sa.Column('detail', sa.Text),
# sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
# )
"""
pass
def downgrade() -> None:
"""
# op.drop_column('pipelines', 'description')
# op.drop_table('audit_log')
"""
pass

View File

@@ -0,0 +1,150 @@
"""Programmatic Alembic runner for LangBot.
Usage from async code:
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade
await run_alembic_upgrade(async_engine)
CLI usage (autogenerate):
python -m langbot.pkg.persistence.alembic_runner autogenerate "add description column"
python -m langbot.pkg.persistence.alembic_runner upgrade
python -m langbot.pkg.persistence.alembic_runner current
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from alembic.config import Config
from alembic import command
from alembic.runtime.migration import MigrationContext
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.engine import Connection
_ALEMBIC_DIR = os.path.join(os.path.dirname(__file__), 'alembic')
def _build_config(connection: Connection) -> Config:
"""Build an Alembic Config with sync connection attached."""
cfg = Config()
cfg.set_main_option('script_location', _ALEMBIC_DIR)
cfg.attributes['connection'] = connection
return cfg
def _do_upgrade(connection: Connection, revision: str = 'head') -> None:
"""Synchronous upgrade — runs inside run_sync."""
cfg = _build_config(connection)
command.upgrade(cfg, revision)
def _do_stamp(connection: Connection, revision: str = 'head') -> None:
"""Synchronous stamp — runs inside run_sync."""
cfg = _build_config(connection)
command.stamp(cfg, revision)
def _do_get_current(connection: Connection) -> str | None:
"""Get current alembic revision synchronously."""
ctx = MigrationContext.configure(connection)
return ctx.get_current_revision()
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
"""Synchronous autogenerate — runs inside run_sync."""
cfg = _build_config(connection)
command.revision(cfg, message=message, autogenerate=True)
async def run_alembic_upgrade(async_engine: AsyncEngine, revision: str = 'head') -> None:
"""Run Alembic upgrade to the given revision."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_upgrade, revision)
await conn.commit()
async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -> None:
"""Stamp the database with a revision without running migrations."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_stamp, revision)
await conn.commit()
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
"""Get current alembic revision, or None if not stamped."""
async with async_engine.connect() as conn:
return await conn.run_sync(_do_get_current)
async def run_alembic_autogenerate(async_engine: AsyncEngine, message: str = 'auto migration') -> None:
"""Compare ORM models against DB schema and generate a migration script."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_autogenerate, message)
# CLI entrypoint: python -m langbot.pkg.persistence.alembic_runner <command> [args]
if __name__ == '__main__':
import sys
import asyncio
def _get_engine():
"""Create engine from data/config.yaml or default SQLite."""
from sqlalchemy.ext.asyncio import create_async_engine
try:
import yaml
with open('data/config.yaml') as f:
config = yaml.safe_load(f)
db_cfg = config.get('database', {})
db_type = db_cfg.get('use', 'sqlite')
if db_type == 'postgresql':
pg = db_cfg.get('postgresql', {})
url = (
f'postgresql+asyncpg://{pg.get("user", "postgres")}:{pg.get("password", "postgres")}'
f'@{pg.get("host", "127.0.0.1")}:{pg.get("port", 5432)}/{pg.get("database", "postgres")}'
)
else:
path = db_cfg.get('sqlite', {}).get('path', 'data/langbot.db')
url = f'sqlite+aiosqlite:///{path}'
except Exception:
url = 'sqlite+aiosqlite:///data/langbot.db'
return create_async_engine(url)
def main():
if len(sys.argv) < 2:
print('Usage: python -m langbot.pkg.persistence.alembic_runner <command> [args]')
print('Commands:')
print(' autogenerate "message" — Generate migration from ORM model diff')
print(' upgrade [revision] — Upgrade database (default: head)')
print(' stamp [revision] — Stamp revision without running (default: head)')
print(' current — Show current revision')
sys.exit(1)
cmd = sys.argv[1]
engine = _get_engine()
if cmd == 'autogenerate':
msg = sys.argv[2] if len(sys.argv) > 2 else 'auto migration'
asyncio.run(run_alembic_autogenerate(engine, msg))
print(f'Migration generated: {msg}')
elif cmd == 'upgrade':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_upgrade(engine, rev))
print(f'Upgraded to: {rev}')
elif cmd == 'stamp':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_stamp(engine, rev))
print(f'Stamped: {rev}')
elif cmd == 'current':
rev = asyncio.run(get_alembic_current(engine))
print(f'Current revision: {rev}')
else:
print(f'Unknown command: {cmd}')
sys.exit(1)
main()

View File

@@ -76,6 +76,9 @@ class PersistenceManager:
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
# Run Alembic migrations (new migration system)
await self._run_alembic_migrations()
await self.write_space_model_providers()
async def create_tables(self):
@@ -135,6 +138,28 @@ class PersistenceManager:
# =================================
async def _run_alembic_migrations(self):
"""Run Alembic-based migrations after legacy migrations complete."""
from . import alembic_runner
engine = self.get_db_engine()
try:
current_rev = await alembic_runner.get_alembic_current(engine)
if current_rev is None:
# First time: stamp baseline so Alembic knows existing schema is up-to-date
self.ap.logger.info('Alembic: no revision found, stamping baseline...')
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
current_rev = '0001_baseline'
# Upgrade to head
await alembic_runner.run_alembic_upgrade(engine, 'head')
self.ap.logger.info('Alembic migrations completed.')
except Exception as e:
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
raise
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
async with self.get_db_engine().connect() as conn:
result = await conn.execute(*args, **kwargs)

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

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

@@ -160,7 +160,6 @@ class PreProcessor(stage.PipelineStage):
elif me.url:
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
elif isinstance(me, platform_message.File):
# if me.url is not None:
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
elif isinstance(me, platform_message.Quote) and quote_msg:
for msg in me.origin:
@@ -172,6 +171,15 @@ class PreProcessor(stage.PipelineStage):
):
if msg.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
elif isinstance(msg, platform_message.File):
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
elif isinstance(msg, platform_message.Voice):
if msg.base64:
content_list.append(
provider_message.ContentElement.from_file_base64(msg.base64, 'voice.silk')
)
elif msg.url:
content_list.append(provider_message.ContentElement.from_file_url(msg.url, 'voice'))
query.variables['user_message_text'] = plain_text

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:
@@ -205,6 +208,7 @@ class ChatMessageHandler(handler.MessageHandler):
'model_name': model_name,
'version': constants.semantic_version,
'instance_id': constants.instance_id,
'edition': constants.edition,
'pipeline_plugins': pipeline_plugins,
'error': locals().get('error_info', None),
'timestamp': datetime.utcnow().isoformat(),

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
@@ -52,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,
@@ -83,6 +227,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,
@@ -91,7 +252,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')
@@ -126,6 +288,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,
@@ -134,7 +313,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')
@@ -241,12 +421,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

@@ -71,7 +71,8 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
else:
# 回退到原有简单逻辑
if event.content:
# 对于音频消息content 来自 recognition 转写文字,在下方音频处理块中统一处理
if event.content and event.type != 'audio':
text_content = event.content.replace('@' + bot_name, '')
yiri_msg_list.append(platform_message.Plain(text=text_content))
if event.picture:
@@ -81,7 +82,38 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if event.file:
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
if event.audio:
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
# 优先使用钉钉自带的语音转写文字recognition字段
if event.content and event.type == 'audio':
yiri_msg_list.append(platform_message.Plain(text=event.content))
else:
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
# Handle quoted/replied message - extract content as top-level components
# so that plugins like FileReader can process them the same way as direct messages
if event.quoted_message:
quote_info = event.quoted_message
msg_type = quote_info.get('msg_type', '')
# Process quoted file - add as top-level File component (same as private chat)
if msg_type == 'file' and quote_info.get('file_url'):
file_name = quote_info.get('file_name', 'file')
yiri_msg_list.append(platform_message.File(url=quote_info['file_url'], name=file_name))
# Process quoted image - add as top-level Image component
elif msg_type == 'picture' and quote_info.get('picture'):
yiri_msg_list.append(platform_message.Image(base64=quote_info['picture']))
# Process quoted audio - add as top-level Voice component
elif msg_type == 'audio' and quote_info.get('audio'):
yiri_msg_list.append(platform_message.Voice(base64=quote_info['audio']))
# Process quoted text - add as Plain text with context prefix
elif msg_type == 'text' and quote_info.get('content'):
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
# Process quoted rich text - add as Plain text with context prefix
elif msg_type == 'richText' and quote_info.get('content'):
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
chain = platform_message.MessageChain(yiri_msg_list)

View File

@@ -709,21 +709,29 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
# Check for quote/reply message
# Extract files/images/voice from quote and add them as top-level components
# so that plugins like FileReader can process them the same way as direct messages
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
if quote_message_id:
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
if quote_chain:
# Filter out Source component from quoted chain, keep only content
quote_origin = platform_message.MessageChain(
[comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
)
if quote_origin:
message_chain.append(
platform_message.Quote(
message_id=quote_message_id,
origin=quote_origin,
)
)
quote_components = [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
# Add quoted content as top-level components instead of wrapping in Quote
for comp in quote_components:
if isinstance(comp, platform_message.File):
# Add file as top-level component (same as direct message)
message_chain.append(comp)
elif isinstance(comp, platform_message.Image):
# Add image as top-level component
message_chain.append(comp)
elif isinstance(comp, platform_message.Voice):
# Add voice as top-level component
message_chain.append(comp)
elif isinstance(comp, platform_message.Plain):
# Add text with context prefix
message_chain.append(platform_message.Plain(text=f'[引用消息] {comp.text}'))
if event.event.message.chat_type == 'p2p':
return platform_events.FriendMessage(
@@ -797,8 +805,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 +1153,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 +1177,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 +1539,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

@@ -126,6 +126,107 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if summary:
yiri_msg_list.append(platform_message.Plain(text=summary))
# Handle quoted message (引用消息) - important for group chat file references
# Extract files/images/voice from quote and add them as top-level components
# so that plugins like FileReader can process them the same way as direct messages
quote_info = event.quote or {}
if quote_info:
# Process quote text content - add as Plain for context
if quote_info.get('content'):
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info.get("content")}'))
# Process quote images - add as top-level Image components
quote_images = quote_info.get('images', [])
if not quote_images and quote_info.get('picurl'):
quote_images = [quote_info.get('picurl')]
for img_data in quote_images:
if img_data:
yiri_msg_list.append(platform_message.Image(base64=img_data))
# Process quote file - add as top-level File component (same as private chat)
quote_file = quote_info.get('file') or {}
if quote_file:
file_url = (
quote_file.get('base64')
or quote_file.get('download_url')
or quote_file.get('url')
or quote_file.get('fileurl')
)
file_name = quote_file.get('filename') or quote_file.get('name')
file_size = quote_file.get('filesize') or quote_file.get('size')
if file_url or file_name:
file_kwargs = {}
if file_url:
file_kwargs['url'] = file_url
if file_name:
file_kwargs['name'] = file_name
if file_size is not None:
file_kwargs['size'] = file_size
try:
yiri_msg_list.append(platform_message.File(**file_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted file unsupported]'))
# Process quote voice - add as top-level Voice/File component
quote_voice = quote_info.get('voice') or {}
if quote_voice:
voice_payload = quote_voice.get('base64') or quote_voice.get('url')
if voice_payload:
if quote_voice.get('base64') and not voice_payload.startswith('data:'):
voice_payload = f'data:audio/mpeg;base64,{quote_voice.get("base64")}'
try:
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
except Exception:
try:
voice_kwargs = {'url': voice_payload}
voice_name = quote_voice.get('filename') or quote_voice.get('name')
voice_size = quote_voice.get('filesize') or quote_voice.get('size')
if voice_name:
voice_kwargs['name'] = voice_name
if voice_size is not None:
voice_kwargs['size'] = voice_size
yiri_msg_list.append(platform_message.File(**voice_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted voice unsupported]'))
# Process quote video - add as top-level File component
quote_video = quote_info.get('video') or {}
if quote_video:
video_payload = (
quote_video.get('base64')
or quote_video.get('url')
or quote_video.get('download_url')
or quote_video.get('fileurl')
)
if video_payload:
video_kwargs = {'url': video_payload}
video_name = quote_video.get('filename') or quote_video.get('name')
video_size = quote_video.get('filesize') or quote_video.get('size')
if video_name:
video_kwargs['name'] = video_name
if video_size is not None:
video_kwargs['size'] = video_size
try:
yiri_msg_list.append(platform_message.File(**video_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted video unsupported]'))
# Process quote link - add as Plain text
quote_link = quote_info.get('link') or {}
if quote_link:
link_summary = '\n'.join(
filter(
None,
[
quote_link.get('title', ''),
quote_link.get('description') or quote_link.get('digest', ''),
quote_link.get('url', ''),
],
)
)
if link_summary:
yiri_msg_list.append(platform_message.Plain(text=f'[引用链接] {link_summary}'))
has_content_element = any(
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
)
@@ -328,6 +429,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
feedback_type = kwargs.get('feedback_type', 0)
feedback_content = kwargs.get('feedback_content', '') or None
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) or None
# WeChat Work returns integer reason codes, but FeedbackEvent expects strings
if inaccurate_reasons:
inaccurate_reasons = [str(r) for r in inaccurate_reasons]
session = kwargs.get('session')
session_id = None
@@ -343,6 +447,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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,

View File

@@ -60,7 +60,16 @@ class TelemetryManager:
except Exception:
sanitized['query_id'] = str(sanitized.get('query_id', ''))
for sfield in ('adapter', 'runner', 'runner_category', 'model_name', 'version', 'error', 'timestamp'):
for sfield in (
'adapter',
'runner',
'runner_category',
'model_name',
'version',
'edition',
'error',
'timestamp',
):
v = sanitized.get(sfield)
sanitized[sfield] = '' if v is None else str(v)

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}'
required_database_version = 24
required_database_version = 25
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
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

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

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

38
uv.lock generated
View File

@@ -186,6 +186,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
name = "alembic"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -1832,7 +1846,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.9.5"
version = "4.9.6"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -1840,6 +1854,7 @@ dependencies = [
{ name = "aiohttp" },
{ name = "aioshutil" },
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "anthropic" },
{ name = "argon2-cffi" },
{ name = "async-lru" },
@@ -1919,6 +1934,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.11.18" },
{ name = "aioshutil", specifier = ">=1.5" },
{ name = "aiosqlite", specifier = ">=0.21.0" },
{ name = "alembic", specifier = ">=1.15.0" },
{ name = "anthropic", specifier = ">=0.51.0" },
{ name = "argon2-cffi", specifier = ">=23.1.0" },
{ name = "async-lru", specifier = ">=2.0.5" },
@@ -1937,7 +1953,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.6" },
{ name = "langbot-plugin", specifier = "==0.3.8" },
{ name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
{ name = "lark-oapi", specifier = ">=1.4.15" },
@@ -1993,7 +2009,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.3.6"
version = "0.3.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2011,9 +2027,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/f0/e5561bd1ebda0b9345ad6b98718b5f002bb3ca79b5ec294dc77cc10957b9/langbot_plugin-0.3.6.tar.gz", hash = "sha256:20db981e416a640f22246e54517abc2a095d8ccf5e69e06c2674fb8a443f5dbe", size = 179266, upload-time = "2026-03-30T15:58:58.523Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/d8/7c8ac9516e35d69ead3e934b408e48541f5772eb88fbed19cd216af4b6c2/langbot_plugin-0.3.8.tar.gz", hash = "sha256:e8e420c3b2f167c9635e3e0af46fb452895be9d68ec05bf112ac5f221c3316f3", size = 179803, upload-time = "2026-04-10T11:05:42.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/f5/ac424c2620e1be98a54a0b8ec0ed256a9c06cea7cd32a30732a1aea5fdc5/langbot_plugin-0.3.6-py3-none-any.whl", hash = "sha256:3238448436c41d50a0a0cf37438d845f0a1371159d440af3411a984e3d4e9eb7", size = 156752, upload-time = "2026-03-30T15:59:00.229Z" },
{ url = "https://files.pythonhosted.org/packages/81/63/4a61b67d4886522647e0b60063da155279b943a6b2e6cd004e29aedf67d1/langbot_plugin-0.3.8-py3-none-any.whl", hash = "sha256:2246f343b4735cb4004cf44462ffb47531222c21efeef163a4acd758ebbec2cd", size = 157354, upload-time = "2026-04-10T11:05:41.525Z" },
]
[[package]]
@@ -2409,6 +2425,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markdown"
version = "3.10.1"

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

16
web/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LangBot</title>
<meta
name="description"
content="Production-grade platform for building agentic IM bots"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

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"
]
},
@@ -46,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",
@@ -55,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",
@@ -65,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",
@@ -77,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",
@@ -95,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",

1912
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,10 +74,6 @@
}
}
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@theme inline {

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

@@ -16,6 +16,7 @@ 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';
@@ -64,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({
@@ -89,6 +112,7 @@ export default function BotForm({
adapter_config: {},
enable: true,
use_pipeline_uuid: '',
pipeline_routing_rules: [],
},
});
@@ -155,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);
@@ -270,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,
@@ -314,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)
@@ -464,6 +491,12 @@ export default function BotForm({
</FormItem>
)}
/>
{/* Pipeline Routing Rules */}
<RoutingRulesEditor
form={form}
pipelineNameList={pipelineNameList}
/>
</CardContent>
</Card>
)}

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 } 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;
@@ -147,14 +146,18 @@ const BotSessionMonitor = forwardRef<
}, [selectedSessionId, 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 parseMessageChain = (content: string): MessageChainComponent[] => {
@@ -393,7 +396,6 @@ const BotSessionMonitor = forwardRef<
<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>
);
@@ -449,12 +451,6 @@ const BotSessionMonitor = forwardRef<
</button>
</>
)}
{selectedSession?.pipeline_name && (
<>
<span>·</span>
<span>{selectedSession.pipeline_name}</span>
</>
)}
{selectedSession?.is_active && (
<>
<span>·</span>
@@ -485,6 +481,9 @@ const BotSessionMonitor = forwardRef<
) : (
messages.map((msg) => {
const isUser = isUserMessage(msg);
const isDiscarded =
msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD;
return (
<div
key={msg.id}
@@ -500,10 +499,11 @@ const BotSessionMonitor = forwardRef<
? 'bg-primary/10 rounded-br-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',
@@ -521,11 +521,25 @@ const BotSessionMonitor = forwardRef<
<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 ? (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Workflow className="w-3 h-3" />
{msg.pipeline_name}
</span>
) : null}
{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>
)}

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

@@ -249,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(),

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';
@@ -75,9 +76,14 @@ 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);
@@ -209,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:
@@ -1161,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

View File

@@ -1,8 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
@@ -29,7 +27,7 @@ import {
Github,
Zap,
} from 'lucide-react';
import { useTheme } from 'next-themes';
import { useTheme } from '@/components/providers/theme-provider';
import {
DropdownMenu,
@@ -244,9 +242,10 @@ function NavItems({
sectionOpenState: Record<string, boolean>;
onSectionToggle: (id: string, open: boolean) => void;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const sidebarData = useSidebarData();
const { setPendingPluginInstallAction } = sidebarData;
const { state: sidebarState, isMobile } = useSidebar();
@@ -413,7 +412,7 @@ function NavItems({
'bg-accent text-accent-foreground font-medium',
)}
onClick={() => {
router.push(itemRoute);
navigate(itemRoute);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -471,7 +470,7 @@ function NavItems({
)}
onClick={(e) => {
e.preventDefault();
router.push(itemRoute);
navigate(itemRoute);
}}
>
{item.emoji ? (
@@ -623,7 +622,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push('/home/market');
navigate('/home/market');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -638,7 +637,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
router.push('/home/plugins');
navigate('/home/plugins');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -652,7 +651,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
router.push('/home/plugins');
navigate('/home/plugins');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -669,7 +668,7 @@ function NavItems({
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={() => {
router.push(`${routePrefix}?id=new`);
navigate(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -720,7 +719,7 @@ function NavItems({
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
@@ -731,7 +730,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push('/home/market');
navigate('/home/market');
}}
>
<Store className="size-4" />
@@ -742,7 +741,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
router.push('/home/plugins');
navigate('/home/plugins');
}}
>
<Upload className="size-4" />
@@ -752,7 +751,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
router.push('/home/plugins');
navigate('/home/plugins');
}}
>
<Github className="size-4" />
@@ -763,10 +762,10 @@ function NavItems({
) : (
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation();
router.push(`${routePrefix}?id=new`);
navigate(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
@@ -1029,9 +1028,10 @@ export default function HomeSidebar({
}: {
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const { isMobile } = useSidebar();
useEffect(() => {
@@ -1071,14 +1071,16 @@ export default function HomeSidebar({
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showModelSettings');
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
router.replace(newUrl, { scroll: false });
navigate(newUrl, { preventScrollReset: true });
}
}
@@ -1087,14 +1089,16 @@ export default function HomeSidebar({
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showAccountSettings');
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
router.replace(newUrl, { scroll: false });
navigate(newUrl, { preventScrollReset: true });
}
}
@@ -1165,7 +1169,7 @@ export default function HomeSidebar({
// User click: update state AND navigate
function handleChildClick(child: SidebarChildVO) {
selectChild(child);
router.push(child.route);
navigate(child.route);
}
function initSelect() {
@@ -1226,7 +1230,7 @@ export default function HomeSidebar({
tooltip="LangBot"
>
<img
src={langbotIcon.src}
src={langbotIcon}
alt="LangBot"
className="size-8 rounded-lg"
/>
@@ -1406,7 +1410,7 @@ export default function HomeSidebar({
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
router.push('/wizard');
navigate('/wizard');
}}
>
<Zap className="text-blue-500" />

View File

@@ -1,5 +1,3 @@
'use client';
import React, {
createContext,
useContext,

View File

@@ -1,5 +1,3 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, Boxes } from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';

View File

@@ -1,5 +1,3 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';

View File

@@ -1,5 +1,3 @@
'use client';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

View File

@@ -1,5 +1,3 @@
'use client';
import { useState, useEffect } from 'react';
import { Trash2, Eye, Wrench, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';

View File

@@ -1,5 +1,3 @@
'use client';
import { useState } from 'react';
import {
Plus,
@@ -135,7 +133,7 @@ export default function ProviderCard({
{isLangBotModels ? (
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
<img
src={langbotIcon.src}
src={langbotIcon}
alt="LangBot"
className="w-full h-full object-cover"
/>

View File

@@ -1,5 +1,3 @@
'use client';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

View File

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

View File

@@ -1,5 +1,3 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import type {

View File

@@ -1,7 +1,5 @@
'use client';
import { useState, useEffect, 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 {
@@ -32,7 +30,7 @@ import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
export default function KBDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const router = useRouter();
const navigate = useNavigate();
const { t } = useTranslation();
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
useSidebarData();
@@ -84,12 +82,12 @@ export default function KBDetailContent({ id }: { id: string }) {
function handleKbDeleted() {
refreshKnowledgeBases();
router.push('/home/knowledge');
navigate('/home/knowledge');
}
function handleNewKbCreated(newKbId: string) {
refreshKnowledgeBases();
router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
navigate(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
}
function handleKbUpdated() {

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
@@ -219,6 +220,12 @@ export default function FileUploadZone({
<p className="text-sm text-yellow-800 dark:text-yellow-200">
{t('knowledge.documentsTab.noParserAvailable')}
</p>
<Link
to="/home/market?category=Parser"
className="text-sm text-primary hover:underline mt-1 inline-block"
>
{t('knowledge.documentsTab.installParserHint')}
</Link>
</div>
) : (
<div className="space-y-2">

View File

@@ -1,5 +1,3 @@
'use client';
import { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';

View File

@@ -1,5 +1,3 @@
'use client';
import {
ColumnDef,
flexRender,

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -304,7 +304,7 @@ export default function KBForm({
{t('knowledge.noEnginesAvailable')}
</p>
<Link
href="/home/market?category=KnowledgeEngine"
to="/home/market?category=KnowledgeEngine"
className="text-sm text-primary hover:underline"
>
{t('knowledge.installEngineHint')}

View File

@@ -1,5 +1,3 @@
'use client';
import { useState } from 'react';
import {
Dialog,

View File

@@ -1,5 +1,3 @@
'use client';
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';

View File

@@ -1,6 +1,4 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -10,7 +8,7 @@ import KBDetailContent from './KBDetailContent';
export default function KnowledgePage() {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
const { refreshKnowledgeBases } = useSidebarData();

View File

@@ -1,5 +1,3 @@
'use client';
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
import React, {
@@ -21,8 +19,8 @@ import {
initializeUserInfo,
initializeSystemInfo,
} from '@/app/infra/http';
import { usePathname, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useNavigate, useLocation } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { CircleHelp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -59,7 +57,7 @@ export default function HomeLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const router = useRouter();
const navigate = useNavigate();
// Initialize user info if not already initialized
useEffect(() => {
@@ -75,14 +73,14 @@ export default function HomeLayout({
// Always re-fetch to ensure we have the latest wizard_status from backend
await initializeSystemInfo();
if (systemInfo.wizard_status === 'none') {
router.replace('/wizard');
navigate('/wizard');
}
} catch {
// If fetching system info fails, don't redirect
}
};
checkWizard();
}, [router]);
}, [navigate]);
return (
<SidebarDataProvider>
@@ -101,7 +99,8 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
zh_Hans: '',
});
const { detailEntityName } = useSidebarData();
const pathname = usePathname();
const location = useLocation();
const pathname = location.pathname;
const { t } = useTranslation();
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
@@ -139,7 +138,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink asChild>
<Link href={sectionLink}>{sectionLabel}</Link>
<Link to={sectionLink}>{sectionLabel}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />

View File

@@ -1,5 +1,3 @@
'use client';
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
import {
Dialog,

View File

@@ -1,7 +1,5 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
@@ -30,7 +28,7 @@ import { toast } from 'sonner';
export default function MCPDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const router = useRouter();
const navigate = useNavigate();
const { t } = useTranslation();
const { refreshMCPServers, mcpServers, setDetailEntityName } =
useSidebarData();
@@ -96,12 +94,12 @@ export default function MCPDetailContent({ id }: { id: string }) {
function handleServerDeleted() {
refreshMCPServers();
router.push('/home/mcp');
navigate('/home/mcp');
}
function handleNewServerCreated(serverName: string) {
refreshMCPServers();
router.push(`/home/mcp?id=${encodeURIComponent(serverName)}`);
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
}
function confirmDelete() {

View File

@@ -1,5 +1,3 @@
'use client';
import React, {
useState,
useEffect,

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 MCPDetailContent from './MCPDetailContent';
export default function MCPPage() {
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 React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {

View File

@@ -1,5 +1,3 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import {

View File

@@ -1,5 +1,3 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import {

View File

@@ -1,5 +1,3 @@
'use client';
import React, { useState } from 'react';
import {
MessageChainComponent,

View File

@@ -1,5 +1,3 @@
'use client';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { MessageDetails } from '../types/monitoring';

View File

@@ -1,5 +1,3 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {

View File

@@ -1,5 +1,3 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';

View File

@@ -1,5 +1,3 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import MetricCard from './MetricCard';

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