mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-24 22:44:23 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc4d8838eb | |||
| fa0a77f09f | |||
| fd6a7b73d4 | |||
| bf0848d60b | |||
| e06fac2bb7 | |||
| bec61427a0 | |||
| 5fae7b2eb0 | |||
| 2eebdfe16a | |||
| 9cd3544d59 | |||
| de4d14fee3 | |||
| f29c568381 | |||
| af3f557055 | |||
| b894842736 | |||
| e190029e1f | |||
| e4940a8050 | |||
| 617c95ebc4 | |||
| 1cdd428bcc | |||
| 71ac719aee | |||
| 4621e6cc9f | |||
| 66087f83e1 | |||
| 25f9330491 | |||
| 14b1e0d33b | |||
| 83ccb33fd3 | |||
| 05bcf543ba | |||
| 7cd063bb5d | |||
| 8f1317b39e | |||
| 77a0de5ef0 | |||
| 875227a2fe | |||
| 2317392ee5 | |||
| c7efa4dd7f | |||
| e701daa8e0 | |||
| 1ae99199b2 | |||
| 7c067a1cb3 | |||
| 478bc62576 | |||
| a740eb8ee9 |
@@ -43,10 +43,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd /tmp/langbot_build_web/web
|
cd /tmp/langbot_build_web/web
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npx vite build
|
||||||
- name: Package Output
|
- name: Package Output
|
||||||
run: |
|
run: |
|
||||||
cp -r /tmp/langbot_build_web/web/out ./web
|
cp -r /tmp/langbot_build_web/web/dist ./web
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ jobs:
|
|||||||
npm install -g pnpm
|
npm install -g pnpm
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
mkdir -p ../src/langbot/web/out
|
mkdir -p ../src/langbot/web/dist
|
||||||
cp -r out ../src/langbot/web/
|
cp -r dist ../src/langbot/web/
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
name: Test Migrations
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- 'src/langbot/pkg/persistence/**'
|
||||||
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
paths:
|
||||||
|
- 'src/langbot/pkg/persistence/**'
|
||||||
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-migrations-sqlite:
|
||||||
|
name: Migrations (SQLite)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Test Alembic upgrade (SQLite)
|
||||||
|
run: |
|
||||||
|
uv run python -c "
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
engine = create_async_engine('sqlite+aiosqlite:///test_migrations.db')
|
||||||
|
|
||||||
|
# Create all tables (simulates existing DB)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
# Stamp baseline
|
||||||
|
await run_alembic_stamp(engine, '0001_baseline')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
|
||||||
|
print(f'Stamped: {rev}')
|
||||||
|
|
||||||
|
# Upgrade to head
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
print(f'After upgrade: {rev}')
|
||||||
|
assert rev is not None, 'Expected a revision after upgrade'
|
||||||
|
|
||||||
|
# Verify idempotent
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev2 = await get_alembic_current(engine)
|
||||||
|
assert rev2 == rev, f'Expected {rev}, got {rev2}'
|
||||||
|
print(f'Idempotent check passed: {rev2}')
|
||||||
|
|
||||||
|
# Fresh DB: upgrade from scratch
|
||||||
|
engine2 = create_async_engine('sqlite+aiosqlite:///test_migrations_fresh.db')
|
||||||
|
async with engine2.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await run_alembic_upgrade(engine2, 'head')
|
||||||
|
rev3 = await get_alembic_current(engine2)
|
||||||
|
print(f'Fresh DB upgrade: {rev3}')
|
||||||
|
assert rev3 is not None
|
||||||
|
|
||||||
|
print('All SQLite migration tests passed!')
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
|
||||||
|
test-migrations-postgres:
|
||||||
|
name: Migrations (PostgreSQL)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: langbot
|
||||||
|
POSTGRES_PASSWORD: langbot
|
||||||
|
POSTGRES_DB: langbot_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd="pg_isready -U langbot"
|
||||||
|
--health-interval=5s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Test Alembic upgrade (PostgreSQL)
|
||||||
|
run: |
|
||||||
|
uv run python -c "
|
||||||
|
import asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
|
||||||
|
|
||||||
|
DB_URL = 'postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test'
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
engine = create_async_engine(DB_URL)
|
||||||
|
|
||||||
|
# Create all tables
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
# Stamp baseline
|
||||||
|
await run_alembic_stamp(engine, '0001_baseline')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
|
||||||
|
print(f'Stamped: {rev}')
|
||||||
|
|
||||||
|
# Upgrade to head
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev = await get_alembic_current(engine)
|
||||||
|
print(f'After upgrade: {rev}')
|
||||||
|
assert rev is not None
|
||||||
|
|
||||||
|
# Verify idempotent
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
rev2 = await get_alembic_current(engine)
|
||||||
|
assert rev2 == rev, f'Expected {rev}, got {rev2}'
|
||||||
|
print(f'Idempotent check passed: {rev2}')
|
||||||
|
|
||||||
|
# Fresh DB: drop all and upgrade from scratch
|
||||||
|
engine2 = create_async_engine(DB_URL.replace('langbot_test', 'langbot_fresh'))
|
||||||
|
|
||||||
|
# Create fresh database
|
||||||
|
from sqlalchemy import text
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text('COMMIT'))
|
||||||
|
await conn.execute(text('CREATE DATABASE langbot_fresh'))
|
||||||
|
|
||||||
|
async with engine2.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await run_alembic_upgrade(engine2, 'head')
|
||||||
|
rev3 = await get_alembic_current(engine2)
|
||||||
|
print(f'Fresh DB upgrade: {rev3}')
|
||||||
|
assert rev3 is not None
|
||||||
|
|
||||||
|
print('All PostgreSQL migration tests passed!')
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
@@ -52,3 +52,6 @@ src/langbot/web/
|
|||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
|
# Next.js build cache (legacy)
|
||||||
|
web/.next/
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
|
|
||||||
RUN cd web && npm install && npm run build
|
RUN cd web && npm install && npx vite build
|
||||||
|
|
||||||
FROM python:3.12.7-slim
|
FROM python:3.12.7-slim
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
COPY --from=node /app/web/out ./web/out
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
RUN apt update \
|
RUN apt update \
|
||||||
&& apt install gcc -y \
|
&& apt install gcc -y \
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.5"
|
version = "4.9.6"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -39,6 +39,7 @@ dependencies = [
|
|||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"slack-sdk>=3.35.0",
|
"slack-sdk>=3.35.0",
|
||||||
|
"alembic>=1.15.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.40",
|
"sqlalchemy[asyncio]>=2.0.40",
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
"telegramify-markdown>=0.5.1",
|
"telegramify-markdown>=0.5.1",
|
||||||
@@ -64,7 +65,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.6",
|
"langbot-plugin==0.3.8",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
@@ -111,7 +112,7 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.9.5'
|
__version__ = '4.9.6'
|
||||||
|
|||||||
@@ -182,6 +182,88 @@ class DingTalkClient:
|
|||||||
for handler in self._message_handlers[msg_type]:
|
for handler in self._message_handlers[msg_type]:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
||||||
|
async def _parse_quoted_message(self, replied_msg: dict) -> dict:
|
||||||
|
"""Parse the quoted/replied message and extract its content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
replied_msg: The repliedMsg object from DingTalk message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing the quoted message info with keys:
|
||||||
|
- message_id: The original message ID
|
||||||
|
- msg_type: The message type (text, file, picture, audio, etc.)
|
||||||
|
- content: The text content (if any)
|
||||||
|
- file_url: The file download URL (if file type)
|
||||||
|
- file_name: The file name (if file type)
|
||||||
|
- picture: The picture base64 (if picture type)
|
||||||
|
- audio: The audio base64 (if audio type)
|
||||||
|
"""
|
||||||
|
quote_info = {
|
||||||
|
'message_id': replied_msg.get('msgId', ''),
|
||||||
|
'msg_type': replied_msg.get('msgType', ''),
|
||||||
|
'sender_id': replied_msg.get('senderId', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg_type = replied_msg.get('msgType', '')
|
||||||
|
content = replied_msg.get('content', {})
|
||||||
|
|
||||||
|
# Handle content as string (JSON) or dict
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
content = json.loads(content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
content = {}
|
||||||
|
|
||||||
|
if msg_type == 'text':
|
||||||
|
# Text message
|
||||||
|
if isinstance(content, dict):
|
||||||
|
quote_info['content'] = content.get('content', '')
|
||||||
|
else:
|
||||||
|
quote_info['content'] = str(content)
|
||||||
|
|
||||||
|
elif msg_type == 'file':
|
||||||
|
# File message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
file_name = content.get('fileName')
|
||||||
|
if download_code and file_name:
|
||||||
|
try:
|
||||||
|
quote_info['file_url'] = await self.get_file_url(download_code)
|
||||||
|
quote_info['file_name'] = file_name
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to get quoted file URL: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'picture':
|
||||||
|
# Picture message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
try:
|
||||||
|
quote_info['picture'] = await self.download_image(download_code)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to download quoted image: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'audio':
|
||||||
|
# Audio message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
try:
|
||||||
|
quote_info['audio'] = await self.get_audio_url(download_code)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to get quoted audio: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'richText':
|
||||||
|
# Rich text message - extract text content
|
||||||
|
rich_text = content.get('richText', [])
|
||||||
|
texts = []
|
||||||
|
for item in rich_text:
|
||||||
|
if 'text' in item and item['text'] != '\n':
|
||||||
|
texts.append(item['text'])
|
||||||
|
quote_info['content'] = '\n'.join(texts)
|
||||||
|
|
||||||
|
return quote_info
|
||||||
|
|
||||||
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
||||||
try:
|
try:
|
||||||
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
||||||
@@ -193,6 +275,15 @@ class DingTalkClient:
|
|||||||
elif str(incoming_message.conversation_type) == '2':
|
elif str(incoming_message.conversation_type) == '2':
|
||||||
message_data['conversation_type'] = 'GroupMessage'
|
message_data['conversation_type'] = 'GroupMessage'
|
||||||
|
|
||||||
|
# Check for quoted/replied message
|
||||||
|
raw_data = incoming_message.to_dict()
|
||||||
|
text_data = raw_data.get('text', {})
|
||||||
|
if isinstance(text_data, dict) and text_data.get('isReplyMsg'):
|
||||||
|
replied_msg = text_data.get('repliedMsg', {})
|
||||||
|
if replied_msg:
|
||||||
|
quote_info = await self._parse_quoted_message(replied_msg)
|
||||||
|
message_data['QuotedMessage'] = quote_info
|
||||||
|
|
||||||
if incoming_message.message_type == 'richText':
|
if incoming_message.message_type == 'richText':
|
||||||
data = incoming_message.rich_text_content.to_dict()
|
data = incoming_message.rich_text_content.to_dict()
|
||||||
|
|
||||||
@@ -268,7 +359,25 @@ class DingTalkClient:
|
|||||||
|
|
||||||
message_data['Type'] = 'image'
|
message_data['Type'] = 'image'
|
||||||
elif incoming_message.message_type == 'audio':
|
elif incoming_message.message_type == 'audio':
|
||||||
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
|
raw_content = incoming_message.to_dict().get('content', {})
|
||||||
|
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||||||
|
if isinstance(raw_content, str):
|
||||||
|
try:
|
||||||
|
raw_content = json.loads(raw_content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raw_content = {}
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
# 提取钉钉自带的语音转写文字(Powered by Qwen)
|
||||||
|
recognition = raw_content.get('recognition', '')
|
||||||
|
if recognition:
|
||||||
|
message_data['Content'] = recognition
|
||||||
|
|
||||||
|
download_code = raw_content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
message_data['Audio'] = await self.get_audio_url(download_code)
|
||||||
|
|
||||||
message_data['Type'] = 'audio'
|
message_data['Type'] = 'audio'
|
||||||
elif incoming_message.message_type == 'file':
|
elif incoming_message.message_type == 'file':
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class DingTalkEvent(dict):
|
|||||||
def conversation(self):
|
def conversation(self):
|
||||||
return self.get('conversation_type', '')
|
return self.get('conversation_type', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quoted_message(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get the quoted/replied message info if this is a reply message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing:
|
||||||
|
- message_id: The original message ID
|
||||||
|
- msg_type: The message type (text, file, picture, audio, etc.)
|
||||||
|
- content: The text content (if any)
|
||||||
|
- file_url: The file download URL (if file type)
|
||||||
|
- file_name: The file name (if file type)
|
||||||
|
- picture: The picture base64 (if picture type)
|
||||||
|
- audio: The audio base64 (if audio type)
|
||||||
|
"""
|
||||||
|
return self.get('QuotedMessage')
|
||||||
|
|
||||||
def __getattr__(self, key: str) -> Optional[Any]:
|
def __getattr__(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
允许通过属性访问数据中的任意字段。
|
允许通过属性访问数据中的任意字段。
|
||||||
|
|||||||
@@ -228,6 +228,9 @@ class StreamSessionManager:
|
|||||||
msg_id = session.msg_id
|
msg_id = session.msg_id
|
||||||
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
||||||
self._msg_index.pop(msg_id, None)
|
self._msg_index.pop(msg_id, None)
|
||||||
|
# Clean up feedback index for expired sessions
|
||||||
|
if session.feedback_id:
|
||||||
|
self._feedback_index.pop(session.feedback_id, None)
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||||
@@ -434,10 +437,10 @@ async def parse_wecom_bot_message(
|
|||||||
}
|
}
|
||||||
if voice_info.get('content'):
|
if voice_info.get('content'):
|
||||||
message_data['content'] = voice_info.get('content')
|
message_data['content'] = voice_info.get('content')
|
||||||
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
# if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
||||||
voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
# voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
if voice_base64:
|
# if voice_base64:
|
||||||
message_data['voice']['base64'] = voice_base64
|
# message_data['voice']['base64'] = voice_base64
|
||||||
elif msg_type == 'video':
|
elif msg_type == 'video':
|
||||||
video_info = msg_json.get('video', {}) or {}
|
video_info = msg_json.get('video', {}) or {}
|
||||||
download_url = video_info.get('url')
|
download_url = video_info.get('url')
|
||||||
@@ -449,10 +452,12 @@ async def parse_wecom_bot_message(
|
|||||||
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
'filename': video_info.get('filename') or video_info.get('name'),
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
}
|
}
|
||||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
# if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
# video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
if video_base64:
|
# if video_base64:
|
||||||
video_data['base64'] = video_base64
|
# video_data['base64'] = video_base64
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
message_data['video'] = video_data
|
message_data['video'] = video_data
|
||||||
elif msg_type == 'file':
|
elif msg_type == 'file':
|
||||||
file_info = msg_json.get('file', {}) or {}
|
file_info = msg_json.get('file', {}) or {}
|
||||||
@@ -466,12 +471,15 @@ async def parse_wecom_bot_message(
|
|||||||
'download_url': download_url,
|
'download_url': download_url,
|
||||||
'extra': file_info,
|
'extra': file_info,
|
||||||
}
|
}
|
||||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
# if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
# file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
||||||
if file_bytes:
|
# if file_bytes:
|
||||||
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
# file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||||
if dl_filename and not file_data.get('filename'):
|
# if dl_filename and not file_data.get('filename'):
|
||||||
file_data['filename'] = dl_filename
|
# file_data['filename'] = dl_filename
|
||||||
|
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
message_data['file'] = file_data
|
message_data['file'] = file_data
|
||||||
elif msg_type == 'link':
|
elif msg_type == 'link':
|
||||||
message_data['link'] = msg_json.get('link', {})
|
message_data['link'] = msg_json.get('link', {})
|
||||||
@@ -587,6 +595,120 @@ async def parse_wecom_bot_message(
|
|||||||
if msg_json.get('aibotid'):
|
if msg_json.get('aibotid'):
|
||||||
message_data['aibotid'] = msg_json.get('aibotid', '')
|
message_data['aibotid'] = msg_json.get('aibotid', '')
|
||||||
|
|
||||||
|
# Handle quote (referenced message) - important for group chat file references
|
||||||
|
quote_info = msg_json.get('quote')
|
||||||
|
if quote_info:
|
||||||
|
quote_data: dict[str, Any] = {}
|
||||||
|
quote_type = quote_info.get('msgtype', '')
|
||||||
|
quote_data['msgtype'] = quote_type
|
||||||
|
|
||||||
|
if quote_type == 'text':
|
||||||
|
quote_data['content'] = quote_info.get('text', {}).get('content', '')
|
||||||
|
elif quote_type == 'image':
|
||||||
|
img_info = quote_info.get('image', {})
|
||||||
|
img_url = img_info.get('url', '')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
|
if base64_data:
|
||||||
|
quote_data['picurl'] = base64_data
|
||||||
|
quote_data['images'] = [base64_data]
|
||||||
|
elif quote_type == 'file':
|
||||||
|
file_info = quote_info.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
|
file_data = {
|
||||||
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
|
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||||
|
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||||
|
'download_url': download_url,
|
||||||
|
'extra': file_info,
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['file'] = file_data
|
||||||
|
elif quote_type == 'voice':
|
||||||
|
voice_info = quote_info.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
item_aeskey = voice_info.get('aeskey', '')
|
||||||
|
voice_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||||
|
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||||
|
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||||
|
}
|
||||||
|
if voice_info.get('content'):
|
||||||
|
quote_data['content'] = voice_info.get('content')
|
||||||
|
# Same as private chat: append aeskey to url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
voice_data['url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['voice'] = voice_data
|
||||||
|
elif quote_type == 'video':
|
||||||
|
video_info = quote_info.get('video', {}) or {}
|
||||||
|
download_url = video_info.get('url')
|
||||||
|
item_aeskey = video_info.get('aeskey', '')
|
||||||
|
video_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||||
|
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||||
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
video_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['video'] = video_data
|
||||||
|
elif quote_type == 'link':
|
||||||
|
quote_data['link'] = quote_info.get('link', {})
|
||||||
|
link = quote_data['link']
|
||||||
|
title = link.get('title', '')
|
||||||
|
desc = link.get('description') or link.get('digest', '')
|
||||||
|
quote_data['content'] = '\n'.join(filter(None, [title, desc]))
|
||||||
|
elif quote_type == 'mixed':
|
||||||
|
# Handle mixed type in quote (text + images + files etc.)
|
||||||
|
items = quote_info.get('mixed', {}).get('msg_item', [])
|
||||||
|
texts = []
|
||||||
|
images = []
|
||||||
|
files = []
|
||||||
|
for item in items:
|
||||||
|
item_type = item.get('msgtype')
|
||||||
|
if item_type == 'text':
|
||||||
|
texts.append(item.get('text', {}).get('content', ''))
|
||||||
|
elif item_type == 'image':
|
||||||
|
img_info = item.get('image', {})
|
||||||
|
img_url = img_info.get('url')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
|
if base64_data:
|
||||||
|
images.append(base64_data)
|
||||||
|
elif item_type == 'file':
|
||||||
|
file_info = item.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
|
file_data = {
|
||||||
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
|
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||||
|
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||||
|
'download_url': download_url,
|
||||||
|
'extra': file_info,
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
files.append(file_data)
|
||||||
|
if texts:
|
||||||
|
quote_data['content'] = ' '.join(texts)
|
||||||
|
if images:
|
||||||
|
quote_data['images'] = images
|
||||||
|
quote_data['picurl'] = images[0]
|
||||||
|
if files:
|
||||||
|
quote_data['files'] = files
|
||||||
|
quote_data['file'] = files[0]
|
||||||
|
|
||||||
|
message_data['quote'] = quote_data
|
||||||
|
|
||||||
return message_data
|
return message_data
|
||||||
|
|
||||||
|
|
||||||
@@ -898,35 +1020,38 @@ class WecomBotClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||||
|
|
||||||
if session:
|
if session:
|
||||||
await self.logger.info(
|
await self.logger.info(
|
||||||
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
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:
|
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:
|
except Exception:
|
||||||
await self.logger.error(traceback.format_exc())
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|||||||
@@ -147,3 +147,10 @@ class WecomBotEvent(dict):
|
|||||||
流式消息 ID
|
流式消息 ID
|
||||||
"""
|
"""
|
||||||
return self.get('stream_id', '')
|
return self.get('stream_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quote(self):
|
||||||
|
"""
|
||||||
|
引用消息信息(群聊中用户引用其他消息时返回)
|
||||||
|
"""
|
||||||
|
return self.get('quote', {})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any, Callable, Optional
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message
|
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||||
@@ -96,6 +96,12 @@ class WecomBotWsClient:
|
|||||||
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
||||||
# Dedup: skip sending when content hasn't changed
|
# Dedup: skip sending when content hasn't changed
|
||||||
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
||||||
|
# Stream session info for feedback tracking
|
||||||
|
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
|
||||||
|
# Feedback tracking: feedback_id -> session info
|
||||||
|
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
|
||||||
|
# msg_id -> feedback_id (for associating feedback with message)
|
||||||
|
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
|
||||||
|
|
||||||
# ── Public API ──────────────────────────────────────────────────
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -164,12 +170,27 @@ class WecomBotWsClient:
|
|||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def on_feedback(self) -> Callable:
|
||||||
|
"""Decorator to register a feedback event handler.
|
||||||
|
|
||||||
|
Same interface as WecomBotClient.on_feedback for compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable):
|
||||||
|
if 'feedback' not in self._message_handlers:
|
||||||
|
self._message_handlers['feedback'] = []
|
||||||
|
self._message_handlers['feedback'].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
async def reply_stream(
|
async def reply_stream(
|
||||||
self,
|
self,
|
||||||
req_id: str,
|
req_id: str,
|
||||||
stream_id: str,
|
stream_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
finish: bool = False,
|
finish: bool = False,
|
||||||
|
feedback_id: str = '',
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""Send a streaming reply frame.
|
"""Send a streaming reply frame.
|
||||||
|
|
||||||
@@ -178,17 +199,22 @@ class WecomBotWsClient:
|
|||||||
stream_id: The stream ID for this streaming session.
|
stream_id: The stream ID for this streaming session.
|
||||||
content: The content to send (supports Markdown).
|
content: The content to send (supports Markdown).
|
||||||
finish: Whether this is the final chunk.
|
finish: Whether this is the final chunk.
|
||||||
|
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The ACK frame dict, or None on failure.
|
The ACK frame dict, or None on failure.
|
||||||
"""
|
"""
|
||||||
|
stream_payload = {
|
||||||
|
'id': stream_id,
|
||||||
|
'finish': finish,
|
||||||
|
'content': content,
|
||||||
|
}
|
||||||
|
if feedback_id:
|
||||||
|
stream_payload['feedback'] = {'id': feedback_id}
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
'msgtype': 'stream',
|
'msgtype': 'stream',
|
||||||
'stream': {
|
'stream': stream_payload,
|
||||||
'id': stream_id,
|
|
||||||
'finish': finish,
|
|
||||||
'content': content,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return await self._send_reply(req_id, body)
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
@@ -253,11 +279,23 @@ class WecomBotWsClient:
|
|||||||
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
||||||
if not is_final and content == self._stream_last_content.get(msg_id):
|
if not is_final and content == self._stream_last_content.get(msg_id):
|
||||||
return True
|
return True
|
||||||
await self.reply_stream(req_id, stream_id, content, finish=is_final)
|
|
||||||
|
# Generate feedback_id for final chunk
|
||||||
|
feedback_id = ''
|
||||||
|
if is_final:
|
||||||
|
feedback_id = _generate_req_id('feedback')
|
||||||
|
self._msg_feedback_ids[msg_id] = feedback_id
|
||||||
|
# Store session info for feedback tracking
|
||||||
|
session_info = self._stream_sessions.get(msg_id)
|
||||||
|
if session_info:
|
||||||
|
self._feedback_sessions[feedback_id] = session_info
|
||||||
|
|
||||||
|
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
|
||||||
self._stream_last_content[msg_id] = content
|
self._stream_last_content[msg_id] = content
|
||||||
if is_final:
|
if is_final:
|
||||||
self._stream_ids.pop(msg_id, None)
|
self._stream_ids.pop(msg_id, None)
|
||||||
self._stream_last_content.pop(msg_id, None)
|
self._stream_last_content.pop(msg_id, None)
|
||||||
|
self._stream_sessions.pop(msg_id, None)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
||||||
@@ -445,6 +483,15 @@ class WecomBotWsClient:
|
|||||||
msg_id = message_data.get('msgid', '')
|
msg_id = message_data.get('msgid', '')
|
||||||
if msg_id:
|
if msg_id:
|
||||||
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
||||||
|
# Store session info for feedback tracking
|
||||||
|
self._stream_sessions[msg_id] = {
|
||||||
|
'req_id': req_id,
|
||||||
|
'stream_id': stream_id,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'user_id': message_data.get('userid', ''),
|
||||||
|
'chat_id': message_data.get('chatid', ''),
|
||||||
|
'chat_type': message_data.get('type', 'single'),
|
||||||
|
}
|
||||||
message_data['stream_id'] = stream_id
|
message_data['stream_id'] = stream_id
|
||||||
message_data['req_id'] = req_id
|
message_data['req_id'] = req_id
|
||||||
|
|
||||||
@@ -454,7 +501,7 @@ class WecomBotWsClient:
|
|||||||
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _handle_event_callback(self, frame: dict):
|
async def _handle_event_callback(self, frame: dict):
|
||||||
"""Handle an incoming event callback frame (enter_chat, template_card_event, etc.)."""
|
"""Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event)."""
|
||||||
try:
|
try:
|
||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
@@ -479,14 +526,54 @@ class WecomBotWsClient:
|
|||||||
if body.get('chatid'):
|
if body.get('chatid'):
|
||||||
message_data['chatid'] = body.get('chatid', '')
|
message_data['chatid'] = body.get('chatid', '')
|
||||||
|
|
||||||
|
if event_type == 'feedback_event':
|
||||||
|
feedback_event = event_info.get('feedback_event', {})
|
||||||
|
feedback_id = feedback_event.get('id', '')
|
||||||
|
feedback_type = feedback_event.get('type', 0)
|
||||||
|
feedback_content = feedback_event.get('content', '')
|
||||||
|
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
||||||
|
|
||||||
|
await self.logger.info(
|
||||||
|
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
|
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up session by feedback_id
|
||||||
|
session_info = self._feedback_sessions.get(feedback_id)
|
||||||
|
session = None
|
||||||
|
if session_info:
|
||||||
|
session = StreamSession(
|
||||||
|
stream_id=session_info.get('stream_id', ''),
|
||||||
|
msg_id=session_info.get('msg_id', ''),
|
||||||
|
chat_id=session_info.get('chat_id') or None,
|
||||||
|
user_id=session_info.get('user_id') or None,
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
)
|
||||||
|
await self.logger.info(
|
||||||
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
|
||||||
|
|
||||||
|
for handler in self._message_handlers.get('feedback', []):
|
||||||
|
try:
|
||||||
|
await handler(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
|
||||||
# Dispatch to event-specific handlers
|
|
||||||
if event_type in self._message_handlers:
|
if event_type in self._message_handlers:
|
||||||
for handler in self._message_handlers[event_type]:
|
for handler in self._message_handlers[event_type]:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
||||||
# Also dispatch to generic 'event' handlers
|
|
||||||
if 'event' in self._message_handlers:
|
if 'event' in self._message_handlers:
|
||||||
for handler in self._message_handlers['event']:
|
for handler in self._message_handlers['event']:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|||||||
@@ -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}')
|
||||||
@@ -105,23 +105,24 @@ class HTTPController:
|
|||||||
):
|
):
|
||||||
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
||||||
path += '.html'
|
path += '.html'
|
||||||
elif path.startswith('home/'):
|
elif not path.startswith('api/'):
|
||||||
# SPA fallback for /home/* sub-routes.
|
# SPA fallback: serve index.html for all non-API, non-static routes
|
||||||
# Entity detail views use query params (e.g. /home/bots?id=uuid),
|
# so that React Router can handle client-side routing (Vite SPA).
|
||||||
# so the pre-rendered list page is served directly via path + '.html'.
|
# For /home/* sub-routes, first try parent .html files (pre-rendered pages).
|
||||||
# This fallback handles any remaining unmatched sub-paths.
|
if path.startswith('home/'):
|
||||||
segments = path.rstrip('/').split('/')
|
segments = path.rstrip('/').split('/')
|
||||||
|
for i in range(len(segments) - 1, 0, -1):
|
||||||
|
parent_path = '/'.join(segments[:i]) + '.html'
|
||||||
|
if os.path.exists(os.path.join(frontend_path, parent_path)):
|
||||||
|
response = await quart.send_from_directory(
|
||||||
|
frontend_path, parent_path, mimetype='text/html'
|
||||||
|
)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||||
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
response.headers['Expires'] = '0'
|
||||||
|
return response
|
||||||
|
|
||||||
# Walk up parent segments looking for matching .html files
|
# Fallback to index.html for SPA client-side routing
|
||||||
for i in range(len(segments) - 1, 0, -1):
|
|
||||||
parent_path = '/'.join(segments[:i]) + '.html'
|
|
||||||
if os.path.exists(os.path.join(frontend_path, parent_path)):
|
|
||||||
response = await quart.send_from_directory(frontend_path, parent_path, mimetype='text/html')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
|
||||||
response.headers['Pragma'] = 'no-cache'
|
|
||||||
response.headers['Expires'] = '0'
|
|
||||||
return response
|
|
||||||
# Final fallback to index.html for /home/* routes
|
|
||||||
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
||||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||||
response.headers['Pragma'] = 'no-cache'
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
|||||||
@@ -1224,30 +1224,83 @@ class MonitoringService:
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
record_id = str(uuid.uuid4())
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
record_data = {
|
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
|
||||||
'id': record_id,
|
|
||||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
||||||
'feedback_id': feedback_id,
|
|
||||||
'feedback_type': feedback_type,
|
|
||||||
'feedback_content': feedback_content,
|
|
||||||
'inaccurate_reasons': json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None,
|
|
||||||
'bot_id': bot_id,
|
|
||||||
'bot_name': bot_name,
|
|
||||||
'pipeline_id': pipeline_id,
|
|
||||||
'pipeline_name': pipeline_name,
|
|
||||||
'session_id': session_id,
|
|
||||||
'message_id': message_id,
|
|
||||||
'stream_id': stream_id,
|
|
||||||
'user_id': user_id,
|
|
||||||
'platform': platform,
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
|
||||||
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
|
|
||||||
|
# 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(
|
async def get_feedback_stats(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class UserService:
|
|||||||
|
|
||||||
user_obj = result_list[0]
|
user_obj = result_list[0]
|
||||||
|
|
||||||
# Check if this is a Space account
|
# Check if this user has a local password set
|
||||||
if user_obj.account_type == 'space':
|
if not user_obj.password:
|
||||||
raise ValueError('请使用 Space 账户登录')
|
raise ValueError('请使用 Space 账户登录')
|
||||||
|
|
||||||
ph = argon2.PasswordHasher()
|
ph = argon2.PasswordHasher()
|
||||||
@@ -108,9 +108,8 @@ class UserService:
|
|||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
|
|
||||||
# Space accounts cannot change password locally
|
if not user_obj.password:
|
||||||
if user_obj.account_type == 'space':
|
raise ValueError('No local password set, please set a password first')
|
||||||
raise ValueError('Space account cannot change password locally')
|
|
||||||
|
|
||||||
ph.verify(user_obj.password, current_password)
|
ph.verify(user_obj.password, current_password)
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,12 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
|||||||
if i == len(keys) - 1:
|
if i == len(keys) - 1:
|
||||||
# At the final key
|
# At the final key
|
||||||
if key in current:
|
if key in current:
|
||||||
if isinstance(current[key], (dict, list)):
|
if isinstance(current[key], list):
|
||||||
# Skip dict and list types
|
# Convert comma-separated string to list
|
||||||
|
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
|
||||||
|
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||||
|
elif isinstance(current[key], dict):
|
||||||
|
# Skip dict types
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Valid scalar value - convert and set it
|
# Valid scalar value - convert and set it
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Bot(Base):
|
|||||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
sqlalchemy.DateTime,
|
sqlalchemy.DateTime,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Alembic environment for LangBot.
|
||||||
|
|
||||||
|
This env.py is designed to be called programmatically (not via CLI).
|
||||||
|
It supports both SQLite and PostgreSQL.
|
||||||
|
|
||||||
|
The sync connection is passed via config attributes by the runner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode — emit SQL without a live connection."""
|
||||||
|
url = context.config.get_main_option('sqlalchemy.url')
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={'paramstyle': 'named'},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations with a live sync connection passed via config attributes."""
|
||||||
|
connection: Connection = context.config.attributes.get('connection')
|
||||||
|
if connection is None:
|
||||||
|
raise RuntimeError('connection not provided in alembic config attributes')
|
||||||
|
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
# render_as_batch=True is critical for SQLite ALTER TABLE support
|
||||||
|
render_as_batch=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Alembic script.py.mako — template for auto-generated revisions
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""baseline: stamp existing schema (db version 25)
|
||||||
|
|
||||||
|
This is a no-op migration that marks the starting point for Alembic.
|
||||||
|
All tables already exist via create_all() + legacy DBMigration system.
|
||||||
|
|
||||||
|
Revision ID: 0001_baseline
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-04-08
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = '0001_baseline'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# No-op: existing schema is already at database_version=25
|
||||||
|
# This revision serves as the Alembic baseline.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""example: sample migration demonstrating Alembic patterns
|
||||||
|
|
||||||
|
This is a SAMPLE showing how to write migrations that work
|
||||||
|
seamlessly across SQLite and PostgreSQL. Delete or adapt as needed.
|
||||||
|
|
||||||
|
Revision ID: 0002_sample
|
||||||
|
Revises: 0001_baseline
|
||||||
|
Create Date: 2026-04-08
|
||||||
|
|
||||||
|
Patterns demonstrated:
|
||||||
|
1. Schema change (add column) — works on both DBs via render_as_batch
|
||||||
|
2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = '0002_sample'
|
||||||
|
down_revision = '0001_baseline'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""
|
||||||
|
EXAMPLE: Uncomment to use. This shows the patterns.
|
||||||
|
|
||||||
|
# --- Pattern 1: Schema change (add/drop column) ---
|
||||||
|
# render_as_batch=True in env.py makes this work on SQLite too.
|
||||||
|
#
|
||||||
|
# op.add_column('pipelines', sa.Column('description', sa.String(512), server_default=''))
|
||||||
|
|
||||||
|
# --- Pattern 2: Data migration (read + modify JSON field) ---
|
||||||
|
# No if/else for sqlite vs postgres needed!
|
||||||
|
#
|
||||||
|
# conn = op.get_bind()
|
||||||
|
# rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall()
|
||||||
|
# for row in rows:
|
||||||
|
# config = json.loads(row[1]) if isinstance(row[1], str) else row[1]
|
||||||
|
# # Modify the config
|
||||||
|
# config.setdefault('ai', {}).setdefault('some_new_key', 'default_value')
|
||||||
|
# conn.execute(
|
||||||
|
# sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"),
|
||||||
|
# {"cfg": json.dumps(config), "uuid": row[0]}
|
||||||
|
# )
|
||||||
|
|
||||||
|
# --- Pattern 3: Create a new table ---
|
||||||
|
#
|
||||||
|
# op.create_table(
|
||||||
|
# 'audit_log',
|
||||||
|
# sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
# sa.Column('action', sa.String(255), nullable=False),
|
||||||
|
# sa.Column('detail', sa.Text),
|
||||||
|
# sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
|
||||||
|
# )
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""
|
||||||
|
# op.drop_column('pipelines', 'description')
|
||||||
|
# op.drop_table('audit_log')
|
||||||
|
"""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Programmatic Alembic runner for LangBot.
|
||||||
|
|
||||||
|
Usage from async code:
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade
|
||||||
|
await run_alembic_upgrade(async_engine)
|
||||||
|
|
||||||
|
CLI usage (autogenerate):
|
||||||
|
python -m langbot.pkg.persistence.alembic_runner autogenerate "add description column"
|
||||||
|
python -m langbot.pkg.persistence.alembic_runner upgrade
|
||||||
|
python -m langbot.pkg.persistence.alembic_runner current
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic import command
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
|
||||||
|
_ALEMBIC_DIR = os.path.join(os.path.dirname(__file__), 'alembic')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_config(connection: Connection) -> Config:
|
||||||
|
"""Build an Alembic Config with sync connection attached."""
|
||||||
|
cfg = Config()
|
||||||
|
cfg.set_main_option('script_location', _ALEMBIC_DIR)
|
||||||
|
cfg.attributes['connection'] = connection
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _do_upgrade(connection: Connection, revision: str = 'head') -> None:
|
||||||
|
"""Synchronous upgrade — runs inside run_sync."""
|
||||||
|
cfg = _build_config(connection)
|
||||||
|
command.upgrade(cfg, revision)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_stamp(connection: Connection, revision: str = 'head') -> None:
|
||||||
|
"""Synchronous stamp — runs inside run_sync."""
|
||||||
|
cfg = _build_config(connection)
|
||||||
|
command.stamp(cfg, revision)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_get_current(connection: Connection) -> str | None:
|
||||||
|
"""Get current alembic revision synchronously."""
|
||||||
|
ctx = MigrationContext.configure(connection)
|
||||||
|
return ctx.get_current_revision()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
|
||||||
|
"""Synchronous autogenerate — runs inside run_sync."""
|
||||||
|
cfg = _build_config(connection)
|
||||||
|
command.revision(cfg, message=message, autogenerate=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_alembic_upgrade(async_engine: AsyncEngine, revision: str = 'head') -> None:
|
||||||
|
"""Run Alembic upgrade to the given revision."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
await conn.run_sync(_do_upgrade, revision)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -> None:
|
||||||
|
"""Stamp the database with a revision without running migrations."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
await conn.run_sync(_do_stamp, revision)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
|
||||||
|
"""Get current alembic revision, or None if not stamped."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
return await conn.run_sync(_do_get_current)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_alembic_autogenerate(async_engine: AsyncEngine, message: str = 'auto migration') -> None:
|
||||||
|
"""Compare ORM models against DB schema and generate a migration script."""
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
await conn.run_sync(_do_autogenerate, message)
|
||||||
|
|
||||||
|
|
||||||
|
# CLI entrypoint: python -m langbot.pkg.persistence.alembic_runner <command> [args]
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
def _get_engine():
|
||||||
|
"""Create engine from data/config.yaml or default SQLite."""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
with open('data/config.yaml') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
db_cfg = config.get('database', {})
|
||||||
|
db_type = db_cfg.get('use', 'sqlite')
|
||||||
|
if db_type == 'postgresql':
|
||||||
|
pg = db_cfg.get('postgresql', {})
|
||||||
|
url = (
|
||||||
|
f'postgresql+asyncpg://{pg.get("user", "postgres")}:{pg.get("password", "postgres")}'
|
||||||
|
f'@{pg.get("host", "127.0.0.1")}:{pg.get("port", 5432)}/{pg.get("database", "postgres")}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
path = db_cfg.get('sqlite', {}).get('path', 'data/langbot.db')
|
||||||
|
url = f'sqlite+aiosqlite:///{path}'
|
||||||
|
except Exception:
|
||||||
|
url = 'sqlite+aiosqlite:///data/langbot.db'
|
||||||
|
|
||||||
|
return create_async_engine(url)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print('Usage: python -m langbot.pkg.persistence.alembic_runner <command> [args]')
|
||||||
|
print('Commands:')
|
||||||
|
print(' autogenerate "message" — Generate migration from ORM model diff')
|
||||||
|
print(' upgrade [revision] — Upgrade database (default: head)')
|
||||||
|
print(' stamp [revision] — Stamp revision without running (default: head)')
|
||||||
|
print(' current — Show current revision')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
engine = _get_engine()
|
||||||
|
|
||||||
|
if cmd == 'autogenerate':
|
||||||
|
msg = sys.argv[2] if len(sys.argv) > 2 else 'auto migration'
|
||||||
|
asyncio.run(run_alembic_autogenerate(engine, msg))
|
||||||
|
print(f'Migration generated: {msg}')
|
||||||
|
elif cmd == 'upgrade':
|
||||||
|
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
|
||||||
|
asyncio.run(run_alembic_upgrade(engine, rev))
|
||||||
|
print(f'Upgraded to: {rev}')
|
||||||
|
elif cmd == 'stamp':
|
||||||
|
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
|
||||||
|
asyncio.run(run_alembic_stamp(engine, rev))
|
||||||
|
print(f'Stamped: {rev}')
|
||||||
|
elif cmd == 'current':
|
||||||
|
rev = asyncio.run(get_alembic_current(engine))
|
||||||
|
print(f'Current revision: {rev}')
|
||||||
|
else:
|
||||||
|
print(f'Unknown command: {cmd}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -76,6 +76,9 @@ class PersistenceManager:
|
|||||||
|
|
||||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||||
|
|
||||||
|
# Run Alembic migrations (new migration system)
|
||||||
|
await self._run_alembic_migrations()
|
||||||
|
|
||||||
await self.write_space_model_providers()
|
await self.write_space_model_providers()
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
@@ -135,6 +138,28 @@ class PersistenceManager:
|
|||||||
|
|
||||||
# =================================
|
# =================================
|
||||||
|
|
||||||
|
async def _run_alembic_migrations(self):
|
||||||
|
"""Run Alembic-based migrations after legacy migrations complete."""
|
||||||
|
from . import alembic_runner
|
||||||
|
|
||||||
|
engine = self.get_db_engine()
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_rev = await alembic_runner.get_alembic_current(engine)
|
||||||
|
|
||||||
|
if current_rev is None:
|
||||||
|
# First time: stamp baseline so Alembic knows existing schema is up-to-date
|
||||||
|
self.ap.logger.info('Alembic: no revision found, stamping baseline...')
|
||||||
|
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
|
||||||
|
current_rev = '0001_baseline'
|
||||||
|
|
||||||
|
# Upgrade to head
|
||||||
|
await alembic_runner.run_alembic_upgrade(engine, 'head')
|
||||||
|
self.ap.logger.info('Alembic migrations completed.')
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
||||||
async with self.get_db_engine().connect() as conn:
|
async with self.get_db_engine().connect() as conn:
|
||||||
result = await conn.execute(*args, **kwargs)
|
result = await conn.execute(*args, **kwargs)
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import sqlalchemy
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(25)
|
||||||
|
class DBMigrateBotPipelineRoutingRules(migration.DBMigration):
|
||||||
|
"""Add pipeline_routing_rules column to bots table"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
sql_text = sqlalchemy.text("ALTER TABLE bots ADD COLUMN pipeline_routing_rules JSON NOT NULL DEFAULT '[]'")
|
||||||
|
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
sql_text = sqlalchemy.text('ALTER TABLE bots DROP COLUMN pipeline_routing_rules')
|
||||||
|
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||||
@@ -37,6 +37,7 @@ class PendingMessage:
|
|||||||
message_chain: platform_message.MessageChain
|
message_chain: platform_message.MessageChain
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||||
pipeline_uuid: typing.Optional[str]
|
pipeline_uuid: typing.Optional[str]
|
||||||
|
routed_by_rule: bool = False
|
||||||
timestamp: float = field(default_factory=time.time)
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ class MessageAggregator:
|
|||||||
message_chain: platform_message.MessageChain,
|
message_chain: platform_message.MessageChain,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
|
routed_by_rule: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a message to the aggregation buffer
|
"""Add a message to the aggregation buffer
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ class MessageAggregator:
|
|||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ class MessageAggregator:
|
|||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
force_flush = False
|
force_flush = False
|
||||||
@@ -217,6 +221,7 @@ class MessageAggregator:
|
|||||||
message_chain=msg.message_chain,
|
message_chain=msg.message_chain,
|
||||||
adapter=msg.adapter,
|
adapter=msg.adapter,
|
||||||
pipeline_uuid=msg.pipeline_uuid,
|
pipeline_uuid=msg.pipeline_uuid,
|
||||||
|
routed_by_rule=msg.routed_by_rule,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -231,6 +236,7 @@ class MessageAggregator:
|
|||||||
message_chain=merged_msg.message_chain,
|
message_chain=merged_msg.message_chain,
|
||||||
adapter=merged_msg.adapter,
|
adapter=merged_msg.adapter,
|
||||||
pipeline_uuid=merged_msg.pipeline_uuid,
|
pipeline_uuid=merged_msg.pipeline_uuid,
|
||||||
|
routed_by_rule=merged_msg.routed_by_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ class Controller:
|
|||||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||||
if pipeline:
|
if pipeline:
|
||||||
await pipeline.run(selected_query)
|
await pipeline.run(selected_query)
|
||||||
|
else:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'No pipeline_uuid for query {selected_query.query_id}, query dropped'
|
||||||
|
)
|
||||||
|
|
||||||
async with self.ap.query_pool:
|
async with self.ap.query_pool:
|
||||||
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
||||||
|
|||||||
@@ -323,6 +323,9 @@ class RuntimePipeline:
|
|||||||
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
||||||
|
|
||||||
if event_ctx.is_prevented_default():
|
if event_ctx.is_prevented_default():
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.ap.logger.debug(f'Processing query {query.query_id}')
|
self.ap.logger.debug(f'Processing query {query.query_id}')
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class QueryPool:
|
|||||||
message_chain: platform_message.MessageChain,
|
message_chain: platform_message.MessageChain,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
|
routed_by_rule: bool = False,
|
||||||
) -> pipeline_query.Query:
|
) -> pipeline_query.Query:
|
||||||
async with self.condition:
|
async with self.condition:
|
||||||
query_id = self.query_id_counter
|
query_id = self.query_id_counter
|
||||||
@@ -52,7 +53,7 @@ class QueryPool:
|
|||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
message_event=message_event,
|
message_event=message_event,
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
variables={},
|
variables={'_routed_by_rule': routed_by_rule},
|
||||||
resp_messages=[],
|
resp_messages=[],
|
||||||
resp_message_chain=[],
|
resp_message_chain=[],
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
elif me.url:
|
elif me.url:
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
||||||
elif isinstance(me, platform_message.File):
|
elif isinstance(me, platform_message.File):
|
||||||
# if me.url is not None:
|
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||||
elif isinstance(me, platform_message.Quote) and quote_msg:
|
elif isinstance(me, platform_message.Quote) and quote_msg:
|
||||||
for msg in me.origin:
|
for msg in me.origin:
|
||||||
@@ -172,6 +171,15 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
):
|
):
|
||||||
if msg.base64 is not None:
|
if msg.base64 is not None:
|
||||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
||||||
|
elif isinstance(msg, platform_message.File):
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
|
||||||
|
elif isinstance(msg, platform_message.Voice):
|
||||||
|
if msg.base64:
|
||||||
|
content_list.append(
|
||||||
|
provider_message.ContentElement.from_file_base64(msg.base64, 'voice.silk')
|
||||||
|
)
|
||||||
|
elif msg.url:
|
||||||
|
content_list.append(provider_message.ContentElement.from_file_url(msg.url, 'voice'))
|
||||||
|
|
||||||
query.variables['user_message_text'] = plain_text
|
query.variables['user_message_text'] = plain_text
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
|
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
else:
|
else:
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'NormalMessageReceived event prevented default for query {query.query_id} without reply'
|
||||||
|
)
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
||||||
else:
|
else:
|
||||||
if event_ctx.event.user_message_alter is not None:
|
if event_ctx.event.user_message_alter is not None:
|
||||||
@@ -205,6 +208,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
|
'edition': constants.edition,
|
||||||
'pipeline_plugins': pipeline_plugins,
|
'pipeline_plugins': pipeline_plugins,
|
||||||
'error': locals().get('error_info', None),
|
'error': locals().get('error_info', None),
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ class GroupRespondRuleCheckStage(stage.PipelineStage):
|
|||||||
if query.launcher_type.value != 'group': # 只处理群消息
|
if query.launcher_type.value != 'group': # 只处理群消息
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
|
# 通过路由规则明确指定的流水线,跳过群响应规则检查
|
||||||
|
if query.variables and query.variables.get('_routed_by_rule', False):
|
||||||
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
rules = query.pipeline_config['trigger']['group-respond-rules']
|
rules = query.pipeline_config['trigger']['group-respond-rules']
|
||||||
|
|
||||||
use_rule = rules
|
use_rule = rules
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -52,6 +54,148 @@ class RuntimeBot:
|
|||||||
self.task_context = taskmgr.TaskContext()
|
self.task_context = taskmgr.TaskContext()
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match_operator(actual: str, operator: str, expected: str) -> bool:
|
||||||
|
"""Evaluate a single operator condition."""
|
||||||
|
if operator == 'eq':
|
||||||
|
return actual == expected
|
||||||
|
elif operator == 'neq':
|
||||||
|
return actual != expected
|
||||||
|
elif operator == 'contains':
|
||||||
|
return expected in actual
|
||||||
|
elif operator == 'not_contains':
|
||||||
|
return expected not in actual
|
||||||
|
elif operator == 'starts_with':
|
||||||
|
return actual.startswith(expected)
|
||||||
|
elif operator == 'regex':
|
||||||
|
try:
|
||||||
|
return bool(re.search(expected, actual))
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
PIPELINE_DISCARD = '__discard__'
|
||||||
|
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
||||||
|
|
||||||
|
def resolve_pipeline_uuid(
|
||||||
|
self,
|
||||||
|
launcher_type: str,
|
||||||
|
launcher_id: str,
|
||||||
|
message_text: str,
|
||||||
|
message_element_types: list[str] | None = None,
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
"""Resolve pipeline UUID based on routing rules.
|
||||||
|
|
||||||
|
Rules are evaluated in order; first match wins.
|
||||||
|
Falls back to use_pipeline_uuid if no rule matches.
|
||||||
|
|
||||||
|
Rule types:
|
||||||
|
- launcher_type: session type ("person" / "group")
|
||||||
|
- launcher_id: session / group id
|
||||||
|
- message_content: message text content
|
||||||
|
- message_has_element: message contains element of given type
|
||||||
|
(Image, Voice, File, Forward, Face, At, AtAll, Quote)
|
||||||
|
Operators: eq (has), neq (doesn't have)
|
||||||
|
|
||||||
|
Operators: eq, neq, contains, not_contains, starts_with, regex
|
||||||
|
|
||||||
|
When pipeline_uuid is ``__discard__``, the message should be
|
||||||
|
silently dropped by the caller.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
|
||||||
|
when a routing rule matched, False when falling back to default.
|
||||||
|
"""
|
||||||
|
rules = self.bot_entity.pipeline_routing_rules or []
|
||||||
|
element_type_set = set(message_element_types or [])
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
rule_type = rule.get('type')
|
||||||
|
operator = rule.get('operator', 'eq')
|
||||||
|
rule_value = rule.get('value', '')
|
||||||
|
target_uuid = rule.get('pipeline_uuid')
|
||||||
|
if not rule_type or not target_uuid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rule_type == 'launcher_type':
|
||||||
|
if self._match_operator(launcher_type, operator, rule_value):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'launcher_id':
|
||||||
|
if self._match_operator(str(launcher_id), operator, str(rule_value)):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'message_content':
|
||||||
|
if self._match_operator(message_text, operator, rule_value):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'message_has_element':
|
||||||
|
has_element = rule_value in element_type_set
|
||||||
|
if operator == 'eq' and has_element:
|
||||||
|
return target_uuid, True
|
||||||
|
elif operator == 'neq' and not has_element:
|
||||||
|
return target_uuid, True
|
||||||
|
|
||||||
|
return self.bot_entity.use_pipeline_uuid, False
|
||||||
|
|
||||||
|
async def _record_discarded_message(
|
||||||
|
self,
|
||||||
|
launcher_type: provider_session.LauncherTypes,
|
||||||
|
launcher_id: str | int,
|
||||||
|
sender_id: str | int,
|
||||||
|
message_event: platform_events.MessageEvent,
|
||||||
|
message_chain: platform_message.MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""Record a discarded message in the monitoring system."""
|
||||||
|
try:
|
||||||
|
if hasattr(message_chain, 'model_dump'):
|
||||||
|
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
message_content = str(message_chain)
|
||||||
|
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(message_event, 'sender'):
|
||||||
|
if hasattr(message_event.sender, 'nickname'):
|
||||||
|
sender_name = message_event.sender.nickname
|
||||||
|
elif hasattr(message_event.sender, 'member_name'):
|
||||||
|
sender_name = message_event.sender.member_name
|
||||||
|
|
||||||
|
# Use the same session_id format as monitoring_helper.py
|
||||||
|
session_id = f'{launcher_type}_{launcher_id}'
|
||||||
|
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_message(
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||||
|
pipeline_id=self.PIPELINE_DISCARD,
|
||||||
|
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||||
|
message_content=message_content,
|
||||||
|
session_id=session_id,
|
||||||
|
status='discarded',
|
||||||
|
level='info',
|
||||||
|
platform=platform,
|
||||||
|
user_id=str(sender_id),
|
||||||
|
user_name=sender_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the session exists so the message appears in the session monitor.
|
||||||
|
# Don't overwrite pipeline info — a session may have messages from
|
||||||
|
# multiple pipelines; discarding shouldn't change the displayed pipeline.
|
||||||
|
session_updated = await self.ap.monitoring_service.update_session_activity(
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if not session_updated:
|
||||||
|
# No session yet (first message for this launcher was discarded).
|
||||||
|
await self.ap.monitoring_service.record_session_start(
|
||||||
|
session_id=session_id,
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||||
|
pipeline_id=self.PIPELINE_DISCARD,
|
||||||
|
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||||
|
platform=platform,
|
||||||
|
user_id=str(sender_id),
|
||||||
|
user_name=sender_name,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'Failed to record discarded message: {e}')
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
async def on_friend_message(
|
async def on_friend_message(
|
||||||
event: platform_events.FriendMessage,
|
event: platform_events.FriendMessage,
|
||||||
@@ -83,6 +227,23 @@ class RuntimeBot:
|
|||||||
if custom_launcher_id:
|
if custom_launcher_id:
|
||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
|
message_text = str(event.message_chain)
|
||||||
|
element_types = [comp.type for comp in event.message_chain]
|
||||||
|
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||||
|
'person', launcher_id, message_text, element_types
|
||||||
|
)
|
||||||
|
|
||||||
|
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||||
|
await self.logger.info('Person message discarded by routing rule')
|
||||||
|
await self._record_discarded_message(
|
||||||
|
provider_session.LauncherTypes.PERSON,
|
||||||
|
launcher_id,
|
||||||
|
event.sender.id,
|
||||||
|
event,
|
||||||
|
event.message_chain,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.msg_aggregator.add_message(
|
await self.ap.msg_aggregator.add_message(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||||
@@ -91,7 +252,8 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
||||||
@@ -126,6 +288,23 @@ class RuntimeBot:
|
|||||||
if custom_launcher_id:
|
if custom_launcher_id:
|
||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
|
message_text = str(event.message_chain)
|
||||||
|
element_types = [comp.type for comp in event.message_chain]
|
||||||
|
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||||
|
'group', launcher_id, message_text, element_types
|
||||||
|
)
|
||||||
|
|
||||||
|
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||||
|
await self.logger.info('Group message discarded by routing rule')
|
||||||
|
await self._record_discarded_message(
|
||||||
|
provider_session.LauncherTypes.GROUP,
|
||||||
|
launcher_id,
|
||||||
|
event.sender.id,
|
||||||
|
event,
|
||||||
|
event.message_chain,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.msg_aggregator.add_message(
|
await self.ap.msg_aggregator.add_message(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||||
@@ -134,7 +313,8 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
||||||
@@ -241,12 +421,20 @@ class PlatformManager:
|
|||||||
# delete all bot log images
|
# delete all bot log images
|
||||||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
||||||
|
|
||||||
|
disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []
|
||||||
|
|
||||||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||||||
for component in self.adapter_components:
|
for component in self.adapter_components:
|
||||||
|
if component.metadata.name in disabled_adapters:
|
||||||
|
continue
|
||||||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||||
self.adapter_dict = adapter_dict
|
self.adapter_dict = adapter_dict
|
||||||
|
|
||||||
|
# Filter out disabled adapters from components list (for API responses)
|
||||||
|
if disabled_adapters:
|
||||||
|
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
|
||||||
|
|
||||||
# initialize websocket adapter
|
# initialize websocket adapter
|
||||||
websocket_adapter_class = self.adapter_dict['websocket']
|
websocket_adapter_class = self.adapter_dict['websocket']
|
||||||
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
||||||
else:
|
else:
|
||||||
# 回退到原有简单逻辑
|
# 回退到原有简单逻辑
|
||||||
if event.content:
|
# 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理
|
||||||
|
if event.content and event.type != 'audio':
|
||||||
text_content = event.content.replace('@' + bot_name, '')
|
text_content = event.content.replace('@' + bot_name, '')
|
||||||
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
||||||
if event.picture:
|
if event.picture:
|
||||||
@@ -81,7 +82,38 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if event.file:
|
if event.file:
|
||||||
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
||||||
if event.audio:
|
if event.audio:
|
||||||
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
# 优先使用钉钉自带的语音转写文字(recognition字段)
|
||||||
|
if event.content and event.type == 'audio':
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||||||
|
else:
|
||||||
|
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
||||||
|
|
||||||
|
# Handle quoted/replied message - extract content as top-level components
|
||||||
|
# so that plugins like FileReader can process them the same way as direct messages
|
||||||
|
if event.quoted_message:
|
||||||
|
quote_info = event.quoted_message
|
||||||
|
msg_type = quote_info.get('msg_type', '')
|
||||||
|
|
||||||
|
# Process quoted file - add as top-level File component (same as private chat)
|
||||||
|
if msg_type == 'file' and quote_info.get('file_url'):
|
||||||
|
file_name = quote_info.get('file_name', 'file')
|
||||||
|
yiri_msg_list.append(platform_message.File(url=quote_info['file_url'], name=file_name))
|
||||||
|
|
||||||
|
# Process quoted image - add as top-level Image component
|
||||||
|
elif msg_type == 'picture' and quote_info.get('picture'):
|
||||||
|
yiri_msg_list.append(platform_message.Image(base64=quote_info['picture']))
|
||||||
|
|
||||||
|
# Process quoted audio - add as top-level Voice component
|
||||||
|
elif msg_type == 'audio' and quote_info.get('audio'):
|
||||||
|
yiri_msg_list.append(platform_message.Voice(base64=quote_info['audio']))
|
||||||
|
|
||||||
|
# Process quoted text - add as Plain text with context prefix
|
||||||
|
elif msg_type == 'text' and quote_info.get('content'):
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
|
||||||
|
|
||||||
|
# Process quoted rich text - add as Plain text with context prefix
|
||||||
|
elif msg_type == 'richText' and quote_info.get('content'):
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
|
||||||
|
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|
||||||
|
|||||||
@@ -709,21 +709,29 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
||||||
|
|
||||||
# Check for quote/reply message
|
# Check for quote/reply message
|
||||||
|
# Extract files/images/voice from quote and add them as top-level components
|
||||||
|
# so that plugins like FileReader can process them the same way as direct messages
|
||||||
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
|
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
|
||||||
if quote_message_id:
|
if quote_message_id:
|
||||||
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
||||||
if quote_chain:
|
if quote_chain:
|
||||||
# Filter out Source component from quoted chain, keep only content
|
# Filter out Source component from quoted chain, keep only content
|
||||||
quote_origin = platform_message.MessageChain(
|
quote_components = [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
|
||||||
[comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
|
|
||||||
)
|
# Add quoted content as top-level components instead of wrapping in Quote
|
||||||
if quote_origin:
|
for comp in quote_components:
|
||||||
message_chain.append(
|
if isinstance(comp, platform_message.File):
|
||||||
platform_message.Quote(
|
# Add file as top-level component (same as direct message)
|
||||||
message_id=quote_message_id,
|
message_chain.append(comp)
|
||||||
origin=quote_origin,
|
elif isinstance(comp, platform_message.Image):
|
||||||
)
|
# Add image as top-level component
|
||||||
)
|
message_chain.append(comp)
|
||||||
|
elif isinstance(comp, platform_message.Voice):
|
||||||
|
# Add voice as top-level component
|
||||||
|
message_chain.append(comp)
|
||||||
|
elif isinstance(comp, platform_message.Plain):
|
||||||
|
# Add text with context prefix
|
||||||
|
message_chain.append(platform_message.Plain(text=f'[引用消息] {comp.text}'))
|
||||||
|
|
||||||
if event.event.message.chat_type == 'p2p':
|
if event.event.message.chat_type == 'p2p':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
@@ -797,8 +805,65 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||||
asyncio.create_task(on_message(event))
|
asyncio.create_task(on_message(event))
|
||||||
|
|
||||||
|
def sync_on_card_action(event):
|
||||||
|
try:
|
||||||
|
action_value_obj = getattr(getattr(event.event, 'action', None), 'value', {})
|
||||||
|
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||||
|
|
||||||
|
if action_value == '有帮助':
|
||||||
|
feedback_type = 1
|
||||||
|
elif action_value == '无帮助':
|
||||||
|
feedback_type = 2
|
||||||
|
else:
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}})
|
||||||
|
|
||||||
|
operator = getattr(event.event, 'operator', None)
|
||||||
|
context = getattr(event.event, 'context', None)
|
||||||
|
|
||||||
|
user_id = getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None)
|
||||||
|
open_chat_id = getattr(context, 'open_chat_id', None)
|
||||||
|
open_message_id = getattr(context, 'open_message_id', None)
|
||||||
|
|
||||||
|
if open_chat_id:
|
||||||
|
session_id = f'group_{open_chat_id}'
|
||||||
|
elif user_id:
|
||||||
|
session_id = f'person_{user_id}'
|
||||||
|
else:
|
||||||
|
session_id = None
|
||||||
|
|
||||||
|
feedback_event = platform_events.FeedbackEvent(
|
||||||
|
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=action_value,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=open_message_id,
|
||||||
|
source_platform_object=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
asyncio.create_task(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||||
|
else:
|
||||||
|
loop.run_until_complete(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||||
|
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
|
||||||
|
except Exception:
|
||||||
|
asyncio.create_task(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}'))
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
|
||||||
|
|
||||||
event_handler = (
|
event_handler = (
|
||||||
lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build()
|
lark_oapi.EventDispatcherHandler.builder('', '')
|
||||||
|
.register_p2_im_message_receive_v1(sync_on_message)
|
||||||
|
.register_p2_card_action_trigger(sync_on_card_action)
|
||||||
|
.build()
|
||||||
)
|
)
|
||||||
|
|
||||||
bot_account_id = config['bot_name']
|
bot_account_id = config['bot_name']
|
||||||
@@ -1088,6 +1153,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'size': 'medium',
|
'size': 'medium',
|
||||||
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
|
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
|
||||||
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
|
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
|
||||||
|
'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}],
|
||||||
'margin': '0px 0px 0px 0px',
|
'margin': '0px 0px 0px 0px',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1111,6 +1177,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'size': 'medium',
|
'size': 'medium',
|
||||||
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
|
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
|
||||||
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
|
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
|
||||||
|
'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}],
|
||||||
'margin': '0px 0px 0px 0px',
|
'margin': '0px 0px 0px 0px',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1472,6 +1539,52 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
if event.__class__ in self.listeners:
|
if event.__class__ in self.listeners:
|
||||||
await self.listeners[event.__class__](event, self)
|
await self.listeners[event.__class__](event, self)
|
||||||
|
elif 'card.action.trigger' == type:
|
||||||
|
try:
|
||||||
|
event_data = data.get('event', {})
|
||||||
|
operator = event_data.get('operator', {})
|
||||||
|
action = event_data.get('action', {})
|
||||||
|
context_data = event_data.get('context', {})
|
||||||
|
|
||||||
|
action_value_obj = action.get('value', {})
|
||||||
|
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||||
|
|
||||||
|
if action_value == '有帮助':
|
||||||
|
feedback_type = 1
|
||||||
|
elif action_value == '无帮助':
|
||||||
|
feedback_type = 2
|
||||||
|
else:
|
||||||
|
return {'toast': {'type': 'success', 'content': '操作成功'}}
|
||||||
|
|
||||||
|
user_id = operator.get('open_id') or operator.get('user_id')
|
||||||
|
open_chat_id = context_data.get('open_chat_id')
|
||||||
|
open_message_id = context_data.get('open_message_id')
|
||||||
|
|
||||||
|
if open_chat_id:
|
||||||
|
session_id = f'group_{open_chat_id}'
|
||||||
|
elif user_id:
|
||||||
|
session_id = f'person_{user_id}'
|
||||||
|
else:
|
||||||
|
session_id = None
|
||||||
|
|
||||||
|
feedback_event = platform_events.FeedbackEvent(
|
||||||
|
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=action_value,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=open_message_id,
|
||||||
|
source_platform_object=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
|
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
|
||||||
|
|
||||||
|
return {'toast': {'type': 'success', 'content': '感谢您的反馈'}}
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')
|
||||||
|
return {'toast': {'type': 'error', 'content': '反馈处理失败'}}
|
||||||
|
|
||||||
elif 'im.chat.member.bot.added_v1' == type:
|
elif 'im.chat.member.bot.added_v1' == type:
|
||||||
try:
|
try:
|
||||||
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
||||||
|
|||||||
@@ -126,6 +126,107 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if summary:
|
if summary:
|
||||||
yiri_msg_list.append(platform_message.Plain(text=summary))
|
yiri_msg_list.append(platform_message.Plain(text=summary))
|
||||||
|
|
||||||
|
# Handle quoted message (引用消息) - important for group chat file references
|
||||||
|
# Extract files/images/voice from quote and add them as top-level components
|
||||||
|
# so that plugins like FileReader can process them the same way as direct messages
|
||||||
|
quote_info = event.quote or {}
|
||||||
|
if quote_info:
|
||||||
|
# Process quote text content - add as Plain for context
|
||||||
|
if quote_info.get('content'):
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info.get("content")}'))
|
||||||
|
|
||||||
|
# Process quote images - add as top-level Image components
|
||||||
|
quote_images = quote_info.get('images', [])
|
||||||
|
if not quote_images and quote_info.get('picurl'):
|
||||||
|
quote_images = [quote_info.get('picurl')]
|
||||||
|
for img_data in quote_images:
|
||||||
|
if img_data:
|
||||||
|
yiri_msg_list.append(platform_message.Image(base64=img_data))
|
||||||
|
|
||||||
|
# Process quote file - add as top-level File component (same as private chat)
|
||||||
|
quote_file = quote_info.get('file') or {}
|
||||||
|
if quote_file:
|
||||||
|
file_url = (
|
||||||
|
quote_file.get('base64')
|
||||||
|
or quote_file.get('download_url')
|
||||||
|
or quote_file.get('url')
|
||||||
|
or quote_file.get('fileurl')
|
||||||
|
)
|
||||||
|
file_name = quote_file.get('filename') or quote_file.get('name')
|
||||||
|
file_size = quote_file.get('filesize') or quote_file.get('size')
|
||||||
|
if file_url or file_name:
|
||||||
|
file_kwargs = {}
|
||||||
|
if file_url:
|
||||||
|
file_kwargs['url'] = file_url
|
||||||
|
if file_name:
|
||||||
|
file_kwargs['name'] = file_name
|
||||||
|
if file_size is not None:
|
||||||
|
file_kwargs['size'] = file_size
|
||||||
|
try:
|
||||||
|
yiri_msg_list.append(platform_message.File(**file_kwargs))
|
||||||
|
except Exception:
|
||||||
|
yiri_msg_list.append(platform_message.Unknown(text='[quoted file unsupported]'))
|
||||||
|
|
||||||
|
# Process quote voice - add as top-level Voice/File component
|
||||||
|
quote_voice = quote_info.get('voice') or {}
|
||||||
|
if quote_voice:
|
||||||
|
voice_payload = quote_voice.get('base64') or quote_voice.get('url')
|
||||||
|
if voice_payload:
|
||||||
|
if quote_voice.get('base64') and not voice_payload.startswith('data:'):
|
||||||
|
voice_payload = f'data:audio/mpeg;base64,{quote_voice.get("base64")}'
|
||||||
|
try:
|
||||||
|
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
voice_kwargs = {'url': voice_payload}
|
||||||
|
voice_name = quote_voice.get('filename') or quote_voice.get('name')
|
||||||
|
voice_size = quote_voice.get('filesize') or quote_voice.get('size')
|
||||||
|
if voice_name:
|
||||||
|
voice_kwargs['name'] = voice_name
|
||||||
|
if voice_size is not None:
|
||||||
|
voice_kwargs['size'] = voice_size
|
||||||
|
yiri_msg_list.append(platform_message.File(**voice_kwargs))
|
||||||
|
except Exception:
|
||||||
|
yiri_msg_list.append(platform_message.Unknown(text='[quoted voice unsupported]'))
|
||||||
|
|
||||||
|
# Process quote video - add as top-level File component
|
||||||
|
quote_video = quote_info.get('video') or {}
|
||||||
|
if quote_video:
|
||||||
|
video_payload = (
|
||||||
|
quote_video.get('base64')
|
||||||
|
or quote_video.get('url')
|
||||||
|
or quote_video.get('download_url')
|
||||||
|
or quote_video.get('fileurl')
|
||||||
|
)
|
||||||
|
if video_payload:
|
||||||
|
video_kwargs = {'url': video_payload}
|
||||||
|
video_name = quote_video.get('filename') or quote_video.get('name')
|
||||||
|
video_size = quote_video.get('filesize') or quote_video.get('size')
|
||||||
|
if video_name:
|
||||||
|
video_kwargs['name'] = video_name
|
||||||
|
if video_size is not None:
|
||||||
|
video_kwargs['size'] = video_size
|
||||||
|
try:
|
||||||
|
yiri_msg_list.append(platform_message.File(**video_kwargs))
|
||||||
|
except Exception:
|
||||||
|
yiri_msg_list.append(platform_message.Unknown(text='[quoted video unsupported]'))
|
||||||
|
|
||||||
|
# Process quote link - add as Plain text
|
||||||
|
quote_link = quote_info.get('link') or {}
|
||||||
|
if quote_link:
|
||||||
|
link_summary = '\n'.join(
|
||||||
|
filter(
|
||||||
|
None,
|
||||||
|
[
|
||||||
|
quote_link.get('title', ''),
|
||||||
|
quote_link.get('description') or quote_link.get('digest', ''),
|
||||||
|
quote_link.get('url', ''),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if link_summary:
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=f'[引用链接] {link_summary}'))
|
||||||
|
|
||||||
has_content_element = any(
|
has_content_element = any(
|
||||||
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
||||||
)
|
)
|
||||||
@@ -328,6 +429,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
feedback_type = kwargs.get('feedback_type', 0)
|
feedback_type = kwargs.get('feedback_type', 0)
|
||||||
feedback_content = kwargs.get('feedback_content', '') or None
|
feedback_content = kwargs.get('feedback_content', '') or None
|
||||||
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) 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 = kwargs.get('session')
|
||||||
|
|
||||||
session_id = None
|
session_id = None
|
||||||
@@ -343,6 +447,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
message_id = session.msg_id
|
message_id = session.msg_id
|
||||||
stream_id = session.stream_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(
|
event = platform_events.FeedbackEvent(
|
||||||
feedback_id=feedback_id,
|
feedback_id=feedback_id,
|
||||||
feedback_type=feedback_type,
|
feedback_type=feedback_type,
|
||||||
|
|||||||
@@ -60,7 +60,16 @@ class TelemetryManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
sanitized['query_id'] = str(sanitized.get('query_id', ''))
|
sanitized['query_id'] = str(sanitized.get('query_id', ''))
|
||||||
|
|
||||||
for sfield in ('adapter', 'runner', 'runner_category', 'model_name', 'version', 'error', 'timestamp'):
|
for sfield in (
|
||||||
|
'adapter',
|
||||||
|
'runner',
|
||||||
|
'runner_category',
|
||||||
|
'model_name',
|
||||||
|
'version',
|
||||||
|
'edition',
|
||||||
|
'error',
|
||||||
|
'timestamp',
|
||||||
|
):
|
||||||
v = sanitized.get(sfield)
|
v = sanitized.get(sfield)
|
||||||
sanitized[sfield] = '' if v is None else str(v)
|
sanitized[sfield] = '' if v is None else str(v)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import langbot
|
|||||||
|
|
||||||
semantic_version = f'v{langbot.__version__}'
|
semantic_version = f'v{langbot.__version__}'
|
||||||
|
|
||||||
required_database_version = 24
|
required_database_version = 25
|
||||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||||
|
|
||||||
debug_mode = False
|
debug_mode = False
|
||||||
|
|||||||
@@ -38,28 +38,31 @@ def get_frontend_path() -> str:
|
|||||||
"""
|
"""
|
||||||
Get the path to the frontend build files.
|
Get the path to the frontend build files.
|
||||||
|
|
||||||
Returns the path to web/out directory, handling both:
|
Returns the path to web/dist directory (Vite build output), handling both:
|
||||||
- Development mode: running from source directory
|
- Development mode: running from source directory
|
||||||
- Package mode: installed via pip/uvx
|
- Package mode: installed via pip/uvx
|
||||||
|
- Legacy mode: web/out (Next.js, for backward compatibility)
|
||||||
"""
|
"""
|
||||||
# First, check if we're running from source directory
|
# Check both dist (Vite) and out (legacy Next.js) paths
|
||||||
if _check_if_source_install() and os.path.exists('web/out'):
|
for dirname in ('dist', 'out'):
|
||||||
return 'web/out'
|
web_dir = f'web/{dirname}'
|
||||||
|
|
||||||
# Second, check current directory for web/out (in case user is in source dir)
|
# First, check if we're running from source directory
|
||||||
if os.path.exists('web/out'):
|
if _check_if_source_install() and os.path.exists(web_dir):
|
||||||
return 'web/out'
|
return web_dir
|
||||||
|
|
||||||
# Third, find it relative to the package installation
|
# Second, check current directory
|
||||||
# Get the directory where this file is located
|
if os.path.exists(web_dir):
|
||||||
# paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root
|
return web_dir
|
||||||
pkg_dir = Path(__file__).parent.parent.parent
|
|
||||||
frontend_path = pkg_dir / 'web' / 'out'
|
# Third, find it relative to the package installation
|
||||||
if frontend_path.exists():
|
pkg_dir = Path(__file__).parent.parent.parent
|
||||||
return str(frontend_path)
|
frontend_path = pkg_dir / 'web' / dirname
|
||||||
|
if frontend_path.exists():
|
||||||
|
return str(frontend_path)
|
||||||
|
|
||||||
# Return the default path (will be checked by caller)
|
# Return the default path (will be checked by caller)
|
||||||
return 'web/out'
|
return 'web/dist'
|
||||||
|
|
||||||
|
|
||||||
def get_resource_path(resource: str) -> str:
|
def get_resource_path(resource: str) -> str:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ system:
|
|||||||
edition: community
|
edition: community
|
||||||
recovery_key: ''
|
recovery_key: ''
|
||||||
allow_modify_login_info: true
|
allow_modify_login_info: true
|
||||||
|
disabled_adapters: []
|
||||||
limitation:
|
limitation:
|
||||||
max_bots: -1
|
max_bots: -1
|
||||||
max_pipelines: -1
|
max_pipelines: -1
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""
|
||||||
|
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchOperator:
|
||||||
|
"""Test the _match_operator static method."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_class():
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
return RuntimeBot
|
||||||
|
|
||||||
|
def test_eq(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'eq', 'hello') is True
|
||||||
|
assert cls._match_operator('hello', 'eq', 'world') is False
|
||||||
|
|
||||||
|
def test_neq(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'neq', 'world') is True
|
||||||
|
assert cls._match_operator('hello', 'neq', 'hello') is False
|
||||||
|
|
||||||
|
def test_contains(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello world', 'contains', 'world') is True
|
||||||
|
assert cls._match_operator('hello world', 'contains', 'xyz') is False
|
||||||
|
|
||||||
|
def test_not_contains(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello world', 'not_contains', 'xyz') is True
|
||||||
|
assert cls._match_operator('hello world', 'not_contains', 'world') is False
|
||||||
|
|
||||||
|
def test_starts_with(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello world', 'starts_with', 'hello') is True
|
||||||
|
assert cls._match_operator('hello world', 'starts_with', 'world') is False
|
||||||
|
|
||||||
|
def test_regex(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello123', 'regex', r'\d+') is True
|
||||||
|
assert cls._match_operator('hello', 'regex', r'\d+') is False
|
||||||
|
|
||||||
|
def test_regex_invalid_pattern(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'regex', r'[invalid') is False
|
||||||
|
|
||||||
|
def test_unknown_operator(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'unknown_op', 'hello') is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolvePipelineUuid:
|
||||||
|
"""Test the resolve_pipeline_uuid method."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_bot(default_pipeline: str, rules: list):
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
bot_entity = Mock()
|
||||||
|
bot_entity.use_pipeline_uuid = default_pipeline
|
||||||
|
bot_entity.pipeline_routing_rules = rules
|
||||||
|
|
||||||
|
bot = object.__new__(RuntimeBot)
|
||||||
|
bot.bot_entity = bot_entity
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def test_no_rules_returns_default(self):
|
||||||
|
bot = self._make_bot('default-uuid', [])
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_none_rules_returns_default(self):
|
||||||
|
bot = self._make_bot('default-uuid', None)
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_launcher_type_match(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'group',
|
||||||
|
'pipeline_uuid': 'group-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
|
||||||
|
assert uuid == 'group-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_launcher_id_match(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_id',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': '12345',
|
||||||
|
'pipeline_uuid': 'vip-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '12345', 'hi')
|
||||||
|
assert uuid == 'vip-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '99999', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_content_contains(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_content',
|
||||||
|
'operator': 'contains',
|
||||||
|
'value': '紧急',
|
||||||
|
'pipeline_uuid': 'urgent-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '这是紧急消息')
|
||||||
|
assert uuid == 'urgent-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '普通消息')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_content_regex(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_content',
|
||||||
|
'operator': 'regex',
|
||||||
|
'value': r'^/admin\b',
|
||||||
|
'pipeline_uuid': 'admin-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '/admin help')
|
||||||
|
assert uuid == 'admin-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hello /admin')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_has_element_eq(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_has_element',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'Image',
|
||||||
|
'pipeline_uuid': 'image-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
|
||||||
|
assert uuid == 'image-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_has_element_neq(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_has_element',
|
||||||
|
'operator': 'neq',
|
||||||
|
'value': 'Image',
|
||||||
|
'pipeline_uuid': 'text-only-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
|
||||||
|
assert uuid == 'text-only-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_has_element_no_types_provided(self):
|
||||||
|
"""When element types are not provided, should not match."""
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_has_element',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'Image',
|
||||||
|
'pipeline_uuid': 'image-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_first_match_wins(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'group',
|
||||||
|
'pipeline_uuid': 'first-pipeline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'group',
|
||||||
|
'pipeline_uuid': 'second-pipeline',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
|
||||||
|
assert uuid == 'first-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
def test_skip_invalid_rules(self):
|
||||||
|
rules = [
|
||||||
|
{'type': '', 'operator': 'eq', 'value': 'x', 'pipeline_uuid': 'p1'},
|
||||||
|
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': ''},
|
||||||
|
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': 'valid'},
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'valid'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
def test_default_operator_is_eq(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'value': 'person',
|
||||||
|
'pipeline_uuid': 'person-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'person-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
def test_discard_pipeline(self):
|
||||||
|
"""When pipeline_uuid is __discard__, the message should be discarded."""
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_content',
|
||||||
|
'operator': 'contains',
|
||||||
|
'value': 'spam',
|
||||||
|
'pipeline_uuid': RuntimeBot.PIPELINE_DISCARD,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'this is spam')
|
||||||
|
assert uuid == RuntimeBot.PIPELINE_DISCARD
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
@@ -186,6 +186,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.18.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -1832,7 +1846,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.5"
|
version = "4.9.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -1840,6 +1854,7 @@ dependencies = [
|
|||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aioshutil" },
|
{ name = "aioshutil" },
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "alembic" },
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "async-lru" },
|
{ name = "async-lru" },
|
||||||
@@ -1919,6 +1934,7 @@ requires-dist = [
|
|||||||
{ name = "aiohttp", specifier = ">=3.11.18" },
|
{ name = "aiohttp", specifier = ">=3.11.18" },
|
||||||
{ name = "aioshutil", specifier = ">=1.5" },
|
{ name = "aioshutil", specifier = ">=1.5" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
||||||
|
{ name = "alembic", specifier = ">=1.15.0" },
|
||||||
{ name = "anthropic", specifier = ">=0.51.0" },
|
{ name = "anthropic", specifier = ">=0.51.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=23.1.0" },
|
{ name = "argon2-cffi", specifier = ">=23.1.0" },
|
||||||
{ name = "async-lru", specifier = ">=2.0.5" },
|
{ name = "async-lru", specifier = ">=2.0.5" },
|
||||||
@@ -1937,7 +1953,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.3.6" },
|
{ name = "langbot-plugin", specifier = "==0.3.8" },
|
||||||
{ name = "langchain", specifier = ">=0.2.0" },
|
{ name = "langchain", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||||
@@ -1993,7 +2009,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.3.6"
|
version = "0.3.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2011,9 +2027,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/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 = [
|
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]]
|
[[package]]
|
||||||
@@ -2409,6 +2425,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.3.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown"
|
name = "markdown"
|
||||||
version = "3.10.1"
|
version = "3.10.1"
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:5300
|
VITE_API_BASE_URL=http://localhost:5300
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
|
/dist/
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "",
|
||||||
|
|||||||
+20
-11
@@ -1,18 +1,27 @@
|
|||||||
import { dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { FlatCompat } from '@eslint/eslintrc';
|
|
||||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
import tseslint from 'typescript-eslint';
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
eslintPluginPrettierRecommended,
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
sed -i 's/children={<HomePage \/>} />\n <HomePage \/>\n <\/HomeLayout>/g' src/router.tsx
|
||||||
|
# well it's easier to recreate router.tsx
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>LangBot</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Production-grade platform for building agentic IM bots"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /root/.openclaw/workspace/coding/projects/LangBot/web
|
||||||
|
|
||||||
|
# Find and replace next/navigation
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
|
||||||
|
-e "s/import {.*useRouter.*} from 'next\/navigation'/import { useNavigate } from 'react-router-dom'/g" \
|
||||||
|
-e "s/import {.*usePathname.*} from 'next\/navigation'/import { useLocation } from 'react-router-dom'/g" \
|
||||||
|
-e "s/import {.*useSearchParams.*} from 'next\/navigation'/import { useSearchParams } from 'react-router-dom'/g" \
|
||||||
|
-e "s/const router = useRouter()/const navigate = useNavigate()/g" \
|
||||||
|
-e "s/router\.push(/navigate(/g" \
|
||||||
|
-e "s/router\.replace(/navigate(/g" \
|
||||||
|
-e "s/router\.back()/navigate(-1)/g" \
|
||||||
|
-e "s/router\.refresh()/navigate(0)/g" \
|
||||||
|
-e "s/const pathname = usePathname()/const location = useLocation();\n const pathname = location.pathname;/g" \
|
||||||
|
-e "s/usePathname()/useLocation().pathname/g" \
|
||||||
|
{} +
|
||||||
|
|
||||||
|
# Note: useSearchParams returns a tuple in react-router-dom. This might need manual fix depending on usage.
|
||||||
|
|
||||||
|
# Replace next/link
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
|
||||||
|
-e "s/import Link from 'next\/link'/import { Link } from 'react-router-dom'/g" \
|
||||||
|
-e "s/<Link href=/<Link to=/g" \
|
||||||
|
{} +
|
||||||
|
|
||||||
|
# Remove 'use client'
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i "s/'use client';//g" {} +
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i 's/"use client";//g' {} +
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { NextConfig } from 'next';
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
output: 'export',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
Generated
+891
-3449
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -3,16 +3,15 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc && vite build",
|
||||||
"start": "next start",
|
"preview": "vite preview",
|
||||||
"lint": "eslint src",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint src --fix",
|
"format": "prettier --write ."
|
||||||
"lint-staged": "lint-staged"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"next lint --fix",
|
"eslint --fix",
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -46,6 +45,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tailwindcss/postcss": "^4.1.5",
|
"@tailwindcss/postcss": "^4.1.5",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -55,8 +55,6 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lucide-react": "^0.507.0",
|
"lucide-react": "^0.507.0",
|
||||||
"next": "~16.1.5",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
@@ -65,6 +63,7 @@
|
|||||||
"react-i18next": "^15.5.1",
|
"react-i18next": "^15.5.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-photo-view": "^1.2.7",
|
"react-photo-view": "^1.2.7",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
@@ -77,10 +76,10 @@
|
|||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.5",
|
"tailwindcss": "^4.1.5",
|
||||||
"uuidjs": "^5.1.0",
|
"uuidjs": "^5.1.0",
|
||||||
|
"vite": "^8.0.3",
|
||||||
"zod": "^3.24.4"
|
"zod": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/estree": "^1.0.8",
|
"@types/estree": "^1.0.8",
|
||||||
"@types/estree-jsx": "^1.0.5",
|
"@types/estree-jsx": "^1.0.5",
|
||||||
@@ -95,9 +94,10 @@
|
|||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/unist": "^3.0.3",
|
"@types/unist": "^3.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.2.4",
|
|
||||||
"eslint-config-prettier": "^10.1.2",
|
"eslint-config-prettier": "^10.1.2",
|
||||||
"eslint-plugin-prettier": "^5.2.6",
|
"eslint-plugin-prettier": "^5.2.6",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"lint-staged": "^15.5.1",
|
"lint-staged": "^15.5.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"tw-animate-css": "^1.2.9",
|
"tw-animate-css": "^1.2.9",
|
||||||
|
|||||||
Generated
+560
-1352
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, Suspense } from 'react';
|
import { useEffect, useState, useCallback, Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -23,8 +21,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
|||||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||||
|
|
||||||
function SpaceOAuthCallbackContent() {
|
function SpaceOAuthCallbackContent() {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [status, setStatus] = useState<
|
const [status, setStatus] = useState<
|
||||||
@@ -51,7 +49,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
const wizardState = localStorage.getItem('langbot_wizard_state');
|
const wizardState = localStorage.getItem('langbot_wizard_state');
|
||||||
const redirectTo = wizardState ? '/wizard' : '/home';
|
const redirectTo = wizardState ? '/wizard' : '/home';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(redirectTo);
|
navigate(redirectTo);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -64,7 +62,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, t],
|
[navigate, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [bindState, setBindState] = useState<string | null>(null);
|
const [bindState, setBindState] = useState<string | null>(null);
|
||||||
@@ -81,7 +79,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
setStatus('success');
|
setStatus('success');
|
||||||
toast.success(t('account.bindSpaceSuccess'));
|
toast.success(t('account.bindSpaceSuccess'));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/home');
|
navigate('/home');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -96,7 +94,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, t],
|
[navigate, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,7 +144,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelBind = () => {
|
const handleCancelBind = () => {
|
||||||
router.push('/home');
|
navigate('/home');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,7 +152,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
|
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<img
|
<img
|
||||||
src={langbotIcon.src}
|
src={langbotIcon}
|
||||||
alt="LangBot"
|
alt="LangBot"
|
||||||
className="w-16 h-16 mb-4 mx-auto"
|
className="w-16 h-16 mb-4 mx-auto"
|
||||||
/>
|
/>
|
||||||
@@ -217,7 +215,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
<>
|
<>
|
||||||
<AlertCircle className="h-12 w-12 text-red-500" />
|
<AlertCircle className="h-12 w-12 text-red-500" />
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push(isBindMode ? '/home' : '/login')}
|
onClick={() => navigate(isBindMode ? '/home' : '/login')}
|
||||||
className="w-full mt-4"
|
className="w-full mt-4"
|
||||||
>
|
>
|
||||||
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}
|
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
:root {
|
:root {
|
||||||
/* 适用于 Firefox 的滚动条 */
|
/* 适用于 Firefox 的滚动条 */
|
||||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
||||||
@@ -72,10 +74,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@import 'tailwindcss';
|
|
||||||
|
|
||||||
@import 'tw-animate-css';
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -34,7 +32,7 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export default function BotDetailContent({ id }: { id: string }) {
|
export default function BotDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
||||||
|
|
||||||
@@ -105,12 +103,12 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleBotDeleted() {
|
function handleBotDeleted() {
|
||||||
refreshBots();
|
refreshBots();
|
||||||
router.push('/home/bots');
|
navigate('/home/bots');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewBotCreated(newBotId: string) {
|
function handleNewBotCreated(newBotId: string) {
|
||||||
refreshBots();
|
refreshBots();
|
||||||
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
@@ -176,9 +174,11 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
{activeTab === 'config' && (
|
||||||
{t('common.save')}
|
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
||||||
</Button>
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Horizontal Tabs */}
|
{/* Horizontal Tabs */}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
|||||||
import { Bot } from '@/app/infra/entities/api';
|
import { Bot } from '@/app/infra/entities/api';
|
||||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import RoutingRulesEditor from './RoutingRulesEditor';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -64,6 +65,28 @@ const getFormSchema = (t: (key: string) => string) =>
|
|||||||
adapter_config: z.record(z.string(), z.any()),
|
adapter_config: z.record(z.string(), z.any()),
|
||||||
enable: z.boolean().optional(),
|
enable: z.boolean().optional(),
|
||||||
use_pipeline_uuid: z.string().optional(),
|
use_pipeline_uuid: z.string().optional(),
|
||||||
|
pipeline_routing_rules: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
type: z.enum([
|
||||||
|
'launcher_type',
|
||||||
|
'launcher_id',
|
||||||
|
'message_content',
|
||||||
|
'message_has_element',
|
||||||
|
]),
|
||||||
|
operator: z.enum([
|
||||||
|
'eq',
|
||||||
|
'neq',
|
||||||
|
'contains',
|
||||||
|
'not_contains',
|
||||||
|
'starts_with',
|
||||||
|
'regex',
|
||||||
|
]),
|
||||||
|
value: z.string(),
|
||||||
|
pipeline_uuid: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function BotForm({
|
export default function BotForm({
|
||||||
@@ -89,6 +112,7 @@ export default function BotForm({
|
|||||||
adapter_config: {},
|
adapter_config: {},
|
||||||
enable: true,
|
enable: true,
|
||||||
use_pipeline_uuid: '',
|
use_pipeline_uuid: '',
|
||||||
|
pipeline_routing_rules: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,6 +179,7 @@ export default function BotForm({
|
|||||||
adapter_config: val.adapter_config,
|
adapter_config: val.adapter_config,
|
||||||
enable: val.enable,
|
enable: val.enable,
|
||||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
||||||
|
pipeline_routing_rules: val.pipeline_routing_rules || [],
|
||||||
});
|
});
|
||||||
handleAdapterSelect(val.adapter);
|
handleAdapterSelect(val.adapter);
|
||||||
|
|
||||||
@@ -270,6 +295,7 @@ export default function BotForm({
|
|||||||
adapter_config: bot.adapter_config,
|
adapter_config: bot.adapter_config,
|
||||||
enable: bot.enable ?? true,
|
enable: bot.enable ?? true,
|
||||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
||||||
|
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
|
||||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||||
| string
|
| string
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -314,6 +340,7 @@ export default function BotForm({
|
|||||||
adapter_config: form.getValues().adapter_config,
|
adapter_config: form.getValues().adapter_config,
|
||||||
enable: form.getValues().enable,
|
enable: form.getValues().enable,
|
||||||
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
||||||
|
pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
|
||||||
};
|
};
|
||||||
httpClient
|
httpClient
|
||||||
.updateBot(initBotId, updateBot)
|
.updateBot(initBotId, updateBot)
|
||||||
@@ -464,6 +491,12 @@ export default function BotForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Pipeline Routing Rules */}
|
||||||
|
<RoutingRulesEditor
|
||||||
|
form={form}
|
||||||
|
pipelineNameList={pipelineNameList}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
|
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
|
||||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
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 { ChevronDownIcon, ExternalLink } from 'lucide-react';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export function BotLogListComponent({
|
export function BotLogListComponent({
|
||||||
botId,
|
botId,
|
||||||
@@ -32,7 +30,7 @@ export function BotLogListComponent({
|
|||||||
hideToolbar?: boolean;
|
hideToolbar?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const manager = useRef(new BotLogManager(botId)).current;
|
const manager = useRef(new BotLogManager(botId)).current;
|
||||||
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
|
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
|
||||||
const [autoFlush, setAutoFlush] = useState(true);
|
const [autoFlush, setAutoFlush] = useState(true);
|
||||||
@@ -231,7 +229,7 @@ export function BotLogListComponent({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
|
onClick={() => navigate(`/home/monitoring?botId=${botId}`)}
|
||||||
>
|
>
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
|
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -12,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Copy, Check } from 'lucide-react';
|
import { Ban, Bot, Copy, Check, Workflow } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
Plain,
|
Plain,
|
||||||
@@ -21,6 +19,7 @@ import {
|
|||||||
Quote,
|
Quote,
|
||||||
Voice,
|
Voice,
|
||||||
} from '@/app/infra/entities/message';
|
} from '@/app/infra/entities/message';
|
||||||
|
import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/RoutingRulesEditor';
|
||||||
|
|
||||||
interface SessionInfo {
|
interface SessionInfo {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -147,14 +146,18 @@ const BotSessionMonitor = forwardRef<
|
|||||||
}, [selectedSessionId, loadMessages]);
|
}, [selectedSessionId, loadMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = messagesContainerRef.current;
|
if (messages.length === 0) return;
|
||||||
if (container) {
|
// Wait for DOM to render the new messages before scrolling
|
||||||
const viewport = container.querySelector(
|
requestAnimationFrame(() => {
|
||||||
'[data-radix-scroll-area-viewport]',
|
const container = messagesContainerRef.current;
|
||||||
);
|
if (container) {
|
||||||
const scrollTarget = viewport || container;
|
const viewport = container.querySelector(
|
||||||
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
'[data-radix-scroll-area-viewport]',
|
||||||
}
|
);
|
||||||
|
const scrollTarget = viewport || container;
|
||||||
|
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
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 className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{session.pipeline_name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -449,12 +451,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{selectedSession?.pipeline_name && (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{selectedSession.pipeline_name}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedSession?.is_active && (
|
{selectedSession?.is_active && (
|
||||||
<>
|
<>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -485,6 +481,9 @@ const BotSessionMonitor = forwardRef<
|
|||||||
) : (
|
) : (
|
||||||
messages.map((msg) => {
|
messages.map((msg) => {
|
||||||
const isUser = isUserMessage(msg);
|
const isUser = isUserMessage(msg);
|
||||||
|
const isDiscarded =
|
||||||
|
msg.status === 'discarded' ||
|
||||||
|
msg.pipeline_id === PIPELINE_DISCARD;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
@@ -500,10 +499,11 @@ const BotSessionMonitor = forwardRef<
|
|||||||
? 'bg-primary/10 rounded-br-sm'
|
? 'bg-primary/10 rounded-br-sm'
|
||||||
: 'bg-muted rounded-bl-sm',
|
: 'bg-muted rounded-bl-sm',
|
||||||
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
||||||
|
isDiscarded && 'opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderMessageContent(msg)}
|
{renderMessageContent(msg)}
|
||||||
{/* Role label + timestamp */}
|
{/* Role label + pipeline + timestamp */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
'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">
|
<span className="tabular-nums">
|
||||||
{formatTime(msg.timestamp)}
|
{formatTime(msg.timestamp)}
|
||||||
</span>
|
</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' && (
|
{msg.status === 'error' && (
|
||||||
<span className="text-red-500">error</span>
|
<span className="text-red-500">error</span>
|
||||||
)}
|
)}
|
||||||
{msg.runner_name && (
|
{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}
|
{msg.runner_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
'use client';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import BotDetailContent from './BotDetailContent';
|
import BotDetailContent from './BotDetailContent';
|
||||||
|
|
||||||
export default function BotConfigPage() {
|
export default function BotConfigPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -67,9 +65,10 @@ export default function ApiIntegrationDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ApiIntegrationDialogProps) {
|
}: ApiIntegrationDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const pathname = usePathname();
|
const location = useLocation();
|
||||||
const searchParams = useSearchParams();
|
const pathname = location.pathname;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [activeTab, setActiveTab] = useState('apikeys');
|
const [activeTab, setActiveTab] = useState('apikeys');
|
||||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||||
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
||||||
@@ -94,7 +93,9 @@ export default function ApiIntegrationDialog({
|
|||||||
if (open) {
|
if (open) {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('action', 'showApiIntegrationSettings');
|
params.set('action', 'showApiIntegrationSettings');
|
||||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
navigate(`${pathname}?${params.toString()}`, {
|
||||||
|
preventScrollReset: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export default function ApiIntegrationDialog({
|
|||||||
const newUrl = params.toString()
|
const newUrl = params.toString()
|
||||||
? `${pathname}?${params.toString()}`
|
? `${pathname}?${params.toString()}`
|
||||||
: pathname;
|
: pathname;
|
||||||
router.replace(newUrl, { scroll: false });
|
navigate(newUrl, { preventScrollReset: true });
|
||||||
}
|
}
|
||||||
onOpenChange(newOpen);
|
onOpenChange(newOpen);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -249,6 +249,9 @@ export default function DynamicFormComponent({
|
|||||||
case 'bot-selector':
|
case 'bot-selector':
|
||||||
fieldSchema = z.string();
|
fieldSchema = z.string();
|
||||||
break;
|
break;
|
||||||
|
case 'tools-selector':
|
||||||
|
fieldSchema = z.array(z.string());
|
||||||
|
break;
|
||||||
case 'model-fallback-selector':
|
case 'model-fallback-selector':
|
||||||
fieldSchema = z.object({
|
fieldSchema = z.object({
|
||||||
primary: z.string(),
|
primary: z.string(),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
KnowledgeBase,
|
KnowledgeBase,
|
||||||
EmbeddingModel,
|
EmbeddingModel,
|
||||||
|
PluginTool,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -75,9 +76,14 @@ export default function DynamicFormItemComponent({
|
|||||||
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
const [bots, setBots] = useState<Bot[]>([]);
|
const [bots, setBots] = useState<Bot[]>([]);
|
||||||
|
const [tools, setTools] = useState<PluginTool[]>([]);
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
||||||
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
|
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
|
||||||
|
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
|
||||||
|
const [tempSelectedToolNames, setTempSelectedToolNames] = useState<string[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||||
|
|
||||||
@@ -209,6 +215,21 @@ export default function DynamicFormItemComponent({
|
|||||||
}
|
}
|
||||||
}, [config.type]);
|
}, [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) {
|
switch (config.type) {
|
||||||
case DynamicFormItemType.INT:
|
case DynamicFormItemType.INT:
|
||||||
case DynamicFormItemType.FLOAT:
|
case DynamicFormItemType.FLOAT:
|
||||||
@@ -1161,6 +1182,139 @@ export default function DynamicFormItemComponent({
|
|||||||
</Select>
|
</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: {
|
case DynamicFormItemType.PROMPT_EDITOR: {
|
||||||
// Guard: field.value may be undefined when the form resets or
|
// Guard: field.value may be undefined when the form resets or
|
||||||
// initialValues haven't propagated yet. Fall back to a default
|
// initialValues haven't propagated yet. Fall back to a default
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
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 { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
|
||||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||||
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
|
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
|
||||||
@@ -29,7 +27,7 @@ import {
|
|||||||
Github,
|
Github,
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from '@/components/providers/theme-provider';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -244,9 +242,10 @@ function NavItems({
|
|||||||
sectionOpenState: Record<string, boolean>;
|
sectionOpenState: Record<string, boolean>;
|
||||||
onSectionToggle: (id: string, open: boolean) => void;
|
onSectionToggle: (id: string, open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const pathname = usePathname();
|
const location = useLocation();
|
||||||
const searchParams = useSearchParams();
|
const pathname = location.pathname;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const sidebarData = useSidebarData();
|
const sidebarData = useSidebarData();
|
||||||
const { setPendingPluginInstallAction } = sidebarData;
|
const { setPendingPluginInstallAction } = sidebarData;
|
||||||
const { state: sidebarState, isMobile } = useSidebar();
|
const { state: sidebarState, isMobile } = useSidebar();
|
||||||
@@ -413,7 +412,7 @@ function NavItems({
|
|||||||
'bg-accent text-accent-foreground font-medium',
|
'bg-accent text-accent-foreground font-medium',
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(itemRoute);
|
navigate(itemRoute);
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -471,7 +470,7 @@ function NavItems({
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
router.push(itemRoute);
|
navigate(itemRoute);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.emoji ? (
|
{item.emoji ? (
|
||||||
@@ -623,7 +622,7 @@ function NavItems({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
router.push('/home/market');
|
navigate('/home/market');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -638,7 +637,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('local');
|
setPendingPluginInstallAction('local');
|
||||||
router.push('/home/plugins');
|
navigate('/home/plugins');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -652,7 +651,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('github');
|
setPendingPluginInstallAction('github');
|
||||||
router.push('/home/plugins');
|
navigate('/home/plugins');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -669,7 +668,7 @@ function NavItems({
|
|||||||
type="button"
|
type="button"
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`${routePrefix}?id=new`);
|
navigate(`${routePrefix}?id=new`);
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -720,7 +719,7 @@ function NavItems({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
@@ -731,7 +730,7 @@ function NavItems({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
router.push('/home/market');
|
navigate('/home/market');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Store className="size-4" />
|
<Store className="size-4" />
|
||||||
@@ -742,7 +741,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('local');
|
setPendingPluginInstallAction('local');
|
||||||
router.push('/home/plugins');
|
navigate('/home/plugins');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
@@ -752,7 +751,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('github');
|
setPendingPluginInstallAction('github');
|
||||||
router.push('/home/plugins');
|
navigate('/home/plugins');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Github className="size-4" />
|
<Github className="size-4" />
|
||||||
@@ -763,10 +762,10 @@ function NavItems({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
router.push(`${routePrefix}?id=new`);
|
navigate(`${routePrefix}?id=new`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
@@ -1029,9 +1028,10 @@ export default function HomeSidebar({
|
|||||||
}: {
|
}: {
|
||||||
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
|
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const pathname = usePathname();
|
const location = useLocation();
|
||||||
const searchParams = useSearchParams();
|
const pathname = location.pathname;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1071,14 +1071,16 @@ export default function HomeSidebar({
|
|||||||
if (open) {
|
if (open) {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('action', 'showModelSettings');
|
params.set('action', 'showModelSettings');
|
||||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
navigate(`${pathname}?${params.toString()}`, {
|
||||||
|
preventScrollReset: true,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.delete('action');
|
params.delete('action');
|
||||||
const newUrl = params.toString()
|
const newUrl = params.toString()
|
||||||
? `${pathname}?${params.toString()}`
|
? `${pathname}?${params.toString()}`
|
||||||
: pathname;
|
: pathname;
|
||||||
router.replace(newUrl, { scroll: false });
|
navigate(newUrl, { preventScrollReset: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,14 +1089,16 @@ export default function HomeSidebar({
|
|||||||
if (open) {
|
if (open) {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('action', 'showAccountSettings');
|
params.set('action', 'showAccountSettings');
|
||||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
navigate(`${pathname}?${params.toString()}`, {
|
||||||
|
preventScrollReset: true,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.delete('action');
|
params.delete('action');
|
||||||
const newUrl = params.toString()
|
const newUrl = params.toString()
|
||||||
? `${pathname}?${params.toString()}`
|
? `${pathname}?${params.toString()}`
|
||||||
: pathname;
|
: pathname;
|
||||||
router.replace(newUrl, { scroll: false });
|
navigate(newUrl, { preventScrollReset: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1165,7 +1169,7 @@ export default function HomeSidebar({
|
|||||||
// User click: update state AND navigate
|
// User click: update state AND navigate
|
||||||
function handleChildClick(child: SidebarChildVO) {
|
function handleChildClick(child: SidebarChildVO) {
|
||||||
selectChild(child);
|
selectChild(child);
|
||||||
router.push(child.route);
|
navigate(child.route);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initSelect() {
|
function initSelect() {
|
||||||
@@ -1226,7 +1230,7 @@ export default function HomeSidebar({
|
|||||||
tooltip="LangBot"
|
tooltip="LangBot"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={langbotIcon.src}
|
src={langbotIcon}
|
||||||
alt="LangBot"
|
alt="LangBot"
|
||||||
className="size-8 rounded-lg"
|
className="size-8 rounded-lg"
|
||||||
/>
|
/>
|
||||||
@@ -1406,7 +1410,7 @@ export default function HomeSidebar({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
router.push('/wizard');
|
navigate('/wizard');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Zap className="text-blue-500" />
|
<Zap className="text-blue-500" />
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, Boxes } from 'lucide-react';
|
import { Plus, Boxes } from 'lucide-react';
|
||||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react';
|
import { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Plus, X } from 'lucide-react';
|
import { Plus, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Trash2, Eye, Wrench, Check } from 'lucide-react';
|
import { Trash2, Eye, Wrench, Check } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -135,7 +133,7 @@ export default function ProviderCard({
|
|||||||
{isLangBotModels ? (
|
{isLangBotModels ? (
|
||||||
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
|
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src={langbotIcon.src}
|
src={langbotIcon}
|
||||||
alt="LangBot"
|
alt="LangBot"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +30,7 @@ import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
|
|||||||
|
|
||||||
export default function KBDetailContent({ id }: { id: string }) {
|
export default function KBDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
|
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
|
||||||
useSidebarData();
|
useSidebarData();
|
||||||
@@ -84,12 +82,12 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleKbDeleted() {
|
function handleKbDeleted() {
|
||||||
refreshKnowledgeBases();
|
refreshKnowledgeBases();
|
||||||
router.push('/home/knowledge');
|
navigate('/home/knowledge');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewKbCreated(newKbId: string) {
|
function handleNewKbCreated(newKbId: string) {
|
||||||
refreshKnowledgeBases();
|
refreshKnowledgeBases();
|
||||||
router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
|
navigate(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKbUpdated() {
|
function handleKbUpdated() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -219,6 +220,12 @@ export default function FileUploadZone({
|
|||||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
{t('knowledge.documentsTab.noParserAvailable')}
|
{t('knowledge.documentsTab.noParserAvailable')}
|
||||||
</p>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { MoreHorizontal } from 'lucide-react';
|
import { MoreHorizontal } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -304,7 +304,7 @@ export default function KBForm({
|
|||||||
{t('knowledge.noEnginesAvailable')}
|
{t('knowledge.noEnginesAvailable')}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/home/market?category=KnowledgeEngine"
|
to="/home/market?category=KnowledgeEngine"
|
||||||
className="text-sm text-primary hover:underline"
|
className="text-sm text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{t('knowledge.installEngineHint')}
|
{t('knowledge.installEngineHint')}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
'use client';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
@@ -10,7 +8,7 @@ import KBDetailContent from './KBDetailContent';
|
|||||||
|
|
||||||
export default function KnowledgePage() {
|
export default function KnowledgePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
const { refreshKnowledgeBases } = useSidebarData();
|
const { refreshKnowledgeBases } = useSidebarData();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
||||||
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
|
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
|
||||||
import React, {
|
import React, {
|
||||||
@@ -21,8 +19,8 @@ import {
|
|||||||
initializeUserInfo,
|
initializeUserInfo,
|
||||||
initializeSystemInfo,
|
initializeSystemInfo,
|
||||||
} from '@/app/infra/http';
|
} from '@/app/infra/http';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import Link from 'next/link';
|
import { Link } from 'react-router-dom';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { CircleHelp } from 'lucide-react';
|
import { CircleHelp } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -59,7 +57,7 @@ export default function HomeLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Initialize user info if not already initialized
|
// Initialize user info if not already initialized
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,14 +73,14 @@ export default function HomeLayout({
|
|||||||
// Always re-fetch to ensure we have the latest wizard_status from backend
|
// Always re-fetch to ensure we have the latest wizard_status from backend
|
||||||
await initializeSystemInfo();
|
await initializeSystemInfo();
|
||||||
if (systemInfo.wizard_status === 'none') {
|
if (systemInfo.wizard_status === 'none') {
|
||||||
router.replace('/wizard');
|
navigate('/wizard');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If fetching system info fails, don't redirect
|
// If fetching system info fails, don't redirect
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkWizard();
|
checkWizard();
|
||||||
}, [router]);
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarDataProvider>
|
<SidebarDataProvider>
|
||||||
@@ -101,7 +99,8 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
zh_Hans: '',
|
zh_Hans: '',
|
||||||
});
|
});
|
||||||
const { detailEntityName } = useSidebarData();
|
const { detailEntityName } = useSidebarData();
|
||||||
const pathname = usePathname();
|
const location = useLocation();
|
||||||
|
const pathname = location.pathname;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
|
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
|
||||||
@@ -139,7 +138,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem className="hidden md:block">
|
<BreadcrumbItem className="hidden md:block">
|
||||||
<BreadcrumbLink asChild>
|
<BreadcrumbLink asChild>
|
||||||
<Link href={sectionLink}>{sectionLabel}</Link>
|
<Link to={sectionLink}>{sectionLabel}</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
|
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -30,7 +28,7 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export default function MCPDetailContent({ id }: { id: string }) {
|
export default function MCPDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshMCPServers, mcpServers, setDetailEntityName } =
|
const { refreshMCPServers, mcpServers, setDetailEntityName } =
|
||||||
useSidebarData();
|
useSidebarData();
|
||||||
@@ -96,12 +94,12 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleServerDeleted() {
|
function handleServerDeleted() {
|
||||||
refreshMCPServers();
|
refreshMCPServers();
|
||||||
router.push('/home/mcp');
|
navigate('/home/mcp');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewServerCreated(serverName: string) {
|
function handleNewServerCreated(serverName: string) {
|
||||||
refreshMCPServers();
|
refreshMCPServers();
|
||||||
router.push(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
'use client';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MCPDetailContent from './MCPDetailContent';
|
import MCPDetailContent from './MCPDetailContent';
|
||||||
|
|
||||||
export default function MCPPage() {
|
export default function MCPPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { MessageDetails } from '../types/monitoring';
|
import { MessageDetails } from '../types/monitoring';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MetricCard from './MetricCard';
|
import MetricCard from './MetricCard';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user