Compare commits

..

7 Commits

Author SHA1 Message Date
RockChinQ 98ccbf0f99 refactor: extract RoutingRulesEditor component, revert log levels to debug
- Extract ~250 lines of inline routing rules UI from BotForm into
  a dedicated RoutingRulesEditor component
- Revert stage interrupt and event prevented-default log levels
  from warning back to debug (these are normal flow, not errors)
- Remove message content from log lines to avoid leaking user data
2026-04-02 22:19:28 +08:00
Typer_Body eb633f8849 fix: format BotForm.tsx with prettier 2026-04-02 01:38:21 +08:00
Typer_Body ac337b31df feat: pipeline routing fix - add routed_by_rule bypass and diagnostic logging
- Skip GroupRespondRuleCheckStage when message is routed by rule
- Add WARNING logs when queries are silently dropped
- Add pipeline routing rules support (bot entity, migration, web UI)
- Pass routed_by_rule flag through aggregator -> pool -> query variables
2026-04-02 01:33:17 +08:00
Typer_Body c3e2d5e055 Merge remote-tracking branch 'origin/master' into temp-update
# Conflicts:
#	web/pnpm-lock.yaml
2026-04-02 01:18:38 +08:00
Junyan Qin 723c57d751 fix: linter err 2026-03-29 23:57:48 +08:00
Junyan Qin 0a69875c09 feat: enhance plugin installation process and improve task management 2026-03-29 23:55:36 +08:00
Typer_Body f41d69324c Optimize the plugin system 2026-03-29 16:45:54 +08:00
154 changed files with 8303 additions and 5346 deletions
@@ -43,10 +43,10 @@ jobs:
run: |
cd /tmp/langbot_build_web/web
npm install
npx vite build
npm run build
- name: Package Output
run: |
cp -r /tmp/langbot_build_web/web/dist ./web
cp -r /tmp/langbot_build_web/web/out ./web
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
+2 -2
View File
@@ -29,8 +29,8 @@ jobs:
npm install -g pnpm
pnpm install
pnpm build
mkdir -p ../src/langbot/web/dist
cp -r dist ../src/langbot/web/
mkdir -p ../src/langbot/web/out
cp -r out ../src/langbot/web/
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v6
-3
View File
@@ -52,6 +52,3 @@ src/langbot/web/
/dist
/build
*.egg-info
# Next.js build cache (legacy)
web/.next/
+2 -2
View File
@@ -4,7 +4,7 @@ WORKDIR /app
COPY web ./web
RUN cd web && npm install && npx vite build
RUN cd web && npm install && npm run build
FROM python:3.12.7-slim
@@ -12,7 +12,7 @@ WORKDIR /app
COPY . .
COPY --from=node /app/web/dist ./web/dist
COPY --from=node /app/web/out ./web/out
RUN apt update \
&& apt install gcc -y \
+2 -2
View File
@@ -64,7 +64,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.7",
"langbot-plugin==0.3.6",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",
@@ -111,7 +111,7 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**"] }
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
[dependency-groups]
dev = [
+14 -19
View File
@@ -434,10 +434,10 @@ async def parse_wecom_bot_message(
}
if voice_info.get('content'):
message_data['content'] = voice_info.get('content')
# if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
# voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
# if voice_base64:
# message_data['voice']['base64'] = voice_base64
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
if voice_base64:
message_data['voice']['base64'] = voice_base64
elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url')
@@ -449,12 +449,10 @@ async def parse_wecom_bot_message(
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
# if (video_data.get('filesize') or 0) <= max_inline_file_size:
# video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
# if video_base64:
# video_data['base64'] = video_base64
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
if video_base64:
video_data['base64'] = video_base64
message_data['video'] = video_data
elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {}
@@ -468,15 +466,12 @@ async def parse_wecom_bot_message(
'download_url': download_url,
'extra': file_info,
}
# if (file_data.get('filesize') or 0) <= max_inline_file_size:
# file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
# if file_bytes:
# file_data['base64'] = _bytes_to_data_uri(file_bytes)
# if dl_filename and not file_data.get('filename'):
# file_data['filename'] = dl_filename
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
if file_bytes:
file_data['base64'] = _bytes_to_data_uri(file_bytes)
if dl_filename and not file_data.get('filename'):
file_data['filename'] = dl_filename
message_data['file'] = file_data
elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {})
+10 -97
View File
@@ -20,7 +20,7 @@ from typing import Any, Callable, Optional
import aiohttp
from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message
from langbot.pkg.platform.logger import EventLogger
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
@@ -96,12 +96,6 @@ class WecomBotWsClient:
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
# Dedup: skip sending when content hasn't changed
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
# Stream session info for feedback tracking
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
# Feedback tracking: feedback_id -> session info
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
# msg_id -> feedback_id (for associating feedback with message)
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
# ── Public API ──────────────────────────────────────────────────
@@ -170,27 +164,12 @@ class WecomBotWsClient:
return decorator
def on_feedback(self) -> Callable:
"""Decorator to register a feedback event handler.
Same interface as WecomBotClient.on_feedback for compatibility.
"""
def decorator(func: Callable):
if 'feedback' not in self._message_handlers:
self._message_handlers['feedback'] = []
self._message_handlers['feedback'].append(func)
return func
return decorator
async def reply_stream(
self,
req_id: str,
stream_id: str,
content: str,
finish: bool = False,
feedback_id: str = '',
) -> Optional[dict]:
"""Send a streaming reply frame.
@@ -199,22 +178,17 @@ class WecomBotWsClient:
stream_id: The stream ID for this streaming session.
content: The content to send (supports Markdown).
finish: Whether this is the final chunk.
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
Returns:
The ACK frame dict, or None on failure.
"""
stream_payload = {
'id': stream_id,
'finish': finish,
'content': content,
}
if feedback_id:
stream_payload['feedback'] = {'id': feedback_id}
body = {
'msgtype': 'stream',
'stream': stream_payload,
'stream': {
'id': stream_id,
'finish': finish,
'content': content,
},
}
return await self._send_reply(req_id, body)
@@ -279,23 +253,11 @@ class WecomBotWsClient:
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
if not is_final and content == self._stream_last_content.get(msg_id):
return True
# 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)
await self.reply_stream(req_id, stream_id, content, finish=is_final)
self._stream_last_content[msg_id] = content
if is_final:
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return True
except Exception:
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
@@ -483,15 +445,6 @@ class WecomBotWsClient:
msg_id = message_data.get('msgid', '')
if msg_id:
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
# Store session info for feedback tracking
self._stream_sessions[msg_id] = {
'req_id': req_id,
'stream_id': stream_id,
'msg_id': msg_id,
'user_id': message_data.get('userid', ''),
'chat_id': message_data.get('chatid', ''),
'chat_type': message_data.get('type', 'single'),
}
message_data['stream_id'] = stream_id
message_data['req_id'] = req_id
@@ -501,7 +454,7 @@ class WecomBotWsClient:
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
async def _handle_event_callback(self, frame: dict):
"""Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event)."""
"""Handle an incoming event callback frame (enter_chat, template_card_event, etc.)."""
try:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
@@ -526,54 +479,14 @@ class WecomBotWsClient:
if body.get('chatid'):
message_data['chatid'] = body.get('chatid', '')
if event_type == 'feedback_event':
feedback_event = event_info.get('feedback_event', {})
feedback_id = feedback_event.get('id', '')
feedback_type = feedback_event.get('type', 0)
feedback_content = feedback_event.get('content', '')
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
await self.logger.info(
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
f'content={feedback_content}, reasons={inaccurate_reasons}'
)
# Look up session by feedback_id
session_info = self._feedback_sessions.get(feedback_id)
session = None
if session_info:
session = StreamSession(
stream_id=session_info.get('stream_id', ''),
msg_id=session_info.get('msg_id', ''),
chat_id=session_info.get('chat_id') or None,
user_id=session_info.get('user_id') or None,
feedback_id=feedback_id,
)
await self.logger.info(
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
)
else:
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
return
event = wecombotevent.WecomBotEvent(message_data)
# Dispatch to event-specific handlers
if event_type in self._message_handlers:
for handler in self._message_handlers[event_type]:
await handler(event)
# Also dispatch to generic 'event' handlers
if 'event' in self._message_handlers:
for handler in self._message_handlers['event']:
await handler(event)
@@ -1,97 +0,0 @@
from __future__ import annotations
import quart
from .. import group
@group.group_class('human-takeover', '/api/v1/human-takeover')
class HumanTakeoverRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_sessions():
"""Get list of takeover sessions, optionally filtered by bot UUID."""
bot_uuid = quart.request.args.get('botUuid')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
sessions, total = await self.ap.human_takeover_service.get_active_sessions(
bot_uuid=bot_uuid if bot_uuid else None,
limit=limit,
offset=offset,
)
return self.success(
data={
'sessions': sessions,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/sessions/<session_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_session_detail(session_id: str):
"""Get detail for a specific takeover session."""
detail = await self.ap.human_takeover_service.get_session_detail(session_id)
if not detail:
return self.success(data={'found': False, 'session_id': session_id})
return self.success(data={'found': True, 'session': detail})
@self.route('/sessions/<session_id>/takeover', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def takeover_session(session_id: str, user_email: str = None):
"""Take over a conversation session."""
data = await quart.request.get_json(silent=True) or {}
bot_uuid = data.get('bot_uuid')
if not bot_uuid:
return self.fail(-1, 'bot_uuid is required')
platform = data.get('platform')
user_id = data.get('user_id')
user_name = data.get('user_name')
try:
result = await self.ap.human_takeover_service.takeover_session(
session_id=session_id,
bot_uuid=bot_uuid,
taken_by=user_email or data.get('taken_by'),
platform=platform,
user_id=user_id,
user_name=user_name,
)
return self.success(data=result)
except ValueError as e:
return self.fail(-1, str(e))
@self.route('/sessions/<session_id>/release', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def release_session(session_id: str):
"""Release a taken-over session back to AI pipeline."""
try:
result = await self.ap.human_takeover_service.release_session(session_id)
return self.success(data=result)
except ValueError as e:
return self.fail(-1, str(e))
@self.route('/sessions/<session_id>/message', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def send_message(session_id: str, user_email: str = None):
"""Send a message from the operator to the user."""
data = await quart.request.get_json(silent=True) or {}
message_text = data.get('message')
if not message_text:
return self.fail(-1, 'message is required')
operator_name = user_email or data.get('operator_name', 'Operator')
try:
result = await self.ap.human_takeover_service.send_message(
session_id=session_id,
message_text=message_text,
operator_name=operator_name,
)
return self.success(data=result)
except ValueError as e:
return self.fail(-1, str(e))
except RuntimeError as e:
return self.fail(-2, str(e))
@@ -1,45 +0,0 @@
from __future__ import annotations
from ... import group
@group.group_class('tools', '/api/v1/tools')
class ToolsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取所有可用工具列表"""
tools = await self.ap.tool_mgr.get_all_tools()
tool_list = []
for tool in tools:
tool_list.append(
{
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
}
)
return self.success(data={'tools': tool_list})
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(tool_name: str) -> str:
"""获取特定工具详情"""
tools = await self.ap.tool_mgr.get_all_tools()
for tool in tools:
if tool.name == tool_name:
return self.success(
data={
'tool': {
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
}
}
)
return self.http_status(404, -1, f'Tool not found: {tool_name}')
@@ -1,314 +0,0 @@
from __future__ import annotations
import uuid
import datetime
import json
import logging
import sqlalchemy
from ....core import app
from ....entity.persistence import human_takeover as persistence_human_takeover
import langbot_plugin.api.entities.builtin.platform.message as platform_message
class HumanTakeoverService:
"""Human takeover service.
Manages operator takeover of user conversation sessions, bypassing
the normal AI pipeline. Uses an in-memory cache for fast synchronous
lookups on the hot message path, backed by database persistence.
"""
ap: app.Application
# In-memory cache: session_id -> HumanTakeoverSession record id
# Only contains sessions with status='active'
_active_sessions: dict[str, str]
logger: logging.Logger
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self._active_sessions = {}
self.logger = logging.getLogger('human-takeover')
async def initialize(self) -> None:
"""Load active takeover sessions from DB into memory cache."""
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).where(
persistence_human_takeover.HumanTakeoverSession.status == 'active'
)
)
rows = result.all()
for row in rows:
session = row[0] if isinstance(row, tuple) else row
self._active_sessions[session.session_id] = session.id
self.logger.info(f'Loaded {len(self._active_sessions)} active takeover sessions from DB')
except Exception as e:
self.logger.warning(f'Failed to load active takeover sessions: {e}')
def is_taken_over(self, session_id: str) -> bool:
"""Check if a session is currently under human takeover.
This is a synchronous in-memory lookup for performance, since it
is called on every incoming message (hot path).
"""
return session_id in self._active_sessions
async def takeover_session(
self,
session_id: str,
bot_uuid: str,
taken_by: str | None = None,
platform: str | None = None,
user_id: str | None = None,
user_name: str | None = None,
) -> dict:
"""Take over a conversation session.
Args:
session_id: The session to take over (e.g. 'person_123' or 'group_456').
bot_uuid: UUID of the bot whose session is being taken over.
taken_by: Email/username of the admin performing the takeover.
platform: Platform name.
user_id: The end-user's ID in the session.
user_name: The end-user's display name.
Returns:
Dict with the created takeover session record.
Raises:
ValueError: If the session is already taken over.
"""
if self.is_taken_over(session_id):
raise ValueError(f'Session {session_id} is already taken over')
record_id = str(uuid.uuid4())
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
record_data = {
'id': record_id,
'session_id': session_id,
'bot_uuid': bot_uuid,
'status': 'active',
'taken_by': taken_by,
'taken_at': now,
'released_at': None,
'platform': platform,
'user_id': user_id,
'user_name': user_name,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_human_takeover.HumanTakeoverSession).values(record_data)
)
# Update in-memory cache
self._active_sessions[session_id] = record_id
self.logger.info(f'Session {session_id} taken over by {taken_by}')
return record_data
async def release_session(self, session_id: str) -> dict:
"""Release a taken-over session back to AI pipeline processing.
Args:
session_id: The session to release.
Returns:
Dict with the updated takeover session record.
Raises:
ValueError: If the session is not currently taken over.
"""
if not self.is_taken_over(session_id):
raise ValueError(f'Session {session_id} is not currently taken over')
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_human_takeover.HumanTakeoverSession)
.where(
sqlalchemy.and_(
persistence_human_takeover.HumanTakeoverSession.session_id == session_id,
persistence_human_takeover.HumanTakeoverSession.status == 'active',
)
)
.values(status='released', released_at=now)
)
# Remove from in-memory cache
self._active_sessions.pop(session_id, None)
self.logger.info(f'Session {session_id} released back to AI pipeline')
return {
'session_id': session_id,
'status': 'released',
'released_at': now.isoformat(),
}
async def send_message(
self,
session_id: str,
message_text: str,
operator_name: str | None = None,
) -> dict:
"""Send a message from the operator to the user via the platform adapter.
Args:
session_id: The taken-over session ID (e.g. 'person_123' or 'group_456').
message_text: The text message to send.
operator_name: Name of the operator sending the message.
Returns:
Dict with send result info.
Raises:
ValueError: If the session is not currently taken over.
RuntimeError: If the bot or adapter cannot be found.
"""
if not self.is_taken_over(session_id):
raise ValueError(f'Session {session_id} is not currently taken over')
# Look up the takeover record to get bot_uuid
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).where(
sqlalchemy.and_(
persistence_human_takeover.HumanTakeoverSession.session_id == session_id,
persistence_human_takeover.HumanTakeoverSession.status == 'active',
)
)
)
row = result.first()
if not row:
raise RuntimeError(f'Active takeover record not found for session {session_id}')
takeover_record = row[0] if isinstance(row, tuple) else row
bot_uuid = takeover_record.bot_uuid
# Get the runtime bot
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
if not runtime_bot:
raise RuntimeError(f'Bot {bot_uuid} not found or not running')
# Parse session_id to determine target_type and target_id
# Format: 'person_{id}' or 'group_{id}'
if session_id.startswith('person_'):
target_type = 'person'
target_id = session_id[len('person_') :]
elif session_id.startswith('group_'):
target_type = 'group'
target_id = session_id[len('group_') :]
else:
raise ValueError(f'Invalid session_id format: {session_id}')
# Build message chain
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
# Send via adapter
await runtime_bot.adapter.send_message(target_type, target_id, message_chain)
# Record the operator message in monitoring
bot_name = runtime_bot.bot_entity.name or bot_uuid
try:
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
except Exception:
message_content = message_text
await self.ap.monitoring_service.record_message(
bot_id=bot_uuid,
bot_name=bot_name,
pipeline_id='__human_takeover__',
pipeline_name='Human Takeover',
message_content=message_content,
session_id=session_id,
status='success',
level='info',
platform=takeover_record.platform,
user_id=operator_name or 'operator',
user_name=operator_name or 'Operator',
role='operator',
)
self.logger.info(f'Operator message sent to session {session_id}: {message_text[:50]}...')
return {
'session_id': session_id,
'message_sent': True,
}
async def get_active_sessions(
self,
bot_uuid: str | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get list of active (or all) takeover sessions.
Args:
bot_uuid: Optional filter by bot UUID.
limit: Maximum number of results.
offset: Pagination offset.
Returns:
Tuple of (list of session dicts, total count).
"""
conditions = []
if bot_uuid:
conditions.append(persistence_human_takeover.HumanTakeoverSession.bot_uuid == bot_uuid)
# Count
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_human_takeover.HumanTakeoverSession.id))
if conditions:
count_query = count_query.where(sqlalchemy.and_(*conditions))
count_result = await self.ap.persistence_mgr.execute_async(count_query)
total = count_result.scalar() or 0
# Fetch records
query = sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).order_by(
persistence_human_takeover.HumanTakeoverSession.taken_at.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit).offset(offset)
result = await self.ap.persistence_mgr.execute_async(query)
rows = result.all()
sessions = []
for row in rows:
session = row[0] if isinstance(row, tuple) else row
sessions.append(
self.ap.persistence_mgr.serialize_model(persistence_human_takeover.HumanTakeoverSession, session)
)
return sessions, total
async def get_session_detail(self, session_id: str) -> dict | None:
"""Get detail for a specific takeover session.
Args:
session_id: The session ID to look up.
Returns:
Session dict or None if not found.
"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession)
.where(persistence_human_takeover.HumanTakeoverSession.session_id == session_id)
.order_by(persistence_human_takeover.HumanTakeoverSession.taken_at.desc())
)
row = result.first()
if not row:
return None
session = row[0] if isinstance(row, tuple) else row
return self.ap.persistence_mgr.serialize_model(persistence_human_takeover.HumanTakeoverSession, session)
-3
View File
@@ -31,7 +31,6 @@ from ..api.http.service import mcp as mcp_service
from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_service
from ..api.http.service import human_takeover as human_takeover_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
@@ -154,8 +153,6 @@ class Application:
monitoring_service: monitoring_service.MonitoringService = None
human_takeover_service: human_takeover_service.HumanTakeoverService = None
def __init__(self):
pass
-5
View File
@@ -28,7 +28,6 @@ from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...api.http.service import monitoring as monitoring_service
from ...api.http.service import human_takeover as human_takeover_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
@@ -165,10 +164,6 @@ class BuildAppStage(stage.BootingStage):
monitoring_service_inst = monitoring_service.MonitoringService(ap)
ap.monitoring_service = monitoring_service_inst
human_takeover_service_inst = human_takeover_service.HumanTakeoverService(ap)
await human_takeover_service_inst.initialize()
ap.human_takeover_service = human_takeover_service_inst
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
await asyncio.sleep(3)
await plugin_connector_inst.initialize()
+2 -6
View File
@@ -80,12 +80,8 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
if i == len(keys) - 1:
# At the final key
if key in current:
if isinstance(current[key], list):
# Convert comma-separated string to list
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
elif isinstance(current[key], dict):
# Skip dict types
if isinstance(current[key], (dict, list)):
# Skip dict and list types
pass
else:
# Valid scalar value - convert and set it
@@ -1,36 +0,0 @@
import sqlalchemy
from .base import Base
class HumanTakeoverSession(Base):
"""Human takeover session records.
Tracks which conversation sessions are currently under human operator control,
bypassing the normal AI pipeline processing.
"""
__tablename__ = 'human_takeover_sessions'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
"""Corresponds to monitoring_sessions.session_id, format: 'person_{id}' or 'group_{id}'"""
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
"""UUID of the bot whose session is being taken over"""
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='active', index=True)
"""Takeover status: 'active' or 'released'"""
taken_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Email/username of the admin who took over the session"""
taken_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
"""Timestamp when the takeover started"""
released_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
"""Timestamp when the takeover was released (null if still active)"""
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
@@ -1,36 +0,0 @@
import sqlalchemy
from .. import migration
@migration.migration_class(26)
class DBMigrateHumanTakeoverSessions(migration.DBMigration):
"""Create human_takeover_sessions table for human operator takeover support"""
async def upgrade(self):
sql_text = sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS human_takeover_sessions (
id VARCHAR(255) PRIMARY KEY,
session_id VARCHAR(255) NOT NULL UNIQUE,
bot_uuid VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'active',
taken_by VARCHAR(255),
taken_at DATETIME NOT NULL,
released_at DATETIME,
platform VARCHAR(255),
user_id VARCHAR(255),
user_name VARCHAR(255)
)
""")
await self.ap.persistence_mgr.execute_async(sql_text)
# Create indexes
for idx_sql in [
'CREATE INDEX IF NOT EXISTS idx_hts_session_id ON human_takeover_sessions (session_id)',
'CREATE INDEX IF NOT EXISTS idx_hts_bot_uuid ON human_takeover_sessions (bot_uuid)',
'CREATE INDEX IF NOT EXISTS idx_hts_status ON human_takeover_sessions (status)',
]:
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(idx_sql))
async def downgrade(self):
sql_text = sqlalchemy.text('DROP TABLE IF EXISTS human_takeover_sessions')
await self.ap.persistence_mgr.execute_async(sql_text)
+6 -2
View File
@@ -247,7 +247,9 @@ class RuntimePipeline:
await self._check_output(query, result)
if result.result_type == pipeline_entities.ResultType.INTERRUPT:
self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
self.ap.logger.debug(
f'Stage {stage_container.inst_name} interrupted query {query.query_id}'
)
break
elif result.result_type == pipeline_entities.ResultType.CONTINUE:
query = result.new_query
@@ -261,7 +263,9 @@ class RuntimePipeline:
await self._check_output(query, sub_result)
if sub_result.result_type == pipeline_entities.ResultType.INTERRUPT:
self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
self.ap.logger.debug(
f'Stage {stage_container.inst_name} interrupted query {query.query_id}'
)
break
elif sub_result.result_type == pipeline_entities.ResultType.CONTINUE:
query = sub_result.new_query
+2 -203
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import json
import re
import traceback
import sqlalchemy
@@ -74,15 +73,11 @@ class RuntimeBot:
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.
@@ -93,22 +88,14 @@ class RuntimeBot:
- 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')
@@ -126,76 +113,9 @@ class RuntimeBot:
elif rule_type == 'message_content':
if self._match_operator(message_text, operator, rule_value):
return target_uuid, True
elif rule_type == 'message_has_element':
has_element = rule_value in element_type_set
if operator == 'eq' and has_element:
return target_uuid, True
elif operator == 'neq' and not has_element:
return target_uuid, True
return self.bot_entity.use_pipeline_uuid, False
async def _record_discarded_message(
self,
launcher_type: provider_session.LauncherTypes,
launcher_id: str | int,
sender_id: str | int,
message_event: platform_events.MessageEvent,
message_chain: platform_message.MessageChain,
) -> None:
"""Record a discarded message in the monitoring system."""
try:
if hasattr(message_chain, 'model_dump'):
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
else:
message_content = str(message_chain)
sender_name = None
if hasattr(message_event, 'sender'):
if hasattr(message_event.sender, 'nickname'):
sender_name = message_event.sender.nickname
elif hasattr(message_event.sender, 'member_name'):
sender_name = message_event.sender.member_name
# Use the same session_id format as monitoring_helper.py
session_id = f'{launcher_type}_{launcher_id}'
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
await self.ap.monitoring_service.record_message(
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
pipeline_id=self.PIPELINE_DISCARD,
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
message_content=message_content,
session_id=session_id,
status='discarded',
level='info',
platform=platform,
user_id=str(sender_id),
user_name=sender_name,
)
# Ensure the session exists so the message appears in the session monitor.
# Don't overwrite pipeline info — a session may have messages from
# multiple pipelines; discarding shouldn't change the displayed pipeline.
session_updated = await self.ap.monitoring_service.update_session_activity(
session_id,
)
if not session_updated:
# No session yet (first message for this launcher was discarded).
await self.ap.monitoring_service.record_session_start(
session_id=session_id,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
pipeline_id=self.PIPELINE_DISCARD,
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
platform=platform,
user_id=str(sender_id),
user_name=sender_name,
)
except Exception as e:
await self.logger.error(f'Failed to record discarded message: {e}')
async def initialize(self):
async def on_friend_message(
event: platform_events.FriendMessage,
@@ -220,47 +140,6 @@ class RuntimeBot:
# Only add to query pool if no webhook requested to skip pipeline
if not skip_pipeline:
# Check if session is under human takeover
person_session_id = f'person_{event.sender.id}'
if (
hasattr(self.ap, 'human_takeover_service')
and self.ap.human_takeover_service
and self.ap.human_takeover_service.is_taken_over(person_session_id)
):
# Session is taken over: record message to monitoring then stop
await self.logger.info(
f'Person message intercepted by human takeover for session {person_session_id}'
)
try:
if hasattr(event.message_chain, 'model_dump'):
msg_content = json.dumps(event.message_chain.model_dump(), ensure_ascii=False)
else:
msg_content = str(event.message_chain)
sender_name = None
if hasattr(event, 'sender') and hasattr(event.sender, 'nickname'):
sender_name = event.sender.nickname
await self.ap.monitoring_service.record_message(
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
pipeline_id='__human_takeover__',
pipeline_name='Human Takeover',
message_content=msg_content,
session_id=person_session_id,
status='success',
level='info',
platform=adapter.__class__.__name__,
user_id=str(event.sender.id),
user_name=sender_name,
role='user',
)
await self.ap.monitoring_service.update_session_activity(person_session_id)
except Exception as e:
await self.logger.error(f'Failed to record takeover message: {e}')
return
launcher_id = event.sender.id
if hasattr(adapter, 'get_launcher_id'):
@@ -269,21 +148,7 @@ class RuntimeBot:
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
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid('person', launcher_id, message_text)
await self.ap.msg_aggregator.add_message(
bot_uuid=self.bot_entity.uuid,
@@ -322,50 +187,6 @@ class RuntimeBot:
# Only add to query pool if no webhook requested to skip pipeline
if not skip_pipeline:
# Check if session is under human takeover
group_session_id = f'group_{event.group.id}'
if (
hasattr(self.ap, 'human_takeover_service')
and self.ap.human_takeover_service
and self.ap.human_takeover_service.is_taken_over(group_session_id)
):
# Session is taken over: record message to monitoring then stop
await self.logger.info(
f'Group message intercepted by human takeover for session {group_session_id}'
)
try:
if hasattr(event.message_chain, 'model_dump'):
msg_content = json.dumps(event.message_chain.model_dump(), ensure_ascii=False)
else:
msg_content = str(event.message_chain)
sender_name = None
if hasattr(event, 'sender'):
if hasattr(event.sender, 'member_name'):
sender_name = event.sender.member_name
elif hasattr(event.sender, 'nickname'):
sender_name = event.sender.nickname
await self.ap.monitoring_service.record_message(
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
pipeline_id='__human_takeover__',
pipeline_name='Human Takeover',
message_content=msg_content,
session_id=group_session_id,
status='success',
level='info',
platform=adapter.__class__.__name__,
user_id=str(event.sender.id),
user_name=sender_name,
role='user',
)
await self.ap.monitoring_service.update_session_activity(group_session_id)
except Exception as e:
await self.logger.error(f'Failed to record takeover message: {e}')
return
launcher_id = event.group.id
if hasattr(adapter, 'get_launcher_id'):
@@ -374,21 +195,7 @@ class RuntimeBot:
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
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid('group', launcher_id, message_text)
await self.ap.msg_aggregator.add_message(
bot_uuid=self.bot_entity.uuid,
@@ -506,20 +313,12 @@ class PlatformManager:
# delete all bot log images
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
for component in self.adapter_components:
if component.metadata.name in disabled_adapters:
continue
adapter_dict[component.metadata.name] = component.get_python_component_class()
self.adapter_dict = adapter_dict
# Filter out disabled adapters from components list (for API responses)
if disabled_adapters:
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
# initialize websocket adapter
websocket_adapter_class = self.adapter_dict['websocket']
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
+1 -106
View File
@@ -797,65 +797,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
asyncio.create_task(on_message(event))
def sync_on_card_action(event):
try:
action_value_obj = getattr(getattr(event.event, 'action', None), 'value', {})
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
if action_value == '有帮助':
feedback_type = 1
elif action_value == '无帮助':
feedback_type = 2
else:
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}})
operator = getattr(event.event, 'operator', None)
context = getattr(event.event, 'context', None)
user_id = getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None)
open_chat_id = getattr(context, 'open_chat_id', None)
open_message_id = getattr(context, 'open_message_id', None)
if open_chat_id:
session_id = f'group_{open_chat_id}'
elif user_id:
session_id = f'person_{user_id}'
else:
session_id = None
feedback_event = platform_events.FeedbackEvent(
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
feedback_type=feedback_type,
feedback_content=action_value,
user_id=user_id,
session_id=session_id,
message_id=open_message_id,
source_platform_object=event,
)
if platform_events.FeedbackEvent in self.listeners:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.create_task(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
else:
loop.run_until_complete(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
except Exception:
asyncio.create_task(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}'))
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
event_handler = (
lark_oapi.EventDispatcherHandler.builder('', '')
.register_p2_im_message_receive_v1(sync_on_message)
.register_p2_card_action_trigger(sync_on_card_action)
.build()
lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build()
)
bot_account_id = config['bot_name']
@@ -1145,7 +1088,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'size': 'medium',
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}],
'margin': '0px 0px 0px 0px',
}
],
@@ -1169,7 +1111,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'size': 'medium',
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}],
'margin': '0px 0px 0px 0px',
}
],
@@ -1531,52 +1472,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
elif 'card.action.trigger' == type:
try:
event_data = data.get('event', {})
operator = event_data.get('operator', {})
action = event_data.get('action', {})
context_data = event_data.get('context', {})
action_value_obj = action.get('value', {})
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
if action_value == '有帮助':
feedback_type = 1
elif action_value == '无帮助':
feedback_type = 2
else:
return {'toast': {'type': 'success', 'content': '操作成功'}}
user_id = operator.get('open_id') or operator.get('user_id')
open_chat_id = context_data.get('open_chat_id')
open_message_id = context_data.get('open_message_id')
if open_chat_id:
session_id = f'group_{open_chat_id}'
elif user_id:
session_id = f'person_{user_id}'
else:
session_id = None
feedback_event = platform_events.FeedbackEvent(
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
feedback_type=feedback_type,
feedback_content=action_value,
user_id=user_id,
session_id=session_id,
message_id=open_message_id,
source_platform_object=data,
)
if platform_events.FeedbackEvent in self.listeners:
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
return {'toast': {'type': 'success', 'content': '感谢您的反馈'}}
except Exception:
await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')
return {'toast': {'type': 'error', 'content': '反馈处理失败'}}
elif 'im.chat.member.bot.added_v1' == type:
try:
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
@@ -343,11 +343,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_id = session.msg_id
stream_id = session.stream_id
await self.logger.info(
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
f'session_id={session_id}, user_id={user_id}, message_id={message_id}'
)
event = platform_events.FeedbackEvent(
feedback_id=feedback_id,
feedback_type=feedback_type,
+1 -1
View File
@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}'
required_database_version = 26
required_database_version = 25
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False
+15 -18
View File
@@ -38,31 +38,28 @@ def get_frontend_path() -> str:
"""
Get the path to the frontend build files.
Returns the path to web/dist directory (Vite build output), handling both:
Returns the path to web/out directory, handling both:
- Development mode: running from source directory
- Package mode: installed via pip/uvx
- Legacy mode: web/out (Next.js, for backward compatibility)
"""
# Check both dist (Vite) and out (legacy Next.js) paths
for dirname in ('dist', 'out'):
web_dir = f'web/{dirname}'
# First, check if we're running from source directory
if _check_if_source_install() and os.path.exists('web/out'):
return 'web/out'
# First, check if we're running from source directory
if _check_if_source_install() and os.path.exists(web_dir):
return web_dir
# Second, check current directory for web/out (in case user is in source dir)
if os.path.exists('web/out'):
return 'web/out'
# Second, check current directory
if os.path.exists(web_dir):
return web_dir
# Third, find it relative to the package installation
pkg_dir = Path(__file__).parent.parent.parent
frontend_path = pkg_dir / 'web' / dirname
if frontend_path.exists():
return str(frontend_path)
# Third, find it relative to the package installation
# Get the directory where this file is located
# paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root
pkg_dir = Path(__file__).parent.parent.parent
frontend_path = pkg_dir / 'web' / 'out'
if frontend_path.exists():
return str(frontend_path)
# Return the default path (will be checked by caller)
return 'web/dist'
return 'web/out'
def get_resource_path(resource: str) -> str:
-1
View File
@@ -20,7 +20,6 @@ system:
edition: community
recovery_key: ''
allow_modify_login_info: true
disabled_adapters: []
limitation:
max_bots: -1
max_pipelines: -1
@@ -1,280 +0,0 @@
"""
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
Generated
+4 -4
View File
@@ -1937,7 +1937,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.3.7" },
{ name = "langbot-plugin", specifier = "==0.3.6" },
{ name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
{ name = "lark-oapi", specifier = ">=1.4.15" },
@@ -1993,7 +1993,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.3.7"
version = "0.3.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2011,9 +2011,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/31/8dc7106cb65004a01e363308343c5a95e35f1722f26c87853e6e12c6fee1/langbot_plugin-0.3.7.tar.gz", hash = "sha256:bc0dea6b1c515d9fc8c3ab14af74bdf3e006d7e20c097b6cb5034f5af4a73cc9", size = 179764, upload-time = "2026-04-03T09:43:17.343Z" }
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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/51/1982c199bd4efbfa3c327c95cca7e4ab502610251567000b348c72bca1b1/langbot_plugin-0.3.7-py3-none-any.whl", hash = "sha256:2e2b9e99163ceb14da28b8ce7c4cbc6990dea15684ec78976bc015e5378feea2", size = 157324, upload-time = "2026-04-03T09:43:15.782Z" },
{ 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" },
]
[[package]]
+1 -1
View File
@@ -1 +1 @@
VITE_API_BASE_URL=http://localhost:5300
NEXT_PUBLIC_API_BASE_URL=http://192.168.1.97:5300
-1
View File
@@ -14,7 +14,6 @@
/coverage
# next.js
/dist/
/.next/
/out/
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
+11 -20
View File
@@ -1,27 +1,18 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...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',
},
},
...compat.extends('next/core-web-vitals', 'next/typescript'),
eslintPluginPrettierRecommended,
{
ignores: ['dist/**', 'node_modules/**'],
},
];
export default eslintConfig;
-2
View File
@@ -1,2 +0,0 @@
sed -i 's/children={<HomePage \/>} />\n <HomePage \/>\n <\/HomeLayout>/g' src/router.tsx
# well it's easier to recreate router.tsx
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LangBot</title>
<meta name="description" content="Production-grade platform for building agentic IM bots" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-29
View File
@@ -1,29 +0,0 @@
#!/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' {} +
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
output: 'export',
};
export default nextConfig;
+3452 -894
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -3,15 +3,16 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write ."
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"lint-staged": "lint-staged"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"next lint --fix",
"prettier --write"
]
},
@@ -45,7 +46,6 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "^6.0.1",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -55,6 +55,8 @@
"input-otp": "^1.4.2",
"lodash": "^4.17.23",
"lucide-react": "^0.507.0",
"next": "~16.1.5",
"next-themes": "^0.4.6",
"postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "19.2.1",
@@ -63,7 +65,6 @@
"react-i18next": "^15.5.1",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
"react-router-dom": "^7.14.0",
"react-syntax-highlighter": "^16.1.0",
"recharts": "2.15.4",
"rehype-autolink-headings": "^7.1.0",
@@ -76,10 +77,10 @@
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
"uuidjs": "^5.1.0",
"vite": "^8.0.3",
"zod": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/debug": "^4.1.12",
"@types/estree": "^1.0.8",
"@types/estree-jsx": "^1.0.5",
@@ -94,10 +95,9 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/unist": "^3.0.3",
"eslint": "^9",
"eslint-config-next": "15.2.4",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3",
"tw-animate-css": "^1.2.9",
+4174 -1403
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -1,5 +1,7 @@
'use client';
import { useEffect, useState, useCallback, Suspense } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useRouter, useSearchParams } from 'next/navigation';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -21,8 +23,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
import langbotIcon from '@/app/assets/langbot-logo.webp';
function SpaceOAuthCallbackContent() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const [status, setStatus] = useState<
@@ -49,7 +51,7 @@ function SpaceOAuthCallbackContent() {
const wizardState = localStorage.getItem('langbot_wizard_state');
const redirectTo = wizardState ? '/wizard' : '/home';
setTimeout(() => {
navigate(redirectTo);
router.push(redirectTo);
}, 1000);
} catch (err) {
setStatus('error');
@@ -62,7 +64,7 @@ function SpaceOAuthCallbackContent() {
}
}
},
[navigate, t],
[router, t],
);
const [bindState, setBindState] = useState<string | null>(null);
@@ -79,7 +81,7 @@ function SpaceOAuthCallbackContent() {
setStatus('success');
toast.success(t('account.bindSpaceSuccess'));
setTimeout(() => {
navigate('/home');
router.push('/home');
}, 1000);
} catch (err) {
setStatus('error');
@@ -94,7 +96,7 @@ function SpaceOAuthCallbackContent() {
setIsProcessing(false);
}
},
[navigate, t],
[router, t],
);
useEffect(() => {
@@ -144,7 +146,7 @@ function SpaceOAuthCallbackContent() {
};
const handleCancelBind = () => {
navigate('/home');
router.push('/home');
};
return (
@@ -152,7 +154,7 @@ function SpaceOAuthCallbackContent() {
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
<CardHeader className="text-center">
<img
src={langbotIcon}
src={langbotIcon.src}
alt="LangBot"
className="w-16 h-16 mb-4 mx-auto"
/>
@@ -215,7 +217,7 @@ function SpaceOAuthCallbackContent() {
<>
<AlertCircle className="h-12 w-12 text-red-500" />
<Button
onClick={() => navigate(isBindMode ? '/home' : '/login')}
onClick={() => router.push(isBindMode ? '/home' : '/login')}
className="w-full mt-4"
>
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}
+2 -2
View File
@@ -1,5 +1,3 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
/* 适用于 Firefox 的滚动条 */
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
@@ -74,7 +72,9 @@
}
}
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
+9 -9
View File
@@ -1,5 +1,7 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
@@ -32,7 +34,7 @@ import { toast } from 'sonner';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const router = useRouter();
const { t } = useTranslation();
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
@@ -103,12 +105,12 @@ export default function BotDetailContent({ id }: { id: string }) {
function handleBotDeleted() {
refreshBots();
navigate('/home/bots');
router.push('/home/bots');
}
function handleNewBotCreated(newBotId: string) {
refreshBots();
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
}
function confirmDelete() {
@@ -174,11 +176,9 @@ export default function BotDetailContent({ id }: { id: string }) {
</div>
)}
</div>
{activeTab === 'config' && (
<Button type="submit" form="bot-form" disabled={!formDirty}>
{t('common.save')}
</Button>
)}
<Button type="submit" form="bot-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>
{/* Horizontal Tabs */}
@@ -34,6 +34,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -68,12 +69,7 @@ const getFormSchema = (t: (key: string) => string) =>
pipeline_routing_rules: z
.array(
z.object({
type: z.enum([
'launcher_type',
'launcher_id',
'message_content',
'message_has_element',
]),
type: z.enum(['launcher_type', 'launcher_id', 'message_content']),
operator: z.enum([
'eq',
'neq',
@@ -6,7 +6,7 @@ import {
PipelineRoutingRule,
RoutingRuleOperator,
} from '@/app/infra/entities/api';
import { Ban, GripVertical, Plus, Trash2 } from 'lucide-react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FormLabel } from '@/components/ui/form';
@@ -14,32 +14,9 @@ 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;
@@ -76,298 +53,26 @@ const OPERATORS_BY_TYPE: Record<
{ 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.type === 'launcher_id') return t('bots.ruleValueLauncherIdPlaceholder');
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 });
};
@@ -393,38 +98,9 @@ export default function RoutingRulesEditor({
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">
@@ -440,41 +116,143 @@ export default function RoutingRulesEditor({
</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>
{rules.map((rule, index) => {
const operatorsForType = OPERATORS_BY_TYPE[rule.type] || OPERATORS_BY_TYPE.message_content;
return (
<div
key={index}
className="flex items-center gap-2 mt-2 p-3 border rounded-md bg-muted/30"
>
{/* 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>
</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>
) : (
<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 ? (
(() => {
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>
{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>
);
})}
</div>
);
}
@@ -1,3 +1,5 @@
'use client';
import { useState } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -1,3 +1,5 @@
'use client';
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -13,7 +15,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { ChevronDownIcon, ExternalLink } from 'lucide-react';
import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useRouter } from 'next/navigation';
export function BotLogListComponent({
botId,
@@ -30,7 +32,7 @@ export function BotLogListComponent({
hideToolbar?: boolean;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const router = useRouter();
const manager = useRef(new BotLogManager(botId)).current;
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
const [autoFlush, setAutoFlush] = useState(true);
@@ -229,7 +231,7 @@ export function BotLogListComponent({
variant="outline"
size="sm"
className="gap-1"
onClick={() => navigate(`/home/monitoring?botId=${botId}`)}
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
>
<ExternalLink className="size-3.5" />
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
@@ -1,3 +1,5 @@
'use client';
import React, {
useState,
useEffect,
@@ -10,7 +12,7 @@ import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { Ban, Bot, Copy, Check, Workflow, UserCheck, Send } from 'lucide-react';
import { Copy, Check } from 'lucide-react';
import {
MessageChainComponent,
Plain,
@@ -19,7 +21,6 @@ import {
Quote,
Voice,
} from '@/app/infra/entities/message';
import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/RoutingRulesEditor';
interface SessionInfo {
session_id: string;
@@ -77,16 +78,6 @@ const BotSessionMonitor = forwardRef<
const [copiedUserId, setCopiedUserId] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
// Human takeover state
const [isTakenOver, setIsTakenOver] = useState(false);
const [takeoverLoading, setTakeoverLoading] = useState(false);
const [operatorMessage, setOperatorMessage] = useState('');
const [sendingMessage, setSendingMessage] = useState(false);
// Track which sessions are taken over for showing badges in the list
const [takenOverSessions, setTakenOverSessions] = useState<Set<string>>(
new Set(),
);
const parseSessionType = (sessionId: string): string | null => {
const idx = sessionId.indexOf('_');
if (idx === -1) return null;
@@ -119,24 +110,6 @@ const BotSessionMonitor = forwardRef<
}
}, [botId]);
// Load active takeover sessions to know which ones show a badge
const loadTakeoverStatus = useCallback(async () => {
try {
const response = await httpClient.getHumanTakeoverSessions({
botUuid: botId,
});
const activeIds = new Set<string>();
for (const session of response.sessions ?? []) {
if (session.status === 'active') {
activeIds.add(session.session_id);
}
}
setTakenOverSessions(activeIds);
} catch {
// Silently ignore — takeover feature may not be available
}
}, [botId]);
useImperativeHandle(
ref,
() => ({
@@ -161,130 +134,28 @@ const BotSessionMonitor = forwardRef<
}
}, []);
// Check takeover status for selected session
const checkTakeoverStatus = useCallback(
async (sessionId: string) => {
try {
const response =
await httpClient.getHumanTakeoverSessionDetail(sessionId);
const isActive =
response.found && response.session?.status === 'active';
setIsTakenOver(isActive);
} catch {
setIsTakenOver(false);
}
},
[],
);
useEffect(() => {
loadSessions();
loadTakeoverStatus();
}, [loadSessions, loadTakeoverStatus]);
}, [loadSessions]);
useEffect(() => {
if (selectedSessionId) {
loadMessages(selectedSessionId);
checkTakeoverStatus(selectedSessionId);
} else {
setMessages([]);
setIsTakenOver(false);
}
}, [selectedSessionId, loadMessages, checkTakeoverStatus]);
// Auto-refresh messages when session is taken over (polling)
useEffect(() => {
if (!selectedSessionId || !isTakenOver) return;
const interval = setInterval(() => {
loadMessages(selectedSessionId);
}, 3000);
return () => clearInterval(interval);
}, [selectedSessionId, isTakenOver, loadMessages]);
}, [selectedSessionId, loadMessages]);
useEffect(() => {
if (messages.length === 0) return;
// Wait for DOM to render the new messages before scrolling
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) {
const viewport = container.querySelector(
'[data-radix-scroll-area-viewport]',
);
const scrollTarget = viewport || container;
scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
});
}, [messages]);
const handleTakeover = async () => {
if (!selectedSessionId || !selectedSession) return;
if (!confirm(t('bots.sessionMonitor.takeoverConfirm'))) return;
setTakeoverLoading(true);
try {
await httpClient.takeoverSession(selectedSessionId, {
bot_uuid: botId,
platform: selectedSession.platform ?? undefined,
user_id: selectedSession.user_id ?? undefined,
user_name: selectedSession.user_name ?? undefined,
});
setIsTakenOver(true);
setTakenOverSessions((prev) => new Set(prev).add(selectedSessionId));
} catch (error) {
console.error('Takeover failed:', error);
alert(t('bots.sessionMonitor.takeoverFailed'));
} finally {
setTakeoverLoading(false);
}
};
const handleRelease = async () => {
if (!selectedSessionId) return;
if (!confirm(t('bots.sessionMonitor.releaseConfirm'))) return;
setTakeoverLoading(true);
try {
await httpClient.releaseSession(selectedSessionId);
setIsTakenOver(false);
setTakenOverSessions((prev) => {
const next = new Set(prev);
next.delete(selectedSessionId);
return next;
});
} catch (error) {
console.error('Release failed:', error);
alert(t('bots.sessionMonitor.releaseFailed'));
} finally {
setTakeoverLoading(false);
}
};
const handleSendMessage = async () => {
if (!selectedSessionId || !operatorMessage.trim()) return;
setSendingMessage(true);
try {
await httpClient.sendTakeoverMessage(
selectedSessionId,
operatorMessage.trim(),
const container = messagesContainerRef.current;
if (container) {
const viewport = container.querySelector(
'[data-radix-scroll-area-viewport]',
);
setOperatorMessage('');
// Reload messages to show the sent one
await loadMessages(selectedSessionId);
} catch (error) {
console.error('Send message failed:', error);
alert(t('bots.sessionMonitor.sendFailed'));
} finally {
setSendingMessage(false);
const scrollTarget = viewport || container;
scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
};
const handleMessageKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
}, [messages]);
const parseMessageChain = (content: string): MessageChainComponent[] => {
try {
@@ -299,16 +170,11 @@ const BotSessionMonitor = forwardRef<
};
const isUserMessage = (msg: SessionMessage): boolean => {
if (msg.role === 'operator') return false;
if (msg.role === 'assistant') return false;
if (msg.role === 'user') return true;
return !msg.runner_name;
};
const isOperatorMessage = (msg: SessionMessage): boolean => {
return msg.role === 'operator';
};
const renderMessageComponent = (
component: MessageChainComponent,
index: number,
@@ -374,7 +240,7 @@ const BotSessionMonitor = forwardRef<
key={index}
className="inline-flex items-center gap-1 text-muted-foreground text-xs"
>
[Voice]
🎙 [Voice]
</span>
);
}
@@ -408,7 +274,7 @@ const BotSessionMonitor = forwardRef<
const file = component as MessageChainComponent & { name?: string };
return (
<span key={index} className="text-muted-foreground text-xs">
[{file.name || 'File'}]
📎 {file.name || 'File'}
</span>
);
}
@@ -468,22 +334,6 @@ const BotSessionMonitor = forwardRef<
(s) => s.session_id === selectedSessionId,
);
const getMessageRoleLabel = (msg: SessionMessage): string => {
if (isOperatorMessage(msg)) {
return t('bots.sessionMonitor.operatorMessage', {
defaultValue: 'Operator',
});
}
if (isUserMessage(msg)) {
return t('bots.sessionMonitor.userMessage', {
defaultValue: 'User',
});
}
return t('bots.sessionMonitor.botMessage', {
defaultValue: 'Assistant',
});
};
return (
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
{/* Left Panel: Session List */}
@@ -502,9 +352,6 @@ const BotSessionMonitor = forwardRef<
<div className="p-1.5">
{sessions.map((session) => {
const isSelected = selectedSessionId === session.session_id;
const sessionTakenOver = takenOverSessions.has(
session.session_id,
);
return (
<button
key={session.session_id}
@@ -541,16 +388,12 @@ const BotSessionMonitor = forwardRef<
{abbreviateId(session.user_id)}
</span>
)}
{sessionTakenOver && (
<span className="flex items-center gap-0.5 text-orange-600 dark:text-orange-400">
<UserCheck className="w-3 h-3" />
</span>
)}
{session.is_active && !sessionTakenOver && (
{session.is_active && (
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
</span>
)}
<span className="truncate">{session.pipeline_name}</span>
</div>
</button>
);
@@ -570,92 +413,56 @@ const BotSessionMonitor = forwardRef<
<>
{/* Chat Header */}
<div className="px-4 py-2.5 border-b shrink-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{selectedSession?.user_name ||
selectedSession?.user_id ||
selectedSessionId.slice(0, 20)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)}
{selectedSession?.platform && (
<>
{parseSessionType(selectedSessionId) && <span>·</span>}
<span>{selectedSession.platform}</span>
</>
)}
{selectedSession?.user_id && (
<>
<span>·</span>
<span className="font-mono">
{selectedSession.user_id}
</span>
<button
type="button"
onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')}
>
{copiedUserId ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</>
)}
{isTakenOver ? (
<>
<span>·</span>
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400">
<UserCheck className="w-3 h-3" />
{t('bots.sessionMonitor.takenOver', {
defaultValue: 'Taken Over',
})}
</span>
</>
) : (
selectedSession?.is_active && (
<>
<span>·</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
Active
</span>
</>
)
)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{selectedSession?.user_name ||
selectedSession?.user_id ||
selectedSessionId.slice(0, 20)}
</div>
{/* Takeover / Release button */}
<div className="flex-shrink-0">
{isTakenOver ? (
<button
type="button"
onClick={handleRelease}
disabled={takeoverLoading}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50 transition-colors disabled:opacity-50"
>
<UserCheck className="w-3.5 h-3.5" />
{t('bots.sessionMonitor.releaseBtn', {
defaultValue: 'Release',
})}
</button>
) : (
<button
type="button"
onClick={handleTakeover}
disabled={takeoverLoading}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50"
>
<UserCheck className="w-3.5 h-3.5" />
{t('bots.sessionMonitor.takeoverBtn', {
defaultValue: 'Take Over',
})}
</button>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)}
{selectedSession?.platform && (
<>
{parseSessionType(selectedSessionId) && <span>·</span>}
<span>{selectedSession.platform}</span>
</>
)}
{selectedSession?.user_id && (
<>
<span>·</span>
<span className="font-mono">
{selectedSession.user_id}
</span>
<button
type="button"
onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')}
>
{copiedUserId ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</>
)}
{selectedSession?.pipeline_name && (
<>
<span>·</span>
<span>{selectedSession.pipeline_name}</span>
</>
)}
{selectedSession?.is_active && (
<>
<span>·</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
Active
</span>
</>
)}
</div>
</div>
@@ -678,10 +485,6 @@ const BotSessionMonitor = forwardRef<
) : (
messages.map((msg) => {
const isUser = isUserMessage(msg);
const isOperator = isOperatorMessage(msg);
const isDiscarded =
msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD;
return (
<div
key={msg.id}
@@ -695,59 +498,34 @@ const BotSessionMonitor = forwardRef<
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
isUser
? 'bg-primary/10 rounded-br-sm'
: isOperator
? 'bg-orange-100/80 dark:bg-orange-900/30 rounded-bl-sm'
: 'bg-muted rounded-bl-sm',
: 'bg-muted rounded-bl-sm',
msg.status === 'error' && 'ring-1 ring-red-400/50',
isDiscarded && 'opacity-60',
)}
>
{renderMessageContent(msg)}
{/* Role label + pipeline + timestamp */}
{/* Role label + timestamp */}
<div
className={cn(
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
)}
>
<span
className={cn(
isOperator &&
'text-orange-600 dark:text-orange-400 font-medium',
)}
>
{getMessageRoleLabel(msg)}
<span>
{isUser
? t('bots.sessionMonitor.userMessage', {
defaultValue: 'User',
})
: t('bots.sessionMonitor.botMessage', {
defaultValue: 'Assistant',
})}
</span>
<span className="tabular-nums">
{formatTime(msg.timestamp)}
</span>
{isDiscarded ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<Ban className="w-3 h-3" />
{t('bots.sessionMonitor.discarded', {
defaultValue: 'Discarded',
})}
</span>
) : msg.pipeline_name &&
msg.pipeline_name !== 'Human Takeover' ? (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Workflow className="w-3 h-3" />
{msg.pipeline_name}
</span>
) : null}
{isOperator && (
<span className="inline-flex items-center gap-0.5 text-orange-600/70 dark:text-orange-400/70">
<UserCheck className="w-3 h-3" />
{t('bots.sessionMonitor.humanTakeover', {
defaultValue: 'Human Takeover',
})}
</span>
)}
{msg.status === 'error' && (
<span className="text-red-500">error</span>
)}
{msg.runner_name && (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Bot className="w-3 h-3" />
<span className="opacity-70">
{msg.runner_name}
</span>
)}
@@ -759,33 +537,6 @@ const BotSessionMonitor = forwardRef<
)}
</div>
</ScrollArea>
{/* Operator Message Input (only shown when session is taken over) */}
{isTakenOver && (
<div className="px-4 py-3 border-t shrink-0">
<div className="flex items-center gap-2">
<input
type="text"
value={operatorMessage}
onChange={(e) => setOperatorMessage(e.target.value)}
onKeyDown={handleMessageKeyDown}
placeholder={t('bots.sessionMonitor.sendMessage', {
defaultValue: 'Send message as operator...',
})}
disabled={sendingMessage}
className="flex-1 h-9 px-3 rounded-md border bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
/>
<button
type="button"
onClick={handleSendMessage}
disabled={sendingMessage || !operatorMessage.trim()}
className="inline-flex items-center justify-center h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:pointer-events-none"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
)}
</>
)}
</div>
+4 -2
View File
@@ -1,10 +1,12 @@
import { useSearchParams } from 'react-router-dom';
'use client';
import { useSearchParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import BotDetailContent from './BotDetailContent';
export default function BotConfigPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const searchParams = useSearchParams();
const detailId = searchParams.get('id');
if (detailId) {
@@ -1,3 +1,5 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
@@ -1,9 +1,11 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Check, Trash2, Plus } from 'lucide-react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import {
Dialog,
DialogContent,
@@ -65,10 +67,9 @@ export default function ApiIntegrationDialog({
onOpenChange,
}: ApiIntegrationDialogProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState('apikeys');
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
@@ -93,9 +94,7 @@ export default function ApiIntegrationDialog({
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showApiIntegrationSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}
}, [open]);
@@ -109,7 +108,7 @@ export default function ApiIntegrationDialog({
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
router.replace(newUrl, { scroll: false });
}
onOpenChange(newOpen);
};
@@ -249,9 +249,6 @@ export default function DynamicFormComponent({
case 'bot-selector':
fieldSchema = z.string();
break;
case 'tools-selector':
fieldSchema = z.array(z.string());
break;
case 'model-fallback-selector':
fieldSchema = z.object({
primary: z.string(),
@@ -23,7 +23,6 @@ import {
Bot,
KnowledgeBase,
EmbeddingModel,
PluginTool,
} from '@/app/infra/entities/api';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -76,14 +75,9 @@ export default function DynamicFormItemComponent({
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [bots, setBots] = useState<Bot[]>([]);
const [tools, setTools] = useState<PluginTool[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
const [tempSelectedToolNames, setTempSelectedToolNames] = useState<string[]>(
[],
);
const { t } = useTranslation();
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
@@ -215,21 +209,6 @@ export default function DynamicFormItemComponent({
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.TOOLS_SELECTOR) {
httpClient
.getTools()
.then((resp) => {
setTools(resp.tools);
})
.catch((err) => {
toast.error(
t('tools.getToolListError', 'Failed to get tools: ') + err.msg,
);
});
}
}, [config.type]);
switch (config.type) {
case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT:
@@ -1182,139 +1161,6 @@ export default function DynamicFormItemComponent({
</Select>
);
case DynamicFormItemType.TOOLS_SELECTOR:
return (
<>
<div className="space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
{field.value.map((toolName: string) => {
const currentTool = tools.find(
(tool) => tool.name === toolName,
);
return (
<div
key={toolName}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex items-center gap-2 flex-1">
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-medium">{toolName}</div>
{currentTool?.human_desc && (
<div className="text-sm text-muted-foreground truncate">
{currentTool.human_desc}
</div>
)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newValue = field.value.filter(
(name: string) => name !== toolName,
);
field.onChange(newValue);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
) : (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('tools.noToolSelected', 'No tools selected')}
</p>
</div>
)}
</div>
<Button
type="button"
onClick={() => {
setTempSelectedToolNames(field.value || []);
setToolsDialogOpen(true);
}}
variant="outline"
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
{t('tools.addTool', 'Add Tool')}
</Button>
<Dialog open={toolsDialogOpen} onOpenChange={setToolsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{t('tools.selectTools', 'Select Tools')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{tools.map((tool) => {
const isSelected = tempSelectedToolNames.includes(tool.name);
return (
<div
key={tool.name}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
setTempSelectedToolNames((prev) =>
prev.includes(tool.name)
? prev.filter((name) => name !== tool.name)
: [...prev, tool.name],
);
}}
>
<Checkbox
checked={isSelected}
aria-label={`Select ${tool.name}`}
/>
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium">{tool.name}</div>
{tool.human_desc && (
<div className="text-sm text-muted-foreground">
{tool.human_desc}
</div>
)}
</div>
</div>
);
})}
{tools.length === 0 && (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('tools.noToolsAvailable', 'No tools available')}
</p>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setToolsDialogOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
field.onChange(tempSelectedToolNames);
setToolsDialogOpen(false);
}}
>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
case DynamicFormItemType.PROMPT_EDITOR: {
// Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default
@@ -1,6 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
@@ -27,7 +29,7 @@ import {
Github,
Zap,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
import { useTheme } from 'next-themes';
import {
DropdownMenu,
@@ -242,10 +244,9 @@ function NavItems({
sectionOpenState: Record<string, boolean>;
onSectionToggle: (id: string, open: boolean) => void;
}) {
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const sidebarData = useSidebarData();
const { setPendingPluginInstallAction } = sidebarData;
const { state: sidebarState, isMobile } = useSidebar();
@@ -412,7 +413,7 @@ function NavItems({
'bg-accent text-accent-foreground font-medium',
)}
onClick={() => {
navigate(itemRoute);
router.push(itemRoute);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -470,7 +471,7 @@ function NavItems({
)}
onClick={(e) => {
e.preventDefault();
navigate(itemRoute);
router.push(itemRoute);
}}
>
{item.emoji ? (
@@ -622,7 +623,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/market');
router.push('/home/market');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -637,7 +638,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
navigate('/home/plugins');
router.push('/home/plugins');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -651,7 +652,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/plugins');
router.push('/home/plugins');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -668,7 +669,7 @@ function NavItems({
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={() => {
navigate(`${routePrefix}?id=new`);
router.push(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -730,7 +731,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/market');
router.push('/home/market');
}}
>
<Store className="size-4" />
@@ -741,7 +742,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
navigate('/home/plugins');
router.push('/home/plugins');
}}
>
<Upload className="size-4" />
@@ -751,7 +752,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/plugins');
router.push('/home/plugins');
}}
>
<Github className="size-4" />
@@ -765,7 +766,7 @@ function NavItems({
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"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
router.push(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
@@ -1028,10 +1029,9 @@ export default function HomeSidebar({
}: {
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
}) {
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { isMobile } = useSidebar();
useEffect(() => {
@@ -1071,16 +1071,14 @@ export default function HomeSidebar({
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showModelSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
router.replace(newUrl, { scroll: false });
}
}
@@ -1089,16 +1087,14 @@ export default function HomeSidebar({
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showAccountSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
router.replace(newUrl, { scroll: false });
}
}
@@ -1169,7 +1165,7 @@ export default function HomeSidebar({
// User click: update state AND navigate
function handleChildClick(child: SidebarChildVO) {
selectChild(child);
navigate(child.route);
router.push(child.route);
}
function initSelect() {
@@ -1230,7 +1226,7 @@ export default function HomeSidebar({
tooltip="LangBot"
>
<img
src={langbotIcon}
src={langbotIcon.src}
alt="LangBot"
className="size-8 rounded-lg"
/>
@@ -1410,7 +1406,7 @@ export default function HomeSidebar({
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
navigate('/wizard');
router.push('/wizard');
}}
>
<Zap className="text-blue-500" />
@@ -1,3 +1,5 @@
'use client';
import React, {
createContext,
useContext,
@@ -1,3 +1,5 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, Boxes } from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
@@ -1,3 +1,5 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -1,3 +1,5 @@
'use client';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -1,3 +1,5 @@
'use client';
import { useState, useEffect } from 'react';
import { Trash2, Eye, Wrench, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -1,3 +1,5 @@
'use client';
import { useState } from 'react';
import {
Plus,
@@ -133,7 +135,7 @@ export default function ProviderCard({
{isLangBotModels ? (
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
<img
src={langbotIcon}
src={langbotIcon.src}
alt="LangBot"
className="w-full h-full object-cover"
/>
@@ -1,3 +1,5 @@
'use client';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -1,3 +1,5 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
@@ -1,3 +1,5 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import type {
@@ -1,5 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import {
@@ -30,7 +32,7 @@ import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
export default function KBDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const router = useRouter();
const { t } = useTranslation();
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
useSidebarData();
@@ -82,12 +84,12 @@ export default function KBDetailContent({ id }: { id: string }) {
function handleKbDeleted() {
refreshKnowledgeBases();
navigate('/home/knowledge');
router.push('/home/knowledge');
}
function handleNewKbCreated(newKbId: string) {
refreshKnowledgeBases();
navigate(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
}
function handleKbUpdated() {
@@ -1,3 +1,5 @@
'use client';
import { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -1,3 +1,5 @@
'use client';
import {
ColumnDef,
flexRender,
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -304,7 +304,7 @@ export default function KBForm({
{t('knowledge.noEnginesAvailable')}
</p>
<Link
to="/home/market?category=KnowledgeEngine"
href="/home/market?category=KnowledgeEngine"
className="text-sm text-primary hover:underline"
>
{t('knowledge.installEngineHint')}
@@ -1,3 +1,5 @@
'use client';
import { useState } from 'react';
import {
Dialog,
@@ -1,3 +1,5 @@
'use client';
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
+4 -2
View File
@@ -1,4 +1,6 @@
import { useSearchParams } from 'react-router-dom';
'use client';
import { useSearchParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -8,7 +10,7 @@ import KBDetailContent from './KBDetailContent';
export default function KnowledgePage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const searchParams = useSearchParams();
const detailId = searchParams.get('id');
const { refreshKnowledgeBases } = useSidebarData();
+9 -8
View File
@@ -1,3 +1,5 @@
'use client';
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
import React, {
@@ -19,8 +21,8 @@ import {
initializeUserInfo,
initializeSystemInfo,
} from '@/app/infra/http';
import { useNavigate, useLocation } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { usePathname, useRouter } from 'next/navigation';
import Link from 'next/link';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { CircleHelp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -57,7 +59,7 @@ export default function HomeLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const navigate = useNavigate();
const router = useRouter();
// Initialize user info if not already initialized
useEffect(() => {
@@ -73,14 +75,14 @@ export default function HomeLayout({
// Always re-fetch to ensure we have the latest wizard_status from backend
await initializeSystemInfo();
if (systemInfo.wizard_status === 'none') {
navigate('/wizard');
router.replace('/wizard');
}
} catch {
// If fetching system info fails, don't redirect
}
};
checkWizard();
}, [navigate]);
}, [router]);
return (
<SidebarDataProvider>
@@ -99,8 +101,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
zh_Hans: '',
});
const { detailEntityName } = useSidebarData();
const location = useLocation();
const pathname = location.pathname;
const pathname = usePathname();
const { t } = useTranslation();
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
@@ -138,7 +139,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink asChild>
<Link to={sectionLink}>{sectionLabel}</Link>
<Link href={sectionLink}>{sectionLabel}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
+2
View File
@@ -1,3 +1,5 @@
'use client';
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
import {
Dialog,
+6 -4
View File
@@ -1,5 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
@@ -28,7 +30,7 @@ import { toast } from 'sonner';
export default function MCPDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const router = useRouter();
const { t } = useTranslation();
const { refreshMCPServers, mcpServers, setDetailEntityName } =
useSidebarData();
@@ -94,12 +96,12 @@ export default function MCPDetailContent({ id }: { id: string }) {
function handleServerDeleted() {
refreshMCPServers();
navigate('/home/mcp');
router.push('/home/mcp');
}
function handleNewServerCreated(serverName: string) {
refreshMCPServers();
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
router.push(`/home/mcp?id=${encodeURIComponent(serverName)}`);
}
function confirmDelete() {
@@ -1,3 +1,5 @@
'use client';
import React, {
useState,
useEffect,
+4 -2
View File
@@ -1,10 +1,12 @@
import { useSearchParams } from 'react-router-dom';
'use client';
import { useSearchParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import MCPDetailContent from './MCPDetailContent';
export default function MCPPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const searchParams = useSearchParams();
const detailId = searchParams.get('id');
if (detailId) {
@@ -1,3 +1,5 @@
'use client';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -1,3 +1,5 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -1,3 +1,5 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -1,3 +1,5 @@
'use client';
import React, { useState } from 'react';
import {
MessageChainComponent,
@@ -1,3 +1,5 @@
'use client';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { MessageDetails } from '../types/monitoring';
@@ -1,3 +1,5 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -1,3 +1,5 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
@@ -1,3 +1,5 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import MetricCard from './MetricCard';
@@ -1,3 +1,5 @@
'use client';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'next/navigation';
import { FilterState, TimeRangeOption, DateRange } from '../types/monitoring';
import { getPresetDateRange } from '../utils/dateUtils';
@@ -7,7 +7,7 @@ import { getPresetDateRange } from '../utils/dateUtils';
* Custom hook for managing monitoring filters
*/
export function useMonitoringFilters() {
const [searchParams] = useSearchParams();
const searchParams = useSearchParams();
// Initialize filters from URL params
const [selectedBots, setSelectedBots] = useState<string[]>(() => {
+2
View File
@@ -1,3 +1,5 @@
'use client';
import React, { Suspense, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -1,5 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
@@ -11,7 +13,7 @@ import { Settings, Bug, BarChart3 } from 'lucide-react';
export default function PipelineDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const router = useRouter();
const { t } = useTranslation();
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
@@ -36,7 +38,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
function handleNewPipelineCreated(newPipelineId: string) {
refreshPipelines();
navigate(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
router.push(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
}
// ==================== Create Mode ====================
@@ -71,7 +73,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
function handleDeletePipeline() {
refreshPipelines();
navigate('/home/pipelines');
router.push('/home/pipelines');
}
// ==================== Edit Mode ====================
@@ -127,7 +129,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={handleDeletePipeline}
onCancel={() => navigate('/home/pipelines')}
onCancel={() => router.push('/home/pipelines')}
onDirtyChange={setFormDirty}
/>
</TabsContent>
@@ -150,7 +152,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
navigate('/home/monitoring');
router.push('/home/monitoring');
}}
/>
</TabsContent>
@@ -1,3 +1,5 @@
'use client';
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -1,3 +1,5 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { backendClient } from '@/app/infra/http';
+4 -2
View File
@@ -1,10 +1,12 @@
import { useSearchParams } from 'react-router-dom';
'use client';
import { useSearchParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import PipelineDetailContent from './PipelineDetailContent';
export default function PipelineConfigPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const searchParams = useSearchParams();
const detailId = searchParams.get('id');
if (detailId) {
@@ -1,3 +1,5 @@
'use client';
import { useEffect } from 'react';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
@@ -1,3 +1,5 @@
'use client';
import React from 'react';
import {
Dialog,
@@ -1,3 +1,5 @@
'use client';
import React, {
createContext,
useContext,
@@ -1,3 +1,5 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Progress } from '@/components/ui/progress';
@@ -1,5 +1,7 @@
'use client';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRouter } from 'next/navigation';
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
import styles from '@/app/home/plugins/plugins.module.css';
@@ -31,10 +33,11 @@ enum PluginOperationType {
UPDATE = 'UPDATE',
}
// eslint-disable-next-line react/display-name
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
(props, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const router = useRouter();
const { refreshPlugins } = useSidebarData();
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
const [showOperationModal, setShowOperationModal] = useState(false);
@@ -160,7 +163,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
function handlePluginClick(plugin: PluginCardVO) {
const pluginId = `${plugin.author}/${plugin.name}`;
navigate(`/home/plugins?id=${encodeURIComponent(pluginId)}`);
router.push(`/home/plugins?id=${encodeURIComponent(pluginId)}`);
}
function handlePluginDelete(plugin: PluginCardVO) {
@@ -1,5 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'next/navigation';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -45,7 +47,7 @@ function MarketPageContent({
installPlugin: (plugin: PluginV4) => void;
}) {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const searchParams = useSearchParams();
const validCategories = [
'Tool',
@@ -1,3 +1,5 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -1,3 +1,5 @@
'use client';
import { useTranslation } from 'react-i18next';
import {
Select,
@@ -1,3 +1,5 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent';
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
@@ -1,3 +1,5 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -1,3 +1,5 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Resolver, useForm } from 'react-hook-form';
+5 -4
View File
@@ -1,3 +1,4 @@
'use client';
import PluginInstalledComponent, {
PluginInstalledComponentRef,
} from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent';
@@ -44,7 +45,7 @@ import {
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSearchParams, useRouter } from 'next/navigation';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -83,7 +84,7 @@ interface GithubAsset {
}
export default function PluginConfigPage() {
const [searchParams] = useSearchParams();
const searchParams = useSearchParams();
const detailId = searchParams.get('id');
// Show plugin detail view when ?id= query param is present
@@ -96,7 +97,7 @@ export default function PluginConfigPage() {
function PluginListView() {
const { t } = useTranslation();
const navigate = useNavigate();
const router = useRouter();
const {
refreshPlugins,
pendingPluginInstallAction,
@@ -671,7 +672,7 @@ function PluginListView() {
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={() => {
navigate('/home/market');
router.push('/home/market');
}}
>
<StoreIcon className="w-4 h-4" />
+1 -20
View File
@@ -155,11 +155,7 @@ export type RoutingRuleOperator =
| 'regex';
export interface PipelineRoutingRule {
type:
| 'launcher_type'
| 'launcher_id'
| 'message_content'
| 'message_has_element';
type: 'launcher_type' | 'launcher_id' | 'message_content';
operator: RoutingRuleOperator;
value: string;
pipeline_uuid: string;
@@ -485,18 +481,3 @@ export interface MCPTool {
description: string;
parameters?: object;
}
export interface PluginTool {
name: string;
description: string;
human_desc: string;
parameters: object;
}
export interface ApiRespTools {
tools: PluginTool[];
}
export interface ApiRespToolDetail {
tool: PluginTool;
}
@@ -42,7 +42,6 @@ export enum DynamicFormItemType {
KNOWLEDGE_BASE_MULTI_SELECTOR = 'knowledge-base-multi-selector',
PLUGIN_SELECTOR = 'plugin-selector',
BOT_SELECTOR = 'bot-selector',
TOOLS_SELECTOR = 'tools-selector',
WEBHOOK_URL = 'webhook-url',
}

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