mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-18 11:44:18 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3d366b569 | |||
| db68c5d0c9 | |||
| 8f1317b39e | |||
| 77a0de5ef0 | |||
| 875227a2fe | |||
| 2317392ee5 | |||
| c7efa4dd7f | |||
| e701daa8e0 | |||
| 1ae99199b2 | |||
| 7c067a1cb3 | |||
| 478bc62576 | |||
| a740eb8ee9 |
@@ -43,10 +43,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd /tmp/langbot_build_web/web
|
cd /tmp/langbot_build_web/web
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npx vite build
|
||||||
- name: Package Output
|
- name: Package Output
|
||||||
run: |
|
run: |
|
||||||
cp -r /tmp/langbot_build_web/web/out ./web
|
cp -r /tmp/langbot_build_web/web/dist ./web
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ jobs:
|
|||||||
npm install -g pnpm
|
npm install -g pnpm
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
mkdir -p ../src/langbot/web/out
|
mkdir -p ../src/langbot/web/dist
|
||||||
cp -r out ../src/langbot/web/
|
cp -r dist ../src/langbot/web/
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|||||||
@@ -52,3 +52,6 @@ src/langbot/web/
|
|||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
|
# Next.js build cache (legacy)
|
||||||
|
web/.next/
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
|
|
||||||
RUN cd web && npm install && npm run build
|
RUN cd web && npm install && npx vite build
|
||||||
|
|
||||||
FROM python:3.12.7-slim
|
FROM python:3.12.7-slim
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
COPY --from=node /app/web/out ./web/out
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
RUN apt update \
|
RUN apt update \
|
||||||
&& apt install gcc -y \
|
&& apt install gcc -y \
|
||||||
|
|||||||
+2
-2
@@ -64,7 +64,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.6",
|
"langbot-plugin==0.3.7",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
@@ -111,7 +111,7 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**"] }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -434,10 +434,10 @@ async def parse_wecom_bot_message(
|
|||||||
}
|
}
|
||||||
if voice_info.get('content'):
|
if voice_info.get('content'):
|
||||||
message_data['content'] = voice_info.get('content')
|
message_data['content'] = voice_info.get('content')
|
||||||
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
# if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
||||||
voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
# voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
if voice_base64:
|
# if voice_base64:
|
||||||
message_data['voice']['base64'] = voice_base64
|
# message_data['voice']['base64'] = voice_base64
|
||||||
elif msg_type == 'video':
|
elif msg_type == 'video':
|
||||||
video_info = msg_json.get('video', {}) or {}
|
video_info = msg_json.get('video', {}) or {}
|
||||||
download_url = video_info.get('url')
|
download_url = video_info.get('url')
|
||||||
@@ -449,10 +449,12 @@ async def parse_wecom_bot_message(
|
|||||||
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
'filename': video_info.get('filename') or video_info.get('name'),
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
}
|
}
|
||||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
# if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
# video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
if video_base64:
|
# if video_base64:
|
||||||
video_data['base64'] = video_base64
|
# video_data['base64'] = video_base64
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
message_data['video'] = video_data
|
message_data['video'] = video_data
|
||||||
elif msg_type == 'file':
|
elif msg_type == 'file':
|
||||||
file_info = msg_json.get('file', {}) or {}
|
file_info = msg_json.get('file', {}) or {}
|
||||||
@@ -466,12 +468,15 @@ async def parse_wecom_bot_message(
|
|||||||
'download_url': download_url,
|
'download_url': download_url,
|
||||||
'extra': file_info,
|
'extra': file_info,
|
||||||
}
|
}
|
||||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
# if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
# file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
||||||
if file_bytes:
|
# if file_bytes:
|
||||||
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
# file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||||
if dl_filename and not file_data.get('filename'):
|
# if dl_filename and not file_data.get('filename'):
|
||||||
file_data['filename'] = dl_filename
|
# file_data['filename'] = dl_filename
|
||||||
|
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
message_data['file'] = file_data
|
message_data['file'] = file_data
|
||||||
elif msg_type == 'link':
|
elif msg_type == 'link':
|
||||||
message_data['link'] = msg_json.get('link', {})
|
message_data['link'] = msg_json.get('link', {})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any, Callable, Optional
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message
|
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||||
@@ -96,6 +96,12 @@ class WecomBotWsClient:
|
|||||||
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
||||||
# Dedup: skip sending when content hasn't changed
|
# Dedup: skip sending when content hasn't changed
|
||||||
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
||||||
|
# Stream session info for feedback tracking
|
||||||
|
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
|
||||||
|
# Feedback tracking: feedback_id -> session info
|
||||||
|
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
|
||||||
|
# msg_id -> feedback_id (for associating feedback with message)
|
||||||
|
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
|
||||||
|
|
||||||
# ── Public API ──────────────────────────────────────────────────
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -164,12 +170,27 @@ class WecomBotWsClient:
|
|||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def on_feedback(self) -> Callable:
|
||||||
|
"""Decorator to register a feedback event handler.
|
||||||
|
|
||||||
|
Same interface as WecomBotClient.on_feedback for compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable):
|
||||||
|
if 'feedback' not in self._message_handlers:
|
||||||
|
self._message_handlers['feedback'] = []
|
||||||
|
self._message_handlers['feedback'].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
async def reply_stream(
|
async def reply_stream(
|
||||||
self,
|
self,
|
||||||
req_id: str,
|
req_id: str,
|
||||||
stream_id: str,
|
stream_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
finish: bool = False,
|
finish: bool = False,
|
||||||
|
feedback_id: str = '',
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""Send a streaming reply frame.
|
"""Send a streaming reply frame.
|
||||||
|
|
||||||
@@ -178,17 +199,22 @@ class WecomBotWsClient:
|
|||||||
stream_id: The stream ID for this streaming session.
|
stream_id: The stream ID for this streaming session.
|
||||||
content: The content to send (supports Markdown).
|
content: The content to send (supports Markdown).
|
||||||
finish: Whether this is the final chunk.
|
finish: Whether this is the final chunk.
|
||||||
|
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The ACK frame dict, or None on failure.
|
The ACK frame dict, or None on failure.
|
||||||
"""
|
"""
|
||||||
|
stream_payload = {
|
||||||
|
'id': stream_id,
|
||||||
|
'finish': finish,
|
||||||
|
'content': content,
|
||||||
|
}
|
||||||
|
if feedback_id:
|
||||||
|
stream_payload['feedback'] = {'id': feedback_id}
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
'msgtype': 'stream',
|
'msgtype': 'stream',
|
||||||
'stream': {
|
'stream': stream_payload,
|
||||||
'id': stream_id,
|
|
||||||
'finish': finish,
|
|
||||||
'content': content,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return await self._send_reply(req_id, body)
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
@@ -253,11 +279,23 @@ class WecomBotWsClient:
|
|||||||
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
||||||
if not is_final and content == self._stream_last_content.get(msg_id):
|
if not is_final and content == self._stream_last_content.get(msg_id):
|
||||||
return True
|
return True
|
||||||
await self.reply_stream(req_id, stream_id, content, finish=is_final)
|
|
||||||
|
# Generate feedback_id for final chunk
|
||||||
|
feedback_id = ''
|
||||||
|
if is_final:
|
||||||
|
feedback_id = _generate_req_id('feedback')
|
||||||
|
self._msg_feedback_ids[msg_id] = feedback_id
|
||||||
|
# Store session info for feedback tracking
|
||||||
|
session_info = self._stream_sessions.get(msg_id)
|
||||||
|
if session_info:
|
||||||
|
self._feedback_sessions[feedback_id] = session_info
|
||||||
|
|
||||||
|
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
|
||||||
self._stream_last_content[msg_id] = content
|
self._stream_last_content[msg_id] = content
|
||||||
if is_final:
|
if is_final:
|
||||||
self._stream_ids.pop(msg_id, None)
|
self._stream_ids.pop(msg_id, None)
|
||||||
self._stream_last_content.pop(msg_id, None)
|
self._stream_last_content.pop(msg_id, None)
|
||||||
|
self._stream_sessions.pop(msg_id, None)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
||||||
@@ -445,6 +483,15 @@ class WecomBotWsClient:
|
|||||||
msg_id = message_data.get('msgid', '')
|
msg_id = message_data.get('msgid', '')
|
||||||
if msg_id:
|
if msg_id:
|
||||||
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
||||||
|
# Store session info for feedback tracking
|
||||||
|
self._stream_sessions[msg_id] = {
|
||||||
|
'req_id': req_id,
|
||||||
|
'stream_id': stream_id,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'user_id': message_data.get('userid', ''),
|
||||||
|
'chat_id': message_data.get('chatid', ''),
|
||||||
|
'chat_type': message_data.get('type', 'single'),
|
||||||
|
}
|
||||||
message_data['stream_id'] = stream_id
|
message_data['stream_id'] = stream_id
|
||||||
message_data['req_id'] = req_id
|
message_data['req_id'] = req_id
|
||||||
|
|
||||||
@@ -454,7 +501,7 @@ class WecomBotWsClient:
|
|||||||
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _handle_event_callback(self, frame: dict):
|
async def _handle_event_callback(self, frame: dict):
|
||||||
"""Handle an incoming event callback frame (enter_chat, template_card_event, etc.)."""
|
"""Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event)."""
|
||||||
try:
|
try:
|
||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
@@ -479,14 +526,54 @@ class WecomBotWsClient:
|
|||||||
if body.get('chatid'):
|
if body.get('chatid'):
|
||||||
message_data['chatid'] = body.get('chatid', '')
|
message_data['chatid'] = body.get('chatid', '')
|
||||||
|
|
||||||
|
if event_type == 'feedback_event':
|
||||||
|
feedback_event = event_info.get('feedback_event', {})
|
||||||
|
feedback_id = feedback_event.get('id', '')
|
||||||
|
feedback_type = feedback_event.get('type', 0)
|
||||||
|
feedback_content = feedback_event.get('content', '')
|
||||||
|
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
||||||
|
|
||||||
|
await self.logger.info(
|
||||||
|
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
|
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up session by feedback_id
|
||||||
|
session_info = self._feedback_sessions.get(feedback_id)
|
||||||
|
session = None
|
||||||
|
if session_info:
|
||||||
|
session = StreamSession(
|
||||||
|
stream_id=session_info.get('stream_id', ''),
|
||||||
|
msg_id=session_info.get('msg_id', ''),
|
||||||
|
chat_id=session_info.get('chat_id') or None,
|
||||||
|
user_id=session_info.get('user_id') or None,
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
)
|
||||||
|
await self.logger.info(
|
||||||
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
|
||||||
|
|
||||||
|
for handler in self._message_handlers.get('feedback', []):
|
||||||
|
try:
|
||||||
|
await handler(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
|
||||||
# Dispatch to event-specific handlers
|
|
||||||
if event_type in self._message_handlers:
|
if event_type in self._message_handlers:
|
||||||
for handler in self._message_handlers[event_type]:
|
for handler in self._message_handlers[event_type]:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
||||||
# Also dispatch to generic 'event' handlers
|
|
||||||
if 'event' in self._message_handlers:
|
if 'event' in self._message_handlers:
|
||||||
for handler in self._message_handlers['event']:
|
for handler in self._message_handlers['event']:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from .. import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('human-takeover', '/api/v1/human-takeover')
|
||||||
|
class HumanTakeoverRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def get_sessions():
|
||||||
|
"""Get list of takeover sessions, optionally filtered by bot UUID."""
|
||||||
|
bot_uuid = quart.request.args.get('botUuid')
|
||||||
|
limit = int(quart.request.args.get('limit', 100))
|
||||||
|
offset = int(quart.request.args.get('offset', 0))
|
||||||
|
|
||||||
|
sessions, total = await self.ap.human_takeover_service.get_active_sessions(
|
||||||
|
bot_uuid=bot_uuid if bot_uuid else None,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'sessions': sessions,
|
||||||
|
'total': total,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/sessions/<session_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def get_session_detail(session_id: str):
|
||||||
|
"""Get detail for a specific takeover session."""
|
||||||
|
detail = await self.ap.human_takeover_service.get_session_detail(session_id)
|
||||||
|
if not detail:
|
||||||
|
return self.success(data={'found': False, 'session_id': session_id})
|
||||||
|
return self.success(data={'found': True, 'session': detail})
|
||||||
|
|
||||||
|
@self.route('/sessions/<session_id>/takeover', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def takeover_session(session_id: str, user_email: str = None):
|
||||||
|
"""Take over a conversation session."""
|
||||||
|
data = await quart.request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
bot_uuid = data.get('bot_uuid')
|
||||||
|
if not bot_uuid:
|
||||||
|
return self.fail(-1, 'bot_uuid is required')
|
||||||
|
|
||||||
|
platform = data.get('platform')
|
||||||
|
user_id = data.get('user_id')
|
||||||
|
user_name = data.get('user_name')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.human_takeover_service.takeover_session(
|
||||||
|
session_id=session_id,
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
taken_by=user_email or data.get('taken_by'),
|
||||||
|
platform=platform,
|
||||||
|
user_id=user_id,
|
||||||
|
user_name=user_name,
|
||||||
|
)
|
||||||
|
return self.success(data=result)
|
||||||
|
except ValueError as e:
|
||||||
|
return self.fail(-1, str(e))
|
||||||
|
|
||||||
|
@self.route('/sessions/<session_id>/release', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def release_session(session_id: str):
|
||||||
|
"""Release a taken-over session back to AI pipeline."""
|
||||||
|
try:
|
||||||
|
result = await self.ap.human_takeover_service.release_session(session_id)
|
||||||
|
return self.success(data=result)
|
||||||
|
except ValueError as e:
|
||||||
|
return self.fail(-1, str(e))
|
||||||
|
|
||||||
|
@self.route('/sessions/<session_id>/message', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def send_message(session_id: str, user_email: str = None):
|
||||||
|
"""Send a message from the operator to the user."""
|
||||||
|
data = await quart.request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
message_text = data.get('message')
|
||||||
|
if not message_text:
|
||||||
|
return self.fail(-1, 'message is required')
|
||||||
|
|
||||||
|
operator_name = user_email or data.get('operator_name', 'Operator')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.human_takeover_service.send_message(
|
||||||
|
session_id=session_id,
|
||||||
|
message_text=message_text,
|
||||||
|
operator_name=operator_name,
|
||||||
|
)
|
||||||
|
return self.success(data=result)
|
||||||
|
except ValueError as e:
|
||||||
|
return self.fail(-1, str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
return self.fail(-2, str(e))
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('tools', '/api/v1/tools')
|
||||||
|
class ToolsRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""获取所有可用工具列表"""
|
||||||
|
tools = await self.ap.tool_mgr.get_all_tools()
|
||||||
|
|
||||||
|
tool_list = []
|
||||||
|
for tool in tools:
|
||||||
|
tool_list.append(
|
||||||
|
{
|
||||||
|
'name': tool.name,
|
||||||
|
'description': tool.description,
|
||||||
|
'human_desc': tool.human_desc,
|
||||||
|
'parameters': tool.parameters,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data={'tools': tool_list})
|
||||||
|
|
||||||
|
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(tool_name: str) -> str:
|
||||||
|
"""获取特定工具详情"""
|
||||||
|
tools = await self.ap.tool_mgr.get_all_tools()
|
||||||
|
|
||||||
|
for tool in tools:
|
||||||
|
if tool.name == tool_name:
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'tool': {
|
||||||
|
'name': tool.name,
|
||||||
|
'description': tool.description,
|
||||||
|
'human_desc': tool.human_desc,
|
||||||
|
'parameters': tool.parameters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.http_status(404, -1, f'Tool not found: {tool_name}')
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from ....core import app
|
||||||
|
from ....entity.persistence import human_takeover as persistence_human_takeover
|
||||||
|
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
|
||||||
|
class HumanTakeoverService:
|
||||||
|
"""Human takeover service.
|
||||||
|
|
||||||
|
Manages operator takeover of user conversation sessions, bypassing
|
||||||
|
the normal AI pipeline. Uses an in-memory cache for fast synchronous
|
||||||
|
lookups on the hot message path, backed by database persistence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ap: app.Application
|
||||||
|
|
||||||
|
# In-memory cache: session_id -> HumanTakeoverSession record id
|
||||||
|
# Only contains sessions with status='active'
|
||||||
|
_active_sessions: dict[str, str]
|
||||||
|
|
||||||
|
logger: logging.Logger
|
||||||
|
|
||||||
|
def __init__(self, ap: app.Application) -> None:
|
||||||
|
self.ap = ap
|
||||||
|
self._active_sessions = {}
|
||||||
|
self.logger = logging.getLogger('human-takeover')
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Load active takeover sessions from DB into memory cache."""
|
||||||
|
try:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).where(
|
||||||
|
persistence_human_takeover.HumanTakeoverSession.status == 'active'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
for row in rows:
|
||||||
|
session = row[0] if isinstance(row, tuple) else row
|
||||||
|
self._active_sessions[session.session_id] = session.id
|
||||||
|
self.logger.info(f'Loaded {len(self._active_sessions)} active takeover sessions from DB')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Failed to load active takeover sessions: {e}')
|
||||||
|
|
||||||
|
def is_taken_over(self, session_id: str) -> bool:
|
||||||
|
"""Check if a session is currently under human takeover.
|
||||||
|
|
||||||
|
This is a synchronous in-memory lookup for performance, since it
|
||||||
|
is called on every incoming message (hot path).
|
||||||
|
"""
|
||||||
|
return session_id in self._active_sessions
|
||||||
|
|
||||||
|
async def takeover_session(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
bot_uuid: str,
|
||||||
|
taken_by: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
user_name: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Take over a conversation session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The session to take over (e.g. 'person_123' or 'group_456').
|
||||||
|
bot_uuid: UUID of the bot whose session is being taken over.
|
||||||
|
taken_by: Email/username of the admin performing the takeover.
|
||||||
|
platform: Platform name.
|
||||||
|
user_id: The end-user's ID in the session.
|
||||||
|
user_name: The end-user's display name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with the created takeover session record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the session is already taken over.
|
||||||
|
"""
|
||||||
|
if self.is_taken_over(session_id):
|
||||||
|
raise ValueError(f'Session {session_id} is already taken over')
|
||||||
|
|
||||||
|
record_id = str(uuid.uuid4())
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
record_data = {
|
||||||
|
'id': record_id,
|
||||||
|
'session_id': session_id,
|
||||||
|
'bot_uuid': bot_uuid,
|
||||||
|
'status': 'active',
|
||||||
|
'taken_by': taken_by,
|
||||||
|
'taken_at': now,
|
||||||
|
'released_at': None,
|
||||||
|
'platform': platform,
|
||||||
|
'user_id': user_id,
|
||||||
|
'user_name': user_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(persistence_human_takeover.HumanTakeoverSession).values(record_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update in-memory cache
|
||||||
|
self._active_sessions[session_id] = record_id
|
||||||
|
|
||||||
|
self.logger.info(f'Session {session_id} taken over by {taken_by}')
|
||||||
|
|
||||||
|
return record_data
|
||||||
|
|
||||||
|
async def release_session(self, session_id: str) -> dict:
|
||||||
|
"""Release a taken-over session back to AI pipeline processing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The session to release.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with the updated takeover session record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the session is not currently taken over.
|
||||||
|
"""
|
||||||
|
if not self.is_taken_over(session_id):
|
||||||
|
raise ValueError(f'Session {session_id} is not currently taken over')
|
||||||
|
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_human_takeover.HumanTakeoverSession)
|
||||||
|
.where(
|
||||||
|
sqlalchemy.and_(
|
||||||
|
persistence_human_takeover.HumanTakeoverSession.session_id == session_id,
|
||||||
|
persistence_human_takeover.HumanTakeoverSession.status == 'active',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(status='released', released_at=now)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove from in-memory cache
|
||||||
|
self._active_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
self.logger.info(f'Session {session_id} released back to AI pipeline')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'session_id': session_id,
|
||||||
|
'status': 'released',
|
||||||
|
'released_at': now.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
message_text: str,
|
||||||
|
operator_name: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Send a message from the operator to the user via the platform adapter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The taken-over session ID (e.g. 'person_123' or 'group_456').
|
||||||
|
message_text: The text message to send.
|
||||||
|
operator_name: Name of the operator sending the message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with send result info.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the session is not currently taken over.
|
||||||
|
RuntimeError: If the bot or adapter cannot be found.
|
||||||
|
"""
|
||||||
|
if not self.is_taken_over(session_id):
|
||||||
|
raise ValueError(f'Session {session_id} is not currently taken over')
|
||||||
|
|
||||||
|
# Look up the takeover record to get bot_uuid
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).where(
|
||||||
|
sqlalchemy.and_(
|
||||||
|
persistence_human_takeover.HumanTakeoverSession.session_id == session_id,
|
||||||
|
persistence_human_takeover.HumanTakeoverSession.status == 'active',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if not row:
|
||||||
|
raise RuntimeError(f'Active takeover record not found for session {session_id}')
|
||||||
|
|
||||||
|
takeover_record = row[0] if isinstance(row, tuple) else row
|
||||||
|
bot_uuid = takeover_record.bot_uuid
|
||||||
|
|
||||||
|
# Get the runtime bot
|
||||||
|
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||||
|
if not runtime_bot:
|
||||||
|
raise RuntimeError(f'Bot {bot_uuid} not found or not running')
|
||||||
|
|
||||||
|
# Parse session_id to determine target_type and target_id
|
||||||
|
# Format: 'person_{id}' or 'group_{id}'
|
||||||
|
if session_id.startswith('person_'):
|
||||||
|
target_type = 'person'
|
||||||
|
target_id = session_id[len('person_') :]
|
||||||
|
elif session_id.startswith('group_'):
|
||||||
|
target_type = 'group'
|
||||||
|
target_id = session_id[len('group_') :]
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Invalid session_id format: {session_id}')
|
||||||
|
|
||||||
|
# Build message chain
|
||||||
|
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
|
||||||
|
|
||||||
|
# Send via adapter
|
||||||
|
await runtime_bot.adapter.send_message(target_type, target_id, message_chain)
|
||||||
|
|
||||||
|
# Record the operator message in monitoring
|
||||||
|
bot_name = runtime_bot.bot_entity.name or bot_uuid
|
||||||
|
try:
|
||||||
|
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
message_content = message_text
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_message(
|
||||||
|
bot_id=bot_uuid,
|
||||||
|
bot_name=bot_name,
|
||||||
|
pipeline_id='__human_takeover__',
|
||||||
|
pipeline_name='Human Takeover',
|
||||||
|
message_content=message_content,
|
||||||
|
session_id=session_id,
|
||||||
|
status='success',
|
||||||
|
level='info',
|
||||||
|
platform=takeover_record.platform,
|
||||||
|
user_id=operator_name or 'operator',
|
||||||
|
user_name=operator_name or 'Operator',
|
||||||
|
role='operator',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info(f'Operator message sent to session {session_id}: {message_text[:50]}...')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'session_id': session_id,
|
||||||
|
'message_sent': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_active_sessions(
|
||||||
|
self,
|
||||||
|
bot_uuid: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""Get list of active (or all) takeover sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot_uuid: Optional filter by bot UUID.
|
||||||
|
limit: Maximum number of results.
|
||||||
|
offset: Pagination offset.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (list of session dicts, total count).
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if bot_uuid:
|
||||||
|
conditions.append(persistence_human_takeover.HumanTakeoverSession.bot_uuid == bot_uuid)
|
||||||
|
|
||||||
|
# Count
|
||||||
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_human_takeover.HumanTakeoverSession.id))
|
||||||
|
if conditions:
|
||||||
|
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
|
||||||
|
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# Fetch records
|
||||||
|
query = sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).order_by(
|
||||||
|
persistence_human_takeover.HumanTakeoverSession.taken_at.desc()
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
query = query.where(sqlalchemy.and_(*conditions))
|
||||||
|
|
||||||
|
query = query.limit(limit).offset(offset)
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
sessions = []
|
||||||
|
for row in rows:
|
||||||
|
session = row[0] if isinstance(row, tuple) else row
|
||||||
|
sessions.append(
|
||||||
|
self.ap.persistence_mgr.serialize_model(persistence_human_takeover.HumanTakeoverSession, session)
|
||||||
|
)
|
||||||
|
|
||||||
|
return sessions, total
|
||||||
|
|
||||||
|
async def get_session_detail(self, session_id: str) -> dict | None:
|
||||||
|
"""Get detail for a specific takeover session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The session ID to look up.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session dict or None if not found.
|
||||||
|
"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession)
|
||||||
|
.where(persistence_human_takeover.HumanTakeoverSession.session_id == session_id)
|
||||||
|
.order_by(persistence_human_takeover.HumanTakeoverSession.taken_at.desc())
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session = row[0] if isinstance(row, tuple) else row
|
||||||
|
return self.ap.persistence_mgr.serialize_model(persistence_human_takeover.HumanTakeoverSession, session)
|
||||||
@@ -31,6 +31,7 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import apikey as apikey_service
|
||||||
from ..api.http.service import webhook as webhook_service
|
from ..api.http.service import webhook as webhook_service
|
||||||
from ..api.http.service import monitoring as monitoring_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 ..discover import engine as discover_engine
|
||||||
from ..storage import mgr as storagemgr
|
from ..storage import mgr as storagemgr
|
||||||
@@ -153,6 +154,8 @@ class Application:
|
|||||||
|
|
||||||
monitoring_service: monitoring_service.MonitoringService = None
|
monitoring_service: monitoring_service.MonitoringService = None
|
||||||
|
|
||||||
|
human_takeover_service: human_takeover_service.HumanTakeoverService = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from ...api.http.service import mcp as mcp_service
|
|||||||
from ...api.http.service import apikey as apikey_service
|
from ...api.http.service import apikey as apikey_service
|
||||||
from ...api.http.service import webhook as webhook_service
|
from ...api.http.service import webhook as webhook_service
|
||||||
from ...api.http.service import monitoring as monitoring_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 ...discover import engine as discover_engine
|
||||||
from ...storage import mgr as storagemgr
|
from ...storage import mgr as storagemgr
|
||||||
from ...utils import logcache
|
from ...utils import logcache
|
||||||
@@ -164,6 +165,10 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
||||||
ap.monitoring_service = monitoring_service_inst
|
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:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
await plugin_connector_inst.initialize()
|
await plugin_connector_inst.initialize()
|
||||||
|
|||||||
@@ -80,8 +80,12 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
|||||||
if i == len(keys) - 1:
|
if i == len(keys) - 1:
|
||||||
# At the final key
|
# At the final key
|
||||||
if key in current:
|
if key in current:
|
||||||
if isinstance(current[key], (dict, list)):
|
if isinstance(current[key], list):
|
||||||
# Skip dict and list types
|
# Convert comma-separated string to list
|
||||||
|
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
|
||||||
|
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||||
|
elif isinstance(current[key], dict):
|
||||||
|
# Skip dict types
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Valid scalar value - convert and set it
|
# Valid scalar value - convert and set it
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Bot(Base):
|
|||||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
sqlalchemy.DateTime,
|
sqlalchemy.DateTime,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class HumanTakeoverSession(Base):
|
||||||
|
"""Human takeover session records.
|
||||||
|
|
||||||
|
Tracks which conversation sessions are currently under human operator control,
|
||||||
|
bypassing the normal AI pipeline processing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'human_takeover_sessions'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||||
|
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||||
|
"""Corresponds to monitoring_sessions.session_id, format: 'person_{id}' or 'group_{id}'"""
|
||||||
|
|
||||||
|
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||||
|
"""UUID of the bot whose session is being taken over"""
|
||||||
|
|
||||||
|
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='active', index=True)
|
||||||
|
"""Takeover status: 'active' or 'released'"""
|
||||||
|
|
||||||
|
taken_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
"""Email/username of the admin who took over the session"""
|
||||||
|
|
||||||
|
taken_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
||||||
|
"""Timestamp when the takeover started"""
|
||||||
|
|
||||||
|
released_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
|
"""Timestamp when the takeover was released (null if still active)"""
|
||||||
|
|
||||||
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import sqlalchemy
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(25)
|
||||||
|
class DBMigrateBotPipelineRoutingRules(migration.DBMigration):
|
||||||
|
"""Add pipeline_routing_rules column to bots table"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
sql_text = sqlalchemy.text("ALTER TABLE bots ADD COLUMN pipeline_routing_rules JSON NOT NULL DEFAULT '[]'")
|
||||||
|
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
sql_text = sqlalchemy.text('ALTER TABLE bots DROP COLUMN pipeline_routing_rules')
|
||||||
|
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import sqlalchemy
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(26)
|
||||||
|
class DBMigrateHumanTakeoverSessions(migration.DBMigration):
|
||||||
|
"""Create human_takeover_sessions table for human operator takeover support"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
sql_text = sqlalchemy.text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS human_takeover_sessions (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
session_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
bot_uuid VARCHAR(255) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||||
|
taken_by VARCHAR(255),
|
||||||
|
taken_at DATETIME NOT NULL,
|
||||||
|
released_at DATETIME,
|
||||||
|
platform VARCHAR(255),
|
||||||
|
user_id VARCHAR(255),
|
||||||
|
user_name VARCHAR(255)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
for idx_sql in [
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_hts_session_id ON human_takeover_sessions (session_id)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_hts_bot_uuid ON human_takeover_sessions (bot_uuid)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_hts_status ON human_takeover_sessions (status)',
|
||||||
|
]:
|
||||||
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(idx_sql))
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
sql_text = sqlalchemy.text('DROP TABLE IF EXISTS human_takeover_sessions')
|
||||||
|
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||||
@@ -37,6 +37,7 @@ class PendingMessage:
|
|||||||
message_chain: platform_message.MessageChain
|
message_chain: platform_message.MessageChain
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||||
pipeline_uuid: typing.Optional[str]
|
pipeline_uuid: typing.Optional[str]
|
||||||
|
routed_by_rule: bool = False
|
||||||
timestamp: float = field(default_factory=time.time)
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ class MessageAggregator:
|
|||||||
message_chain: platform_message.MessageChain,
|
message_chain: platform_message.MessageChain,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
|
routed_by_rule: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a message to the aggregation buffer
|
"""Add a message to the aggregation buffer
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ class MessageAggregator:
|
|||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ class MessageAggregator:
|
|||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
force_flush = False
|
force_flush = False
|
||||||
@@ -217,6 +221,7 @@ class MessageAggregator:
|
|||||||
message_chain=msg.message_chain,
|
message_chain=msg.message_chain,
|
||||||
adapter=msg.adapter,
|
adapter=msg.adapter,
|
||||||
pipeline_uuid=msg.pipeline_uuid,
|
pipeline_uuid=msg.pipeline_uuid,
|
||||||
|
routed_by_rule=msg.routed_by_rule,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -231,6 +236,7 @@ class MessageAggregator:
|
|||||||
message_chain=merged_msg.message_chain,
|
message_chain=merged_msg.message_chain,
|
||||||
adapter=merged_msg.adapter,
|
adapter=merged_msg.adapter,
|
||||||
pipeline_uuid=merged_msg.pipeline_uuid,
|
pipeline_uuid=merged_msg.pipeline_uuid,
|
||||||
|
routed_by_rule=merged_msg.routed_by_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ class Controller:
|
|||||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||||
if pipeline:
|
if pipeline:
|
||||||
await pipeline.run(selected_query)
|
await pipeline.run(selected_query)
|
||||||
|
else:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'No pipeline_uuid for query {selected_query.query_id}, query dropped'
|
||||||
|
)
|
||||||
|
|
||||||
async with self.ap.query_pool:
|
async with self.ap.query_pool:
|
||||||
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
||||||
|
|||||||
@@ -323,6 +323,9 @@ class RuntimePipeline:
|
|||||||
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
||||||
|
|
||||||
if event_ctx.is_prevented_default():
|
if event_ctx.is_prevented_default():
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.ap.logger.debug(f'Processing query {query.query_id}')
|
self.ap.logger.debug(f'Processing query {query.query_id}')
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class QueryPool:
|
|||||||
message_chain: platform_message.MessageChain,
|
message_chain: platform_message.MessageChain,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
|
routed_by_rule: bool = False,
|
||||||
) -> pipeline_query.Query:
|
) -> pipeline_query.Query:
|
||||||
async with self.condition:
|
async with self.condition:
|
||||||
query_id = self.query_id_counter
|
query_id = self.query_id_counter
|
||||||
@@ -52,7 +53,7 @@ class QueryPool:
|
|||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
message_event=message_event,
|
message_event=message_event,
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
variables={},
|
variables={'_routed_by_rule': routed_by_rule},
|
||||||
resp_messages=[],
|
resp_messages=[],
|
||||||
resp_message_chain=[],
|
resp_message_chain=[],
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
|
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
else:
|
else:
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'NormalMessageReceived event prevented default for query {query.query_id} without reply'
|
||||||
|
)
|
||||||
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
||||||
else:
|
else:
|
||||||
if event_ctx.event.user_message_alter is not None:
|
if event_ctx.event.user_message_alter is not None:
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ class GroupRespondRuleCheckStage(stage.PipelineStage):
|
|||||||
if query.launcher_type.value != 'group': # 只处理群消息
|
if query.launcher_type.value != 'group': # 只处理群消息
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
|
# 通过路由规则明确指定的流水线,跳过群响应规则检查
|
||||||
|
if query.variables and query.variables.get('_routed_by_rule', False):
|
||||||
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|
||||||
rules = query.pipeline_config['trigger']['group-respond-rules']
|
rules = query.pipeline_config['trigger']['group-respond-rules']
|
||||||
|
|
||||||
use_rule = rules
|
use_rule = rules
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
@@ -52,6 +54,148 @@ class RuntimeBot:
|
|||||||
self.task_context = taskmgr.TaskContext()
|
self.task_context = taskmgr.TaskContext()
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match_operator(actual: str, operator: str, expected: str) -> bool:
|
||||||
|
"""Evaluate a single operator condition."""
|
||||||
|
if operator == 'eq':
|
||||||
|
return actual == expected
|
||||||
|
elif operator == 'neq':
|
||||||
|
return actual != expected
|
||||||
|
elif operator == 'contains':
|
||||||
|
return expected in actual
|
||||||
|
elif operator == 'not_contains':
|
||||||
|
return expected not in actual
|
||||||
|
elif operator == 'starts_with':
|
||||||
|
return actual.startswith(expected)
|
||||||
|
elif operator == 'regex':
|
||||||
|
try:
|
||||||
|
return bool(re.search(expected, actual))
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
PIPELINE_DISCARD = '__discard__'
|
||||||
|
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
||||||
|
|
||||||
|
def resolve_pipeline_uuid(
|
||||||
|
self,
|
||||||
|
launcher_type: str,
|
||||||
|
launcher_id: str,
|
||||||
|
message_text: str,
|
||||||
|
message_element_types: list[str] | None = None,
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
|
"""Resolve pipeline UUID based on routing rules.
|
||||||
|
|
||||||
|
Rules are evaluated in order; first match wins.
|
||||||
|
Falls back to use_pipeline_uuid if no rule matches.
|
||||||
|
|
||||||
|
Rule types:
|
||||||
|
- launcher_type: session type ("person" / "group")
|
||||||
|
- launcher_id: session / group id
|
||||||
|
- message_content: message text content
|
||||||
|
- message_has_element: message contains element of given type
|
||||||
|
(Image, Voice, File, Forward, Face, At, AtAll, Quote)
|
||||||
|
Operators: eq (has), neq (doesn't have)
|
||||||
|
|
||||||
|
Operators: eq, neq, contains, not_contains, starts_with, regex
|
||||||
|
|
||||||
|
When pipeline_uuid is ``__discard__``, the message should be
|
||||||
|
silently dropped by the caller.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
|
||||||
|
when a routing rule matched, False when falling back to default.
|
||||||
|
"""
|
||||||
|
rules = self.bot_entity.pipeline_routing_rules or []
|
||||||
|
element_type_set = set(message_element_types or [])
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
rule_type = rule.get('type')
|
||||||
|
operator = rule.get('operator', 'eq')
|
||||||
|
rule_value = rule.get('value', '')
|
||||||
|
target_uuid = rule.get('pipeline_uuid')
|
||||||
|
if not rule_type or not target_uuid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rule_type == 'launcher_type':
|
||||||
|
if self._match_operator(launcher_type, operator, rule_value):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'launcher_id':
|
||||||
|
if self._match_operator(str(launcher_id), operator, str(rule_value)):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'message_content':
|
||||||
|
if self._match_operator(message_text, operator, rule_value):
|
||||||
|
return target_uuid, True
|
||||||
|
elif rule_type == 'message_has_element':
|
||||||
|
has_element = rule_value in element_type_set
|
||||||
|
if operator == 'eq' and has_element:
|
||||||
|
return target_uuid, True
|
||||||
|
elif operator == 'neq' and not has_element:
|
||||||
|
return target_uuid, True
|
||||||
|
|
||||||
|
return self.bot_entity.use_pipeline_uuid, False
|
||||||
|
|
||||||
|
async def _record_discarded_message(
|
||||||
|
self,
|
||||||
|
launcher_type: provider_session.LauncherTypes,
|
||||||
|
launcher_id: str | int,
|
||||||
|
sender_id: str | int,
|
||||||
|
message_event: platform_events.MessageEvent,
|
||||||
|
message_chain: platform_message.MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""Record a discarded message in the monitoring system."""
|
||||||
|
try:
|
||||||
|
if hasattr(message_chain, 'model_dump'):
|
||||||
|
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
message_content = str(message_chain)
|
||||||
|
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(message_event, 'sender'):
|
||||||
|
if hasattr(message_event.sender, 'nickname'):
|
||||||
|
sender_name = message_event.sender.nickname
|
||||||
|
elif hasattr(message_event.sender, 'member_name'):
|
||||||
|
sender_name = message_event.sender.member_name
|
||||||
|
|
||||||
|
# Use the same session_id format as monitoring_helper.py
|
||||||
|
session_id = f'{launcher_type}_{launcher_id}'
|
||||||
|
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_message(
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||||
|
pipeline_id=self.PIPELINE_DISCARD,
|
||||||
|
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||||
|
message_content=message_content,
|
||||||
|
session_id=session_id,
|
||||||
|
status='discarded',
|
||||||
|
level='info',
|
||||||
|
platform=platform,
|
||||||
|
user_id=str(sender_id),
|
||||||
|
user_name=sender_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the session exists so the message appears in the session monitor.
|
||||||
|
# Don't overwrite pipeline info — a session may have messages from
|
||||||
|
# multiple pipelines; discarding shouldn't change the displayed pipeline.
|
||||||
|
session_updated = await self.ap.monitoring_service.update_session_activity(
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if not session_updated:
|
||||||
|
# No session yet (first message for this launcher was discarded).
|
||||||
|
await self.ap.monitoring_service.record_session_start(
|
||||||
|
session_id=session_id,
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||||
|
pipeline_id=self.PIPELINE_DISCARD,
|
||||||
|
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||||
|
platform=platform,
|
||||||
|
user_id=str(sender_id),
|
||||||
|
user_name=sender_name,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'Failed to record discarded message: {e}')
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
async def on_friend_message(
|
async def on_friend_message(
|
||||||
event: platform_events.FriendMessage,
|
event: platform_events.FriendMessage,
|
||||||
@@ -76,6 +220,47 @@ class RuntimeBot:
|
|||||||
|
|
||||||
# Only add to query pool if no webhook requested to skip pipeline
|
# Only add to query pool if no webhook requested to skip pipeline
|
||||||
if not 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
|
launcher_id = event.sender.id
|
||||||
|
|
||||||
if hasattr(adapter, 'get_launcher_id'):
|
if hasattr(adapter, 'get_launcher_id'):
|
||||||
@@ -83,6 +268,23 @@ class RuntimeBot:
|
|||||||
if custom_launcher_id:
|
if custom_launcher_id:
|
||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
|
message_text = str(event.message_chain)
|
||||||
|
element_types = [comp.type for comp in event.message_chain]
|
||||||
|
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||||
|
'person', launcher_id, message_text, element_types
|
||||||
|
)
|
||||||
|
|
||||||
|
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||||
|
await self.logger.info('Person message discarded by routing rule')
|
||||||
|
await self._record_discarded_message(
|
||||||
|
provider_session.LauncherTypes.PERSON,
|
||||||
|
launcher_id,
|
||||||
|
event.sender.id,
|
||||||
|
event,
|
||||||
|
event.message_chain,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.msg_aggregator.add_message(
|
await self.ap.msg_aggregator.add_message(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||||
@@ -91,7 +293,8 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
||||||
@@ -119,6 +322,50 @@ class RuntimeBot:
|
|||||||
|
|
||||||
# Only add to query pool if no webhook requested to skip pipeline
|
# Only add to query pool if no webhook requested to skip pipeline
|
||||||
if not 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
|
launcher_id = event.group.id
|
||||||
|
|
||||||
if hasattr(adapter, 'get_launcher_id'):
|
if hasattr(adapter, 'get_launcher_id'):
|
||||||
@@ -126,6 +373,23 @@ class RuntimeBot:
|
|||||||
if custom_launcher_id:
|
if custom_launcher_id:
|
||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
|
message_text = str(event.message_chain)
|
||||||
|
element_types = [comp.type for comp in event.message_chain]
|
||||||
|
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||||
|
'group', launcher_id, message_text, element_types
|
||||||
|
)
|
||||||
|
|
||||||
|
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||||
|
await self.logger.info('Group message discarded by routing rule')
|
||||||
|
await self._record_discarded_message(
|
||||||
|
provider_session.LauncherTypes.GROUP,
|
||||||
|
launcher_id,
|
||||||
|
event.sender.id,
|
||||||
|
event,
|
||||||
|
event.message_chain,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.msg_aggregator.add_message(
|
await self.ap.msg_aggregator.add_message(
|
||||||
bot_uuid=self.bot_entity.uuid,
|
bot_uuid=self.bot_entity.uuid,
|
||||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||||
@@ -134,7 +398,8 @@ class RuntimeBot:
|
|||||||
message_event=event,
|
message_event=event,
|
||||||
message_chain=event.message_chain,
|
message_chain=event.message_chain,
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
routed_by_rule=routed_by_rule,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
||||||
@@ -241,12 +506,20 @@ class PlatformManager:
|
|||||||
# delete all bot log images
|
# delete all bot log images
|
||||||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
||||||
|
|
||||||
|
disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []
|
||||||
|
|
||||||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||||||
for component in self.adapter_components:
|
for component in self.adapter_components:
|
||||||
|
if component.metadata.name in disabled_adapters:
|
||||||
|
continue
|
||||||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||||
self.adapter_dict = adapter_dict
|
self.adapter_dict = adapter_dict
|
||||||
|
|
||||||
|
# Filter out disabled adapters from components list (for API responses)
|
||||||
|
if disabled_adapters:
|
||||||
|
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
|
||||||
|
|
||||||
# initialize websocket adapter
|
# initialize websocket adapter
|
||||||
websocket_adapter_class = self.adapter_dict['websocket']
|
websocket_adapter_class = self.adapter_dict['websocket']
|
||||||
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
||||||
|
|||||||
@@ -797,8 +797,65 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||||
asyncio.create_task(on_message(event))
|
asyncio.create_task(on_message(event))
|
||||||
|
|
||||||
|
def sync_on_card_action(event):
|
||||||
|
try:
|
||||||
|
action_value_obj = getattr(getattr(event.event, 'action', None), 'value', {})
|
||||||
|
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||||
|
|
||||||
|
if action_value == '有帮助':
|
||||||
|
feedback_type = 1
|
||||||
|
elif action_value == '无帮助':
|
||||||
|
feedback_type = 2
|
||||||
|
else:
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}})
|
||||||
|
|
||||||
|
operator = getattr(event.event, 'operator', None)
|
||||||
|
context = getattr(event.event, 'context', None)
|
||||||
|
|
||||||
|
user_id = getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None)
|
||||||
|
open_chat_id = getattr(context, 'open_chat_id', None)
|
||||||
|
open_message_id = getattr(context, 'open_message_id', None)
|
||||||
|
|
||||||
|
if open_chat_id:
|
||||||
|
session_id = f'group_{open_chat_id}'
|
||||||
|
elif user_id:
|
||||||
|
session_id = f'person_{user_id}'
|
||||||
|
else:
|
||||||
|
session_id = None
|
||||||
|
|
||||||
|
feedback_event = platform_events.FeedbackEvent(
|
||||||
|
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=action_value,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=open_message_id,
|
||||||
|
source_platform_object=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
asyncio.create_task(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||||
|
else:
|
||||||
|
loop.run_until_complete(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||||
|
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
|
||||||
|
except Exception:
|
||||||
|
asyncio.create_task(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}'))
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||||
|
|
||||||
|
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
|
||||||
|
|
||||||
event_handler = (
|
event_handler = (
|
||||||
lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build()
|
lark_oapi.EventDispatcherHandler.builder('', '')
|
||||||
|
.register_p2_im_message_receive_v1(sync_on_message)
|
||||||
|
.register_p2_card_action_trigger(sync_on_card_action)
|
||||||
|
.build()
|
||||||
)
|
)
|
||||||
|
|
||||||
bot_account_id = config['bot_name']
|
bot_account_id = config['bot_name']
|
||||||
@@ -1088,6 +1145,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'size': 'medium',
|
'size': 'medium',
|
||||||
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
|
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
|
||||||
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
|
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
|
||||||
|
'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}],
|
||||||
'margin': '0px 0px 0px 0px',
|
'margin': '0px 0px 0px 0px',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1111,6 +1169,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'size': 'medium',
|
'size': 'medium',
|
||||||
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
|
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
|
||||||
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
|
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
|
||||||
|
'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}],
|
||||||
'margin': '0px 0px 0px 0px',
|
'margin': '0px 0px 0px 0px',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1472,6 +1531,52 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
if event.__class__ in self.listeners:
|
if event.__class__ in self.listeners:
|
||||||
await self.listeners[event.__class__](event, self)
|
await self.listeners[event.__class__](event, self)
|
||||||
|
elif 'card.action.trigger' == type:
|
||||||
|
try:
|
||||||
|
event_data = data.get('event', {})
|
||||||
|
operator = event_data.get('operator', {})
|
||||||
|
action = event_data.get('action', {})
|
||||||
|
context_data = event_data.get('context', {})
|
||||||
|
|
||||||
|
action_value_obj = action.get('value', {})
|
||||||
|
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||||
|
|
||||||
|
if action_value == '有帮助':
|
||||||
|
feedback_type = 1
|
||||||
|
elif action_value == '无帮助':
|
||||||
|
feedback_type = 2
|
||||||
|
else:
|
||||||
|
return {'toast': {'type': 'success', 'content': '操作成功'}}
|
||||||
|
|
||||||
|
user_id = operator.get('open_id') or operator.get('user_id')
|
||||||
|
open_chat_id = context_data.get('open_chat_id')
|
||||||
|
open_message_id = context_data.get('open_message_id')
|
||||||
|
|
||||||
|
if open_chat_id:
|
||||||
|
session_id = f'group_{open_chat_id}'
|
||||||
|
elif user_id:
|
||||||
|
session_id = f'person_{user_id}'
|
||||||
|
else:
|
||||||
|
session_id = None
|
||||||
|
|
||||||
|
feedback_event = platform_events.FeedbackEvent(
|
||||||
|
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=action_value,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=open_message_id,
|
||||||
|
source_platform_object=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
|
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
|
||||||
|
|
||||||
|
return {'toast': {'type': 'success', 'content': '感谢您的反馈'}}
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')
|
||||||
|
return {'toast': {'type': 'error', 'content': '反馈处理失败'}}
|
||||||
|
|
||||||
elif 'im.chat.member.bot.added_v1' == type:
|
elif 'im.chat.member.bot.added_v1' == type:
|
||||||
try:
|
try:
|
||||||
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
||||||
|
|||||||
@@ -343,6 +343,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
message_id = session.msg_id
|
message_id = session.msg_id
|
||||||
stream_id = session.stream_id
|
stream_id = session.stream_id
|
||||||
|
|
||||||
|
await self.logger.info(
|
||||||
|
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
|
f'session_id={session_id}, user_id={user_id}, message_id={message_id}'
|
||||||
|
)
|
||||||
|
|
||||||
event = platform_events.FeedbackEvent(
|
event = platform_events.FeedbackEvent(
|
||||||
feedback_id=feedback_id,
|
feedback_id=feedback_id,
|
||||||
feedback_type=feedback_type,
|
feedback_type=feedback_type,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import langbot
|
|||||||
|
|
||||||
semantic_version = f'v{langbot.__version__}'
|
semantic_version = f'v{langbot.__version__}'
|
||||||
|
|
||||||
required_database_version = 24
|
required_database_version = 26
|
||||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||||
|
|
||||||
debug_mode = False
|
debug_mode = False
|
||||||
|
|||||||
@@ -38,28 +38,31 @@ def get_frontend_path() -> str:
|
|||||||
"""
|
"""
|
||||||
Get the path to the frontend build files.
|
Get the path to the frontend build files.
|
||||||
|
|
||||||
Returns the path to web/out directory, handling both:
|
Returns the path to web/dist directory (Vite build output), handling both:
|
||||||
- Development mode: running from source directory
|
- Development mode: running from source directory
|
||||||
- Package mode: installed via pip/uvx
|
- Package mode: installed via pip/uvx
|
||||||
|
- Legacy mode: web/out (Next.js, for backward compatibility)
|
||||||
"""
|
"""
|
||||||
# First, check if we're running from source directory
|
# Check both dist (Vite) and out (legacy Next.js) paths
|
||||||
if _check_if_source_install() and os.path.exists('web/out'):
|
for dirname in ('dist', 'out'):
|
||||||
return 'web/out'
|
web_dir = f'web/{dirname}'
|
||||||
|
|
||||||
# Second, check current directory for web/out (in case user is in source dir)
|
# First, check if we're running from source directory
|
||||||
if os.path.exists('web/out'):
|
if _check_if_source_install() and os.path.exists(web_dir):
|
||||||
return 'web/out'
|
return web_dir
|
||||||
|
|
||||||
# Third, find it relative to the package installation
|
# Second, check current directory
|
||||||
# Get the directory where this file is located
|
if os.path.exists(web_dir):
|
||||||
# paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root
|
return web_dir
|
||||||
pkg_dir = Path(__file__).parent.parent.parent
|
|
||||||
frontend_path = pkg_dir / 'web' / 'out'
|
# Third, find it relative to the package installation
|
||||||
if frontend_path.exists():
|
pkg_dir = Path(__file__).parent.parent.parent
|
||||||
return str(frontend_path)
|
frontend_path = pkg_dir / 'web' / dirname
|
||||||
|
if frontend_path.exists():
|
||||||
|
return str(frontend_path)
|
||||||
|
|
||||||
# Return the default path (will be checked by caller)
|
# Return the default path (will be checked by caller)
|
||||||
return 'web/out'
|
return 'web/dist'
|
||||||
|
|
||||||
|
|
||||||
def get_resource_path(resource: str) -> str:
|
def get_resource_path(resource: str) -> str:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ system:
|
|||||||
edition: community
|
edition: community
|
||||||
recovery_key: ''
|
recovery_key: ''
|
||||||
allow_modify_login_info: true
|
allow_modify_login_info: true
|
||||||
|
disabled_adapters: []
|
||||||
limitation:
|
limitation:
|
||||||
max_bots: -1
|
max_bots: -1
|
||||||
max_pipelines: -1
|
max_pipelines: -1
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""
|
||||||
|
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchOperator:
|
||||||
|
"""Test the _match_operator static method."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_class():
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
return RuntimeBot
|
||||||
|
|
||||||
|
def test_eq(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'eq', 'hello') is True
|
||||||
|
assert cls._match_operator('hello', 'eq', 'world') is False
|
||||||
|
|
||||||
|
def test_neq(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'neq', 'world') is True
|
||||||
|
assert cls._match_operator('hello', 'neq', 'hello') is False
|
||||||
|
|
||||||
|
def test_contains(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello world', 'contains', 'world') is True
|
||||||
|
assert cls._match_operator('hello world', 'contains', 'xyz') is False
|
||||||
|
|
||||||
|
def test_not_contains(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello world', 'not_contains', 'xyz') is True
|
||||||
|
assert cls._match_operator('hello world', 'not_contains', 'world') is False
|
||||||
|
|
||||||
|
def test_starts_with(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello world', 'starts_with', 'hello') is True
|
||||||
|
assert cls._match_operator('hello world', 'starts_with', 'world') is False
|
||||||
|
|
||||||
|
def test_regex(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello123', 'regex', r'\d+') is True
|
||||||
|
assert cls._match_operator('hello', 'regex', r'\d+') is False
|
||||||
|
|
||||||
|
def test_regex_invalid_pattern(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'regex', r'[invalid') is False
|
||||||
|
|
||||||
|
def test_unknown_operator(self):
|
||||||
|
cls = self._get_class()
|
||||||
|
assert cls._match_operator('hello', 'unknown_op', 'hello') is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolvePipelineUuid:
|
||||||
|
"""Test the resolve_pipeline_uuid method."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_bot(default_pipeline: str, rules: list):
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
bot_entity = Mock()
|
||||||
|
bot_entity.use_pipeline_uuid = default_pipeline
|
||||||
|
bot_entity.pipeline_routing_rules = rules
|
||||||
|
|
||||||
|
bot = object.__new__(RuntimeBot)
|
||||||
|
bot.bot_entity = bot_entity
|
||||||
|
return bot
|
||||||
|
|
||||||
|
def test_no_rules_returns_default(self):
|
||||||
|
bot = self._make_bot('default-uuid', [])
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_none_rules_returns_default(self):
|
||||||
|
bot = self._make_bot('default-uuid', None)
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_launcher_type_match(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'group',
|
||||||
|
'pipeline_uuid': 'group-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
|
||||||
|
assert uuid == 'group-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_launcher_id_match(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_id',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': '12345',
|
||||||
|
'pipeline_uuid': 'vip-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '12345', 'hi')
|
||||||
|
assert uuid == 'vip-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '99999', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_content_contains(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_content',
|
||||||
|
'operator': 'contains',
|
||||||
|
'value': '紧急',
|
||||||
|
'pipeline_uuid': 'urgent-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '这是紧急消息')
|
||||||
|
assert uuid == 'urgent-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '普通消息')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_content_regex(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_content',
|
||||||
|
'operator': 'regex',
|
||||||
|
'value': r'^/admin\b',
|
||||||
|
'pipeline_uuid': 'admin-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '/admin help')
|
||||||
|
assert uuid == 'admin-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hello /admin')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_has_element_eq(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_has_element',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'Image',
|
||||||
|
'pipeline_uuid': 'image-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
|
||||||
|
assert uuid == 'image-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_has_element_neq(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_has_element',
|
||||||
|
'operator': 'neq',
|
||||||
|
'value': 'Image',
|
||||||
|
'pipeline_uuid': 'text-only-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
|
||||||
|
assert uuid == 'text-only-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_message_has_element_no_types_provided(self):
|
||||||
|
"""When element types are not provided, should not match."""
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_has_element',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'Image',
|
||||||
|
'pipeline_uuid': 'image-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
|
|
||||||
|
def test_first_match_wins(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'group',
|
||||||
|
'pipeline_uuid': 'first-pipeline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'operator': 'eq',
|
||||||
|
'value': 'group',
|
||||||
|
'pipeline_uuid': 'second-pipeline',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
|
||||||
|
assert uuid == 'first-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
def test_skip_invalid_rules(self):
|
||||||
|
rules = [
|
||||||
|
{'type': '', 'operator': 'eq', 'value': 'x', 'pipeline_uuid': 'p1'},
|
||||||
|
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': ''},
|
||||||
|
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': 'valid'},
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'valid'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
def test_default_operator_is_eq(self):
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'launcher_type',
|
||||||
|
'value': 'person',
|
||||||
|
'pipeline_uuid': 'person-pipeline',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||||
|
assert uuid == 'person-pipeline'
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
def test_discard_pipeline(self):
|
||||||
|
"""When pipeline_uuid is __discard__, the message should be discarded."""
|
||||||
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
'type': 'message_content',
|
||||||
|
'operator': 'contains',
|
||||||
|
'value': 'spam',
|
||||||
|
'pipeline_uuid': RuntimeBot.PIPELINE_DISCARD,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bot = self._make_bot('default-uuid', rules)
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'this is spam')
|
||||||
|
assert uuid == RuntimeBot.PIPELINE_DISCARD
|
||||||
|
assert routed is True
|
||||||
|
|
||||||
|
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
|
||||||
|
assert uuid == 'default-uuid'
|
||||||
|
assert routed is False
|
||||||
@@ -1937,7 +1937,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.3.6" },
|
{ name = "langbot-plugin", specifier = "==0.3.7" },
|
||||||
{ name = "langchain", specifier = ">=0.2.0" },
|
{ name = "langchain", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||||
@@ -1993,7 +1993,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.3.6"
|
version = "0.3.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2011,9 +2011,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/f0/e5561bd1ebda0b9345ad6b98718b5f002bb3ca79b5ec294dc77cc10957b9/langbot_plugin-0.3.6.tar.gz", hash = "sha256:20db981e416a640f22246e54517abc2a095d8ccf5e69e06c2674fb8a443f5dbe", size = 179266, upload-time = "2026-03-30T15:58:58.523Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/12/31/8dc7106cb65004a01e363308343c5a95e35f1722f26c87853e6e12c6fee1/langbot_plugin-0.3.7.tar.gz", hash = "sha256:bc0dea6b1c515d9fc8c3ab14af74bdf3e006d7e20c097b6cb5034f5af4a73cc9", size = 179764, upload-time = "2026-04-03T09:43:17.343Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/f5/ac424c2620e1be98a54a0b8ec0ed256a9c06cea7cd32a30732a1aea5fdc5/langbot_plugin-0.3.6-py3-none-any.whl", hash = "sha256:3238448436c41d50a0a0cf37438d845f0a1371159d440af3411a984e3d4e9eb7", size = 156752, upload-time = "2026-03-30T15:59:00.229Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/51/1982c199bd4efbfa3c327c95cca7e4ab502610251567000b348c72bca1b1/langbot_plugin-0.3.7-py3-none-any.whl", hash = "sha256:2e2b9e99163ceb14da28b8ce7c4cbc6990dea15684ec78976bc015e5378feea2", size = 157324, upload-time = "2026-04-03T09:43:15.782Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:5300
|
VITE_API_BASE_URL=http://localhost:5300
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
|
/dist/
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "",
|
||||||
|
|||||||
+20
-11
@@ -1,18 +1,27 @@
|
|||||||
import { dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { FlatCompat } from '@eslint/eslintrc';
|
|
||||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
import tseslint from 'typescript-eslint';
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
eslintPluginPrettierRecommended,
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
sed -i 's/children={<HomePage \/>} />\n <HomePage \/>\n <\/HomeLayout>/g' src/router.tsx
|
||||||
|
# well it's easier to recreate router.tsx
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>LangBot</title>
|
||||||
|
<meta name="description" content="Production-grade platform for building agentic IM bots" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /root/.openclaw/workspace/coding/projects/LangBot/web
|
||||||
|
|
||||||
|
# Find and replace next/navigation
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
|
||||||
|
-e "s/import {.*useRouter.*} from 'next\/navigation'/import { useNavigate } from 'react-router-dom'/g" \
|
||||||
|
-e "s/import {.*usePathname.*} from 'next\/navigation'/import { useLocation } from 'react-router-dom'/g" \
|
||||||
|
-e "s/import {.*useSearchParams.*} from 'next\/navigation'/import { useSearchParams } from 'react-router-dom'/g" \
|
||||||
|
-e "s/const router = useRouter()/const navigate = useNavigate()/g" \
|
||||||
|
-e "s/router\.push(/navigate(/g" \
|
||||||
|
-e "s/router\.replace(/navigate(/g" \
|
||||||
|
-e "s/router\.back()/navigate(-1)/g" \
|
||||||
|
-e "s/router\.refresh()/navigate(0)/g" \
|
||||||
|
-e "s/const pathname = usePathname()/const location = useLocation();\n const pathname = location.pathname;/g" \
|
||||||
|
-e "s/usePathname()/useLocation().pathname/g" \
|
||||||
|
{} +
|
||||||
|
|
||||||
|
# Note: useSearchParams returns a tuple in react-router-dom. This might need manual fix depending on usage.
|
||||||
|
|
||||||
|
# Replace next/link
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
|
||||||
|
-e "s/import Link from 'next\/link'/import { Link } from 'react-router-dom'/g" \
|
||||||
|
-e "s/<Link href=/<Link to=/g" \
|
||||||
|
{} +
|
||||||
|
|
||||||
|
# Remove 'use client'
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i "s/'use client';//g" {} +
|
||||||
|
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i 's/"use client";//g' {} +
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { NextConfig } from 'next';
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
output: 'export',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
Generated
+891
-3449
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -3,16 +3,15 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc && vite build",
|
||||||
"start": "next start",
|
"preview": "vite preview",
|
||||||
"lint": "eslint src",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint src --fix",
|
"format": "prettier --write ."
|
||||||
"lint-staged": "lint-staged"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"next lint --fix",
|
"eslint --fix",
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -46,6 +45,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tailwindcss/postcss": "^4.1.5",
|
"@tailwindcss/postcss": "^4.1.5",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -55,8 +55,6 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lucide-react": "^0.507.0",
|
"lucide-react": "^0.507.0",
|
||||||
"next": "~16.1.5",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
@@ -65,6 +63,7 @@
|
|||||||
"react-i18next": "^15.5.1",
|
"react-i18next": "^15.5.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-photo-view": "^1.2.7",
|
"react-photo-view": "^1.2.7",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
@@ -77,10 +76,10 @@
|
|||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.5",
|
"tailwindcss": "^4.1.5",
|
||||||
"uuidjs": "^5.1.0",
|
"uuidjs": "^5.1.0",
|
||||||
|
"vite": "^8.0.3",
|
||||||
"zod": "^3.24.4"
|
"zod": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/estree": "^1.0.8",
|
"@types/estree": "^1.0.8",
|
||||||
"@types/estree-jsx": "^1.0.5",
|
"@types/estree-jsx": "^1.0.5",
|
||||||
@@ -95,9 +94,10 @@
|
|||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/unist": "^3.0.3",
|
"@types/unist": "^3.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.2.4",
|
|
||||||
"eslint-config-prettier": "^10.1.2",
|
"eslint-config-prettier": "^10.1.2",
|
||||||
"eslint-plugin-prettier": "^5.2.6",
|
"eslint-plugin-prettier": "^5.2.6",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"lint-staged": "^15.5.1",
|
"lint-staged": "^15.5.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"tw-animate-css": "^1.2.9",
|
"tw-animate-css": "^1.2.9",
|
||||||
|
|||||||
Generated
+1401
-4172
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, Suspense } from 'react';
|
import { useEffect, useState, useCallback, Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -23,8 +21,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
|||||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||||
|
|
||||||
function SpaceOAuthCallbackContent() {
|
function SpaceOAuthCallbackContent() {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [status, setStatus] = useState<
|
const [status, setStatus] = useState<
|
||||||
@@ -51,7 +49,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
const wizardState = localStorage.getItem('langbot_wizard_state');
|
const wizardState = localStorage.getItem('langbot_wizard_state');
|
||||||
const redirectTo = wizardState ? '/wizard' : '/home';
|
const redirectTo = wizardState ? '/wizard' : '/home';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(redirectTo);
|
navigate(redirectTo);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -64,7 +62,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, t],
|
[navigate, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [bindState, setBindState] = useState<string | null>(null);
|
const [bindState, setBindState] = useState<string | null>(null);
|
||||||
@@ -81,7 +79,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
setStatus('success');
|
setStatus('success');
|
||||||
toast.success(t('account.bindSpaceSuccess'));
|
toast.success(t('account.bindSpaceSuccess'));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/home');
|
navigate('/home');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -96,7 +94,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, t],
|
[navigate, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,7 +144,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelBind = () => {
|
const handleCancelBind = () => {
|
||||||
router.push('/home');
|
navigate('/home');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,7 +152,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
|
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<img
|
<img
|
||||||
src={langbotIcon.src}
|
src={langbotIcon}
|
||||||
alt="LangBot"
|
alt="LangBot"
|
||||||
className="w-16 h-16 mb-4 mx-auto"
|
className="w-16 h-16 mb-4 mx-auto"
|
||||||
/>
|
/>
|
||||||
@@ -217,7 +215,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
<>
|
<>
|
||||||
<AlertCircle className="h-12 w-12 text-red-500" />
|
<AlertCircle className="h-12 w-12 text-red-500" />
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push(isBindMode ? '/home' : '/login')}
|
onClick={() => navigate(isBindMode ? '/home' : '/login')}
|
||||||
className="w-full mt-4"
|
className="w-full mt-4"
|
||||||
>
|
>
|
||||||
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}
|
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
:root {
|
:root {
|
||||||
/* 适用于 Firefox 的滚动条 */
|
/* 适用于 Firefox 的滚动条 */
|
||||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
||||||
@@ -72,9 +74,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@import 'tailwindcss';
|
|
||||||
|
|
||||||
@import 'tw-animate-css';
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -34,7 +32,7 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export default function BotDetailContent({ id }: { id: string }) {
|
export default function BotDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
||||||
|
|
||||||
@@ -105,12 +103,12 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleBotDeleted() {
|
function handleBotDeleted() {
|
||||||
refreshBots();
|
refreshBots();
|
||||||
router.push('/home/bots');
|
navigate('/home/bots');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewBotCreated(newBotId: string) {
|
function handleNewBotCreated(newBotId: string) {
|
||||||
refreshBots();
|
refreshBots();
|
||||||
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
@@ -176,9 +174,11 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
{activeTab === 'config' && (
|
||||||
{t('common.save')}
|
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
||||||
</Button>
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Horizontal Tabs */}
|
{/* Horizontal Tabs */}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
|||||||
import { Bot } from '@/app/infra/entities/api';
|
import { Bot } from '@/app/infra/entities/api';
|
||||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import RoutingRulesEditor from './RoutingRulesEditor';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -64,6 +65,28 @@ const getFormSchema = (t: (key: string) => string) =>
|
|||||||
adapter_config: z.record(z.string(), z.any()),
|
adapter_config: z.record(z.string(), z.any()),
|
||||||
enable: z.boolean().optional(),
|
enable: z.boolean().optional(),
|
||||||
use_pipeline_uuid: z.string().optional(),
|
use_pipeline_uuid: z.string().optional(),
|
||||||
|
pipeline_routing_rules: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
type: z.enum([
|
||||||
|
'launcher_type',
|
||||||
|
'launcher_id',
|
||||||
|
'message_content',
|
||||||
|
'message_has_element',
|
||||||
|
]),
|
||||||
|
operator: z.enum([
|
||||||
|
'eq',
|
||||||
|
'neq',
|
||||||
|
'contains',
|
||||||
|
'not_contains',
|
||||||
|
'starts_with',
|
||||||
|
'regex',
|
||||||
|
]),
|
||||||
|
value: z.string(),
|
||||||
|
pipeline_uuid: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function BotForm({
|
export default function BotForm({
|
||||||
@@ -89,6 +112,7 @@ export default function BotForm({
|
|||||||
adapter_config: {},
|
adapter_config: {},
|
||||||
enable: true,
|
enable: true,
|
||||||
use_pipeline_uuid: '',
|
use_pipeline_uuid: '',
|
||||||
|
pipeline_routing_rules: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,6 +179,7 @@ export default function BotForm({
|
|||||||
adapter_config: val.adapter_config,
|
adapter_config: val.adapter_config,
|
||||||
enable: val.enable,
|
enable: val.enable,
|
||||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
||||||
|
pipeline_routing_rules: val.pipeline_routing_rules || [],
|
||||||
});
|
});
|
||||||
handleAdapterSelect(val.adapter);
|
handleAdapterSelect(val.adapter);
|
||||||
|
|
||||||
@@ -270,6 +295,7 @@ export default function BotForm({
|
|||||||
adapter_config: bot.adapter_config,
|
adapter_config: bot.adapter_config,
|
||||||
enable: bot.enable ?? true,
|
enable: bot.enable ?? true,
|
||||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
||||||
|
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
|
||||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||||
| string
|
| string
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -314,6 +340,7 @@ export default function BotForm({
|
|||||||
adapter_config: form.getValues().adapter_config,
|
adapter_config: form.getValues().adapter_config,
|
||||||
enable: form.getValues().enable,
|
enable: form.getValues().enable,
|
||||||
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
||||||
|
pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
|
||||||
};
|
};
|
||||||
httpClient
|
httpClient
|
||||||
.updateBot(initBotId, updateBot)
|
.updateBot(initBotId, updateBot)
|
||||||
@@ -464,6 +491,12 @@ export default function BotForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Pipeline Routing Rules */}
|
||||||
|
<RoutingRulesEditor
|
||||||
|
form={form}
|
||||||
|
pipelineNameList={pipelineNameList}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { UseFormReturn } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
PipelineRoutingRule,
|
||||||
|
RoutingRuleOperator,
|
||||||
|
} from '@/app/infra/entities/api';
|
||||||
|
import { Ban, GripVertical, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { FormLabel } from '@/components/ui/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
KeyboardSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { useRef, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export const PIPELINE_DISCARD = '__discard__';
|
||||||
|
|
||||||
|
interface PipelineOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
emoji?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoutingRulesEditorProps {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
form: UseFormReturn<any>;
|
||||||
|
pipelineNameList: PipelineOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATORS_BY_TYPE: Record<
|
||||||
|
PipelineRoutingRule['type'],
|
||||||
|
{ value: RoutingRuleOperator; labelKey: string }[]
|
||||||
|
> = {
|
||||||
|
launcher_type: [
|
||||||
|
{ value: 'eq', labelKey: 'bots.operatorEq' },
|
||||||
|
{ value: 'neq', labelKey: 'bots.operatorNeq' },
|
||||||
|
],
|
||||||
|
launcher_id: [
|
||||||
|
{ value: 'eq', labelKey: 'bots.operatorEq' },
|
||||||
|
{ value: 'neq', labelKey: 'bots.operatorNeq' },
|
||||||
|
{ value: 'contains', labelKey: 'bots.operatorContains' },
|
||||||
|
{ value: 'not_contains', labelKey: 'bots.operatorNotContains' },
|
||||||
|
{ value: 'regex', labelKey: 'bots.operatorRegex' },
|
||||||
|
],
|
||||||
|
message_content: [
|
||||||
|
{ value: 'eq', labelKey: 'bots.operatorEq' },
|
||||||
|
{ value: 'neq', labelKey: 'bots.operatorNeq' },
|
||||||
|
{ value: 'contains', labelKey: 'bots.operatorContains' },
|
||||||
|
{ value: 'not_contains', labelKey: 'bots.operatorNotContains' },
|
||||||
|
{ value: 'starts_with', labelKey: 'bots.operatorStartsWith' },
|
||||||
|
{ value: 'regex', labelKey: 'bots.operatorRegex' },
|
||||||
|
],
|
||||||
|
message_has_element: [
|
||||||
|
{ value: 'eq', labelKey: 'bots.operatorHas' },
|
||||||
|
{ value: 'neq', labelKey: 'bots.operatorNotHas' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getValuePlaceholder(
|
||||||
|
t: (key: string) => string,
|
||||||
|
rule: PipelineRoutingRule,
|
||||||
|
): string {
|
||||||
|
if (rule.type === 'launcher_id')
|
||||||
|
return t('bots.ruleValueLauncherIdPlaceholder');
|
||||||
|
if (rule.type === 'message_has_element')
|
||||||
|
return t('bots.ruleValueElementPlaceholder');
|
||||||
|
if (rule.operator === 'regex') return t('bots.ruleValueRegexpPlaceholder');
|
||||||
|
return t('bots.ruleValueMessagePlaceholder');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Static rule row (used in DragOverlay) ─────────────────────────── */
|
||||||
|
|
||||||
|
interface RuleRowContentProps {
|
||||||
|
rule: PipelineRoutingRule;
|
||||||
|
index: number;
|
||||||
|
pipelineNameList: PipelineOption[];
|
||||||
|
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
|
||||||
|
removeRule: (index: number) => void;
|
||||||
|
dragHandleProps?: Record<string, unknown>;
|
||||||
|
isOverlay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuleRowContent({
|
||||||
|
rule,
|
||||||
|
index,
|
||||||
|
pipelineNameList,
|
||||||
|
updateRule,
|
||||||
|
removeRule,
|
||||||
|
dragHandleProps,
|
||||||
|
isOverlay,
|
||||||
|
}: RuleRowContentProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const operatorsForType =
|
||||||
|
OPERATORS_BY_TYPE[rule.type] || OPERATORS_BY_TYPE.message_content;
|
||||||
|
const isDiscard = rule.pipeline_uuid === PIPELINE_DISCARD;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 mt-2 p-3 border rounded-md bg-muted/30 ${
|
||||||
|
isOverlay ? 'shadow-lg ring-2 ring-primary/20 bg-background' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none"
|
||||||
|
{...dragHandleProps}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Field selector */}
|
||||||
|
<Select
|
||||||
|
value={rule.type}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
updateRule(index, {
|
||||||
|
type: val as PipelineRoutingRule['type'],
|
||||||
|
operator: 'eq',
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[130px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="launcher_type">
|
||||||
|
{t('bots.ruleTypeLauncherType')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="launcher_id">
|
||||||
|
{t('bots.ruleTypeLauncherId')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="message_content">
|
||||||
|
{t('bots.ruleTypeMessageContent')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="message_has_element">
|
||||||
|
{t('bots.ruleTypeMessageHasElement')}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Operator selector */}
|
||||||
|
<Select
|
||||||
|
value={rule.operator || 'eq'}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
updateRule(index, { operator: val as RoutingRuleOperator });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{operatorsForType.map((op) => (
|
||||||
|
<SelectItem key={op.value} value={op.value}>
|
||||||
|
{t(op.labelKey)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Value input */}
|
||||||
|
{rule.type === 'launcher_type' ? (
|
||||||
|
<Select
|
||||||
|
value={rule.value}
|
||||||
|
onValueChange={(val) => updateRule(index, { value: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue placeholder={t('bots.ruleValuePlaceholder')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="person">
|
||||||
|
{t('bots.sessionTypePerson')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="group">{t('bots.sessionTypeGroup')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : rule.type === 'message_has_element' ? (
|
||||||
|
<Select
|
||||||
|
value={rule.value}
|
||||||
|
onValueChange={(val) => updateRule(index, { value: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue placeholder={t('bots.ruleValueElementPlaceholder')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Image">{t('bots.elementImage')}</SelectItem>
|
||||||
|
<SelectItem value="Voice">{t('bots.elementVoice')}</SelectItem>
|
||||||
|
<SelectItem value="File">{t('bots.elementFile')}</SelectItem>
|
||||||
|
<SelectItem value="Forward">{t('bots.elementForward')}</SelectItem>
|
||||||
|
<SelectItem value="Face">{t('bots.elementFace')}</SelectItem>
|
||||||
|
<SelectItem value="At">{t('bots.elementAt')}</SelectItem>
|
||||||
|
<SelectItem value="AtAll">{t('bots.elementAtAll')}</SelectItem>
|
||||||
|
<SelectItem value="Quote">{t('bots.elementQuote')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
placeholder={getValuePlaceholder(t, rule)}
|
||||||
|
value={rule.value}
|
||||||
|
onChange={(e) => updateRule(index, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground shrink-0">→</span>
|
||||||
|
|
||||||
|
{/* Pipeline selector */}
|
||||||
|
<Select
|
||||||
|
value={rule.pipeline_uuid}
|
||||||
|
onValueChange={(val) => updateRule(index, { pipeline_uuid: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
{rule.pipeline_uuid ? (
|
||||||
|
isDiscard ? (
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<Ban className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{t('bots.pipelineDiscard')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const p = pipelineNameList.find(
|
||||||
|
(p) => p.value === rule.pipeline_uuid,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{p?.emoji && (
|
||||||
|
<span className="text-sm shrink-0">{p.emoji}</span>
|
||||||
|
)}
|
||||||
|
<span>{p?.label ?? rule.pipeline_uuid}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={t('bots.selectPipeline')} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={PIPELINE_DISCARD}>
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<Ban className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{t('bots.pipelineDiscard')}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectSeparator />
|
||||||
|
{pipelineNameList.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.emoji && (
|
||||||
|
<span className="text-sm shrink-0">{item.emoji}</span>
|
||||||
|
)}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => removeRule(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sortable rule row ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface SortableRuleRowProps {
|
||||||
|
id: string;
|
||||||
|
rule: PipelineRoutingRule;
|
||||||
|
index: number;
|
||||||
|
pipelineNameList: PipelineOption[];
|
||||||
|
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
|
||||||
|
removeRule: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableRuleRow({
|
||||||
|
id,
|
||||||
|
rule,
|
||||||
|
index,
|
||||||
|
pipelineNameList,
|
||||||
|
updateRule,
|
||||||
|
removeRule,
|
||||||
|
}: SortableRuleRowProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||||
|
useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
// No transition — items reorder visually during drag via transform;
|
||||||
|
// on drop the data updates and transform resets, so animating would
|
||||||
|
// cause a redundant "swap" flicker.
|
||||||
|
opacity: isDragging ? 0.3 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style}>
|
||||||
|
<RuleRowContent
|
||||||
|
rule={rule}
|
||||||
|
index={index}
|
||||||
|
pipelineNameList={pipelineNameList}
|
||||||
|
updateRule={updateRule}
|
||||||
|
removeRule={removeRule}
|
||||||
|
dragHandleProps={{ ...attributes, ...listeners }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main editor ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export default function RoutingRulesEditor({
|
||||||
|
form,
|
||||||
|
pipelineNameList,
|
||||||
|
}: RoutingRulesEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const rules: PipelineRoutingRule[] =
|
||||||
|
form.watch('pipeline_routing_rules') || [];
|
||||||
|
|
||||||
|
// Stable unique ids for sortable items.
|
||||||
|
// We keep a running counter so newly added rules always get fresh ids.
|
||||||
|
const nextId = useRef(0);
|
||||||
|
const idsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
const sortableIds = useMemo(() => {
|
||||||
|
// Grow the id list to match rules length (newly added items get new ids).
|
||||||
|
while (idsRef.current.length < rules.length) {
|
||||||
|
idsRef.current.push(`rule-${nextId.current++}`);
|
||||||
|
}
|
||||||
|
// Shrink if rules were removed from the end.
|
||||||
|
if (idsRef.current.length > rules.length) {
|
||||||
|
idsRef.current = idsRef.current.slice(0, rules.length);
|
||||||
|
}
|
||||||
|
return idsRef.current;
|
||||||
|
}, [rules.length]);
|
||||||
|
|
||||||
|
const updateRules = (newRules: PipelineRoutingRule[]) => {
|
||||||
|
form.setValue('pipeline_routing_rules', newRules, { shouldDirty: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRule = () => {
|
||||||
|
updateRules([
|
||||||
|
...rules,
|
||||||
|
{
|
||||||
|
type: 'launcher_type',
|
||||||
|
operator: 'eq',
|
||||||
|
value: '',
|
||||||
|
pipeline_uuid: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRule = (index: number, patch: Partial<PipelineRoutingRule>) => {
|
||||||
|
const updated = [...rules];
|
||||||
|
updated[index] = { ...updated[index], ...patch };
|
||||||
|
updateRules(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRule = (index: number) => {
|
||||||
|
const updated = [...rules];
|
||||||
|
updated.splice(index, 1);
|
||||||
|
// Also remove the corresponding sortable id so indices stay in sync.
|
||||||
|
idsRef.current.splice(index, 1);
|
||||||
|
updateRules(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
setActiveId(null);
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = sortableIds.indexOf(active.id as string);
|
||||||
|
const newIndex = sortableIds.indexOf(over.id as string);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
|
||||||
|
updateRules(arrayMove(rules, oldIndex, newIndex));
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeIndex = activeId ? sortableIds.indexOf(activeId) : -1;
|
||||||
|
const activeRule = activeIndex >= 0 ? rules[activeIndex] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<FormLabel>{t('bots.routingRules')}</FormLabel>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t('bots.routingRulesDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addRule}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('bots.addRoutingRule')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sortableIds}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{rules.map((rule, index) => (
|
||||||
|
<SortableRuleRow
|
||||||
|
key={sortableIds[index]}
|
||||||
|
id={sortableIds[index]}
|
||||||
|
rule={rule}
|
||||||
|
index={index}
|
||||||
|
pipelineNameList={pipelineNameList}
|
||||||
|
updateRule={updateRule}
|
||||||
|
removeRule={removeRule}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeRule ? (
|
||||||
|
<RuleRowContent
|
||||||
|
rule={activeRule}
|
||||||
|
index={activeIndex}
|
||||||
|
pipelineNameList={pipelineNameList}
|
||||||
|
updateRule={updateRule}
|
||||||
|
removeRule={removeRule}
|
||||||
|
isOverlay
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
|
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
|
||||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||||
@@ -15,7 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { ChevronDownIcon, ExternalLink } from 'lucide-react';
|
import { ChevronDownIcon, ExternalLink } from 'lucide-react';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export function BotLogListComponent({
|
export function BotLogListComponent({
|
||||||
botId,
|
botId,
|
||||||
@@ -32,7 +30,7 @@ export function BotLogListComponent({
|
|||||||
hideToolbar?: boolean;
|
hideToolbar?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const manager = useRef(new BotLogManager(botId)).current;
|
const manager = useRef(new BotLogManager(botId)).current;
|
||||||
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
|
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
|
||||||
const [autoFlush, setAutoFlush] = useState(true);
|
const [autoFlush, setAutoFlush] = useState(true);
|
||||||
@@ -231,7 +229,7 @@ export function BotLogListComponent({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
|
onClick={() => navigate(`/home/monitoring?botId=${botId}`)}
|
||||||
>
|
>
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
|
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -12,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Copy, Check } from 'lucide-react';
|
import { Ban, Bot, Copy, Check, Workflow, UserCheck, Send } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
Plain,
|
Plain,
|
||||||
@@ -21,6 +19,7 @@ import {
|
|||||||
Quote,
|
Quote,
|
||||||
Voice,
|
Voice,
|
||||||
} from '@/app/infra/entities/message';
|
} from '@/app/infra/entities/message';
|
||||||
|
import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/RoutingRulesEditor';
|
||||||
|
|
||||||
interface SessionInfo {
|
interface SessionInfo {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -78,6 +77,16 @@ const BotSessionMonitor = forwardRef<
|
|||||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
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 parseSessionType = (sessionId: string): string | null => {
|
||||||
const idx = sessionId.indexOf('_');
|
const idx = sessionId.indexOf('_');
|
||||||
if (idx === -1) return null;
|
if (idx === -1) return null;
|
||||||
@@ -110,6 +119,24 @@ const BotSessionMonitor = forwardRef<
|
|||||||
}
|
}
|
||||||
}, [botId]);
|
}, [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(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@@ -134,29 +161,131 @@ const BotSessionMonitor = forwardRef<
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Check takeover status for selected session
|
||||||
|
const checkTakeoverStatus = useCallback(
|
||||||
|
async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
const response =
|
||||||
|
await httpClient.getHumanTakeoverSessionDetail(sessionId);
|
||||||
|
const isActive =
|
||||||
|
response.found && response.session?.status === 'active';
|
||||||
|
setIsTakenOver(isActive);
|
||||||
|
} catch {
|
||||||
|
setIsTakenOver(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSessions();
|
loadSessions();
|
||||||
}, [loadSessions]);
|
loadTakeoverStatus();
|
||||||
|
}, [loadSessions, loadTakeoverStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSessionId) {
|
if (selectedSessionId) {
|
||||||
loadMessages(selectedSessionId);
|
loadMessages(selectedSessionId);
|
||||||
|
checkTakeoverStatus(selectedSessionId);
|
||||||
} else {
|
} else {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setIsTakenOver(false);
|
||||||
}
|
}
|
||||||
}, [selectedSessionId, loadMessages]);
|
}, [selectedSessionId, loadMessages, checkTakeoverStatus]);
|
||||||
|
|
||||||
|
// Auto-refresh messages when session is taken over (polling)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSessionId || !isTakenOver) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadMessages(selectedSessionId);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [selectedSessionId, isTakenOver, loadMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = messagesContainerRef.current;
|
if (messages.length === 0) return;
|
||||||
if (container) {
|
// Wait for DOM to render the new messages before scrolling
|
||||||
const viewport = container.querySelector(
|
requestAnimationFrame(() => {
|
||||||
'[data-radix-scroll-area-viewport]',
|
const container = messagesContainerRef.current;
|
||||||
);
|
if (container) {
|
||||||
const scrollTarget = viewport || container;
|
const viewport = container.querySelector(
|
||||||
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
'[data-radix-scroll-area-viewport]',
|
||||||
}
|
);
|
||||||
|
const scrollTarget = viewport || container;
|
||||||
|
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleTakeover = async () => {
|
||||||
|
if (!selectedSessionId || !selectedSession) return;
|
||||||
|
if (!confirm(t('bots.sessionMonitor.takeoverConfirm'))) return;
|
||||||
|
|
||||||
|
setTakeoverLoading(true);
|
||||||
|
try {
|
||||||
|
await httpClient.takeoverSession(selectedSessionId, {
|
||||||
|
bot_uuid: botId,
|
||||||
|
platform: selectedSession.platform ?? undefined,
|
||||||
|
user_id: selectedSession.user_id ?? undefined,
|
||||||
|
user_name: selectedSession.user_name ?? undefined,
|
||||||
|
});
|
||||||
|
setIsTakenOver(true);
|
||||||
|
setTakenOverSessions((prev) => new Set(prev).add(selectedSessionId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Takeover failed:', error);
|
||||||
|
alert(t('bots.sessionMonitor.takeoverFailed'));
|
||||||
|
} finally {
|
||||||
|
setTakeoverLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRelease = async () => {
|
||||||
|
if (!selectedSessionId) return;
|
||||||
|
if (!confirm(t('bots.sessionMonitor.releaseConfirm'))) return;
|
||||||
|
|
||||||
|
setTakeoverLoading(true);
|
||||||
|
try {
|
||||||
|
await httpClient.releaseSession(selectedSessionId);
|
||||||
|
setIsTakenOver(false);
|
||||||
|
setTakenOverSessions((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(selectedSessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Release failed:', error);
|
||||||
|
alert(t('bots.sessionMonitor.releaseFailed'));
|
||||||
|
} finally {
|
||||||
|
setTakeoverLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!selectedSessionId || !operatorMessage.trim()) return;
|
||||||
|
|
||||||
|
setSendingMessage(true);
|
||||||
|
try {
|
||||||
|
await httpClient.sendTakeoverMessage(
|
||||||
|
selectedSessionId,
|
||||||
|
operatorMessage.trim(),
|
||||||
|
);
|
||||||
|
setOperatorMessage('');
|
||||||
|
// Reload messages to show the sent one
|
||||||
|
await loadMessages(selectedSessionId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send message failed:', error);
|
||||||
|
alert(t('bots.sessionMonitor.sendFailed'));
|
||||||
|
} finally {
|
||||||
|
setSendingMessage(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessageKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content);
|
const parsed = JSON.parse(content);
|
||||||
@@ -170,11 +299,16 @@ const BotSessionMonitor = forwardRef<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isUserMessage = (msg: SessionMessage): boolean => {
|
const isUserMessage = (msg: SessionMessage): boolean => {
|
||||||
|
if (msg.role === 'operator') return false;
|
||||||
if (msg.role === 'assistant') return false;
|
if (msg.role === 'assistant') return false;
|
||||||
if (msg.role === 'user') return true;
|
if (msg.role === 'user') return true;
|
||||||
return !msg.runner_name;
|
return !msg.runner_name;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isOperatorMessage = (msg: SessionMessage): boolean => {
|
||||||
|
return msg.role === 'operator';
|
||||||
|
};
|
||||||
|
|
||||||
const renderMessageComponent = (
|
const renderMessageComponent = (
|
||||||
component: MessageChainComponent,
|
component: MessageChainComponent,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -240,7 +374,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
key={index}
|
key={index}
|
||||||
className="inline-flex items-center gap-1 text-muted-foreground text-xs"
|
className="inline-flex items-center gap-1 text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
🎙 [Voice]
|
[Voice]
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -274,7 +408,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
const file = component as MessageChainComponent & { name?: string };
|
const file = component as MessageChainComponent & { name?: string };
|
||||||
return (
|
return (
|
||||||
<span key={index} className="text-muted-foreground text-xs">
|
<span key={index} className="text-muted-foreground text-xs">
|
||||||
📎 {file.name || 'File'}
|
[{file.name || 'File'}]
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -334,6 +468,22 @@ const BotSessionMonitor = forwardRef<
|
|||||||
(s) => s.session_id === selectedSessionId,
|
(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 (
|
return (
|
||||||
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
||||||
{/* Left Panel: Session List */}
|
{/* Left Panel: Session List */}
|
||||||
@@ -352,6 +502,9 @@ const BotSessionMonitor = forwardRef<
|
|||||||
<div className="p-1.5">
|
<div className="p-1.5">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const isSelected = selectedSessionId === session.session_id;
|
const isSelected = selectedSessionId === session.session_id;
|
||||||
|
const sessionTakenOver = takenOverSessions.has(
|
||||||
|
session.session_id,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.session_id}
|
key={session.session_id}
|
||||||
@@ -388,12 +541,16 @@ const BotSessionMonitor = forwardRef<
|
|||||||
{abbreviateId(session.user_id)}
|
{abbreviateId(session.user_id)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{session.is_active && (
|
{sessionTakenOver && (
|
||||||
|
<span className="flex items-center gap-0.5 text-orange-600 dark:text-orange-400">
|
||||||
|
<UserCheck className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{session.is_active && !sessionTakenOver && (
|
||||||
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
|
<span className="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 className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{session.pipeline_name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -413,56 +570,92 @@ const BotSessionMonitor = forwardRef<
|
|||||||
<>
|
<>
|
||||||
{/* Chat Header */}
|
{/* Chat Header */}
|
||||||
<div className="px-4 py-2.5 border-b shrink-0">
|
<div className="px-4 py-2.5 border-b shrink-0">
|
||||||
<div className="min-w-0">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className="min-w-0">
|
||||||
{selectedSession?.user_name ||
|
<div className="text-sm font-medium truncate">
|
||||||
selectedSession?.user_id ||
|
{selectedSession?.user_name ||
|
||||||
selectedSessionId.slice(0, 20)}
|
selectedSession?.user_id ||
|
||||||
|
selectedSessionId.slice(0, 20)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||||
|
{parseSessionType(selectedSessionId) && (
|
||||||
|
<span>{parseSessionType(selectedSessionId)}</span>
|
||||||
|
)}
|
||||||
|
{selectedSession?.platform && (
|
||||||
|
<>
|
||||||
|
{parseSessionType(selectedSessionId) && <span>·</span>}
|
||||||
|
<span>{selectedSession.platform}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedSession?.user_id && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{selectedSession.user_id}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyUserId(selectedSession.user_id!)}
|
||||||
|
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={t('common.copy')}
|
||||||
|
>
|
||||||
|
{copiedUserId ? (
|
||||||
|
<Check className="w-3 h-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isTakenOver ? (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400">
|
||||||
|
<UserCheck className="w-3 h-3" />
|
||||||
|
{t('bots.sessionMonitor.takenOver', {
|
||||||
|
defaultValue: 'Taken Over',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
selectedSession?.is_active && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
{/* Takeover / Release button */}
|
||||||
{parseSessionType(selectedSessionId) && (
|
<div className="flex-shrink-0">
|
||||||
<span>{parseSessionType(selectedSessionId)}</span>
|
{isTakenOver ? (
|
||||||
)}
|
<button
|
||||||
{selectedSession?.platform && (
|
type="button"
|
||||||
<>
|
onClick={handleRelease}
|
||||||
{parseSessionType(selectedSessionId) && <span>·</span>}
|
disabled={takeoverLoading}
|
||||||
<span>{selectedSession.platform}</span>
|
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" />
|
||||||
{selectedSession?.user_id && (
|
{t('bots.sessionMonitor.releaseBtn', {
|
||||||
<>
|
defaultValue: 'Release',
|
||||||
<span>·</span>
|
})}
|
||||||
<span className="font-mono">
|
</button>
|
||||||
{selectedSession.user_id}
|
) : (
|
||||||
</span>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={handleTakeover}
|
||||||
onClick={() => copyUserId(selectedSession.user_id!)}
|
disabled={takeoverLoading}
|
||||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
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"
|
||||||
title={t('common.copy')}
|
>
|
||||||
>
|
<UserCheck className="w-3.5 h-3.5" />
|
||||||
{copiedUserId ? (
|
{t('bots.sessionMonitor.takeoverBtn', {
|
||||||
<Check className="w-3 h-3 text-green-600" />
|
defaultValue: 'Take Over',
|
||||||
) : (
|
})}
|
||||||
<Copy className="w-3 h-3" />
|
</button>
|
||||||
)}
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,6 +678,10 @@ const BotSessionMonitor = forwardRef<
|
|||||||
) : (
|
) : (
|
||||||
messages.map((msg) => {
|
messages.map((msg) => {
|
||||||
const isUser = isUserMessage(msg);
|
const isUser = isUserMessage(msg);
|
||||||
|
const isOperator = isOperatorMessage(msg);
|
||||||
|
const isDiscarded =
|
||||||
|
msg.status === 'discarded' ||
|
||||||
|
msg.pipeline_id === PIPELINE_DISCARD;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
@@ -498,34 +695,59 @@ const BotSessionMonitor = forwardRef<
|
|||||||
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
|
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
|
||||||
isUser
|
isUser
|
||||||
? 'bg-primary/10 rounded-br-sm'
|
? 'bg-primary/10 rounded-br-sm'
|
||||||
: 'bg-muted rounded-bl-sm',
|
: isOperator
|
||||||
|
? 'bg-orange-100/80 dark:bg-orange-900/30 rounded-bl-sm'
|
||||||
|
: 'bg-muted rounded-bl-sm',
|
||||||
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
||||||
|
isDiscarded && 'opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderMessageContent(msg)}
|
{renderMessageContent(msg)}
|
||||||
{/* Role label + timestamp */}
|
{/* Role label + pipeline + timestamp */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>
|
<span
|
||||||
{isUser
|
className={cn(
|
||||||
? t('bots.sessionMonitor.userMessage', {
|
isOperator &&
|
||||||
defaultValue: 'User',
|
'text-orange-600 dark:text-orange-400 font-medium',
|
||||||
})
|
)}
|
||||||
: t('bots.sessionMonitor.botMessage', {
|
>
|
||||||
defaultValue: 'Assistant',
|
{getMessageRoleLabel(msg)}
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{formatTime(msg.timestamp)}
|
{formatTime(msg.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
|
{isDiscarded ? (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-destructive">
|
||||||
|
<Ban className="w-3 h-3" />
|
||||||
|
{t('bots.sessionMonitor.discarded', {
|
||||||
|
defaultValue: 'Discarded',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
) : msg.pipeline_name &&
|
||||||
|
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' && (
|
{msg.status === 'error' && (
|
||||||
<span className="text-red-500">error</span>
|
<span className="text-red-500">error</span>
|
||||||
)}
|
)}
|
||||||
{msg.runner_name && (
|
{msg.runner_name && (
|
||||||
<span className="opacity-70">
|
<span className="inline-flex items-center gap-0.5 opacity-70">
|
||||||
|
<Bot className="w-3 h-3" />
|
||||||
{msg.runner_name}
|
{msg.runner_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -537,6 +759,33 @@ const BotSessionMonitor = forwardRef<
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
'use client';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import BotDetailContent from './BotDetailContent';
|
import BotDetailContent from './BotDetailContent';
|
||||||
|
|
||||||
export default function BotConfigPage() {
|
export default function BotConfigPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -67,9 +65,10 @@ export default function ApiIntegrationDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ApiIntegrationDialogProps) {
|
}: ApiIntegrationDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const pathname = usePathname();
|
const location = useLocation();
|
||||||
const searchParams = useSearchParams();
|
const pathname = location.pathname;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [activeTab, setActiveTab] = useState('apikeys');
|
const [activeTab, setActiveTab] = useState('apikeys');
|
||||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||||
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
||||||
@@ -94,7 +93,9 @@ export default function ApiIntegrationDialog({
|
|||||||
if (open) {
|
if (open) {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('action', 'showApiIntegrationSettings');
|
params.set('action', 'showApiIntegrationSettings');
|
||||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
navigate(`${pathname}?${params.toString()}`, {
|
||||||
|
preventScrollReset: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export default function ApiIntegrationDialog({
|
|||||||
const newUrl = params.toString()
|
const newUrl = params.toString()
|
||||||
? `${pathname}?${params.toString()}`
|
? `${pathname}?${params.toString()}`
|
||||||
: pathname;
|
: pathname;
|
||||||
router.replace(newUrl, { scroll: false });
|
navigate(newUrl, { preventScrollReset: true });
|
||||||
}
|
}
|
||||||
onOpenChange(newOpen);
|
onOpenChange(newOpen);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -249,6 +249,9 @@ export default function DynamicFormComponent({
|
|||||||
case 'bot-selector':
|
case 'bot-selector':
|
||||||
fieldSchema = z.string();
|
fieldSchema = z.string();
|
||||||
break;
|
break;
|
||||||
|
case 'tools-selector':
|
||||||
|
fieldSchema = z.array(z.string());
|
||||||
|
break;
|
||||||
case 'model-fallback-selector':
|
case 'model-fallback-selector':
|
||||||
fieldSchema = z.object({
|
fieldSchema = z.object({
|
||||||
primary: z.string(),
|
primary: z.string(),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
KnowledgeBase,
|
KnowledgeBase,
|
||||||
EmbeddingModel,
|
EmbeddingModel,
|
||||||
|
PluginTool,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -75,9 +76,14 @@ export default function DynamicFormItemComponent({
|
|||||||
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
const [bots, setBots] = useState<Bot[]>([]);
|
const [bots, setBots] = useState<Bot[]>([]);
|
||||||
|
const [tools, setTools] = useState<PluginTool[]>([]);
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
||||||
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
|
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
|
||||||
|
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
|
||||||
|
const [tempSelectedToolNames, setTempSelectedToolNames] = useState<string[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||||
|
|
||||||
@@ -209,6 +215,21 @@ export default function DynamicFormItemComponent({
|
|||||||
}
|
}
|
||||||
}, [config.type]);
|
}, [config.type]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.type === DynamicFormItemType.TOOLS_SELECTOR) {
|
||||||
|
httpClient
|
||||||
|
.getTools()
|
||||||
|
.then((resp) => {
|
||||||
|
setTools(resp.tools);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(
|
||||||
|
t('tools.getToolListError', 'Failed to get tools: ') + err.msg,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [config.type]);
|
||||||
|
|
||||||
switch (config.type) {
|
switch (config.type) {
|
||||||
case DynamicFormItemType.INT:
|
case DynamicFormItemType.INT:
|
||||||
case DynamicFormItemType.FLOAT:
|
case DynamicFormItemType.FLOAT:
|
||||||
@@ -1161,6 +1182,139 @@ export default function DynamicFormItemComponent({
|
|||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case DynamicFormItemType.TOOLS_SELECTOR:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{field.value && field.value.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{field.value.map((toolName: string) => {
|
||||||
|
const currentTool = tools.find(
|
||||||
|
(tool) => tool.name === toolName,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={toolName}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium">{toolName}</div>
|
||||||
|
{currentTool?.human_desc && (
|
||||||
|
<div className="text-sm text-muted-foreground truncate">
|
||||||
|
{currentTool.human_desc}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
const newValue = field.value.filter(
|
||||||
|
(name: string) => name !== toolName,
|
||||||
|
);
|
||||||
|
field.onChange(newValue);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('tools.noToolSelected', 'No tools selected')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTempSelectedToolNames(field.value || []);
|
||||||
|
setToolsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t('tools.addTool', 'Add Tool')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={toolsDialogOpen} onOpenChange={setToolsDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t('tools.selectTools', 'Select Tools')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
||||||
|
{tools.map((tool) => {
|
||||||
|
const isSelected = tempSelectedToolNames.includes(tool.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tool.name}
|
||||||
|
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setTempSelectedToolNames((prev) =>
|
||||||
|
prev.includes(tool.name)
|
||||||
|
? prev.filter((name) => name !== tool.name)
|
||||||
|
: [...prev, tool.name],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
aria-label={`Select ${tool.name}`}
|
||||||
|
/>
|
||||||
|
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{tool.name}</div>
|
||||||
|
{tool.human_desc && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{tool.human_desc}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tools.length === 0 && (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('tools.noToolsAvailable', 'No tools available')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setToolsDialogOpen(false)}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(tempSelectedToolNames);
|
||||||
|
setToolsDialogOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.PROMPT_EDITOR: {
|
case DynamicFormItemType.PROMPT_EDITOR: {
|
||||||
// Guard: field.value may be undefined when the form resets or
|
// Guard: field.value may be undefined when the form resets or
|
||||||
// initialValues haven't propagated yet. Fall back to a default
|
// initialValues haven't propagated yet. Fall back to a default
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
|
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
|
||||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||||
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
|
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
|
||||||
@@ -29,7 +27,7 @@ import {
|
|||||||
Github,
|
Github,
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from '@/components/providers/theme-provider';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -244,9 +242,10 @@ function NavItems({
|
|||||||
sectionOpenState: Record<string, boolean>;
|
sectionOpenState: Record<string, boolean>;
|
||||||
onSectionToggle: (id: string, open: boolean) => void;
|
onSectionToggle: (id: string, open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const pathname = usePathname();
|
const location = useLocation();
|
||||||
const searchParams = useSearchParams();
|
const pathname = location.pathname;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const sidebarData = useSidebarData();
|
const sidebarData = useSidebarData();
|
||||||
const { setPendingPluginInstallAction } = sidebarData;
|
const { setPendingPluginInstallAction } = sidebarData;
|
||||||
const { state: sidebarState, isMobile } = useSidebar();
|
const { state: sidebarState, isMobile } = useSidebar();
|
||||||
@@ -413,7 +412,7 @@ function NavItems({
|
|||||||
'bg-accent text-accent-foreground font-medium',
|
'bg-accent text-accent-foreground font-medium',
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(itemRoute);
|
navigate(itemRoute);
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -471,7 +470,7 @@ function NavItems({
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
router.push(itemRoute);
|
navigate(itemRoute);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.emoji ? (
|
{item.emoji ? (
|
||||||
@@ -623,7 +622,7 @@ function NavItems({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
router.push('/home/market');
|
navigate('/home/market');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -638,7 +637,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('local');
|
setPendingPluginInstallAction('local');
|
||||||
router.push('/home/plugins');
|
navigate('/home/plugins');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -652,7 +651,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('github');
|
setPendingPluginInstallAction('github');
|
||||||
router.push('/home/plugins');
|
navigate('/home/plugins');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -669,7 +668,7 @@ function NavItems({
|
|||||||
type="button"
|
type="button"
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`${routePrefix}?id=new`);
|
navigate(`${routePrefix}?id=new`);
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -731,7 +730,7 @@ function NavItems({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
router.push('/home/market');
|
navigate('/home/market');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Store className="size-4" />
|
<Store className="size-4" />
|
||||||
@@ -742,7 +741,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('local');
|
setPendingPluginInstallAction('local');
|
||||||
router.push('/home/plugins');
|
navigate('/home/plugins');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
@@ -752,7 +751,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('github');
|
setPendingPluginInstallAction('github');
|
||||||
router.push('/home/plugins');
|
navigate('/home/plugins');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Github className="size-4" />
|
<Github className="size-4" />
|
||||||
@@ -766,7 +765,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"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
router.push(`${routePrefix}?id=new`);
|
navigate(`${routePrefix}?id=new`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
@@ -1029,9 +1028,10 @@ export default function HomeSidebar({
|
|||||||
}: {
|
}: {
|
||||||
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
|
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const pathname = usePathname();
|
const location = useLocation();
|
||||||
const searchParams = useSearchParams();
|
const pathname = location.pathname;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1071,14 +1071,16 @@ export default function HomeSidebar({
|
|||||||
if (open) {
|
if (open) {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('action', 'showModelSettings');
|
params.set('action', 'showModelSettings');
|
||||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
navigate(`${pathname}?${params.toString()}`, {
|
||||||
|
preventScrollReset: true,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.delete('action');
|
params.delete('action');
|
||||||
const newUrl = params.toString()
|
const newUrl = params.toString()
|
||||||
? `${pathname}?${params.toString()}`
|
? `${pathname}?${params.toString()}`
|
||||||
: pathname;
|
: pathname;
|
||||||
router.replace(newUrl, { scroll: false });
|
navigate(newUrl, { preventScrollReset: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,14 +1089,16 @@ export default function HomeSidebar({
|
|||||||
if (open) {
|
if (open) {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set('action', 'showAccountSettings');
|
params.set('action', 'showAccountSettings');
|
||||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
navigate(`${pathname}?${params.toString()}`, {
|
||||||
|
preventScrollReset: true,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.delete('action');
|
params.delete('action');
|
||||||
const newUrl = params.toString()
|
const newUrl = params.toString()
|
||||||
? `${pathname}?${params.toString()}`
|
? `${pathname}?${params.toString()}`
|
||||||
: pathname;
|
: pathname;
|
||||||
router.replace(newUrl, { scroll: false });
|
navigate(newUrl, { preventScrollReset: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1165,7 +1169,7 @@ export default function HomeSidebar({
|
|||||||
// User click: update state AND navigate
|
// User click: update state AND navigate
|
||||||
function handleChildClick(child: SidebarChildVO) {
|
function handleChildClick(child: SidebarChildVO) {
|
||||||
selectChild(child);
|
selectChild(child);
|
||||||
router.push(child.route);
|
navigate(child.route);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initSelect() {
|
function initSelect() {
|
||||||
@@ -1226,7 +1230,7 @@ export default function HomeSidebar({
|
|||||||
tooltip="LangBot"
|
tooltip="LangBot"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={langbotIcon.src}
|
src={langbotIcon}
|
||||||
alt="LangBot"
|
alt="LangBot"
|
||||||
className="size-8 rounded-lg"
|
className="size-8 rounded-lg"
|
||||||
/>
|
/>
|
||||||
@@ -1406,7 +1410,7 @@ export default function HomeSidebar({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
router.push('/wizard');
|
navigate('/wizard');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Zap className="text-blue-500" />
|
<Zap className="text-blue-500" />
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, Boxes } from 'lucide-react';
|
import { Plus, Boxes } from 'lucide-react';
|
||||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react';
|
import { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Plus, X } from 'lucide-react';
|
import { Plus, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Trash2, Eye, Wrench, Check } from 'lucide-react';
|
import { Trash2, Eye, Wrench, Check } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -135,7 +133,7 @@ export default function ProviderCard({
|
|||||||
{isLangBotModels ? (
|
{isLangBotModels ? (
|
||||||
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
|
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src={langbotIcon.src}
|
src={langbotIcon}
|
||||||
alt="LangBot"
|
alt="LangBot"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +30,7 @@ import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
|
|||||||
|
|
||||||
export default function KBDetailContent({ id }: { id: string }) {
|
export default function KBDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
|
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
|
||||||
useSidebarData();
|
useSidebarData();
|
||||||
@@ -84,12 +82,12 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleKbDeleted() {
|
function handleKbDeleted() {
|
||||||
refreshKnowledgeBases();
|
refreshKnowledgeBases();
|
||||||
router.push('/home/knowledge');
|
navigate('/home/knowledge');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewKbCreated(newKbId: string) {
|
function handleNewKbCreated(newKbId: string) {
|
||||||
refreshKnowledgeBases();
|
refreshKnowledgeBases();
|
||||||
router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
|
navigate(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKbUpdated() {
|
function handleKbUpdated() {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { MoreHorizontal } from 'lucide-react';
|
import { MoreHorizontal } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import { Link } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -304,7 +304,7 @@ export default function KBForm({
|
|||||||
{t('knowledge.noEnginesAvailable')}
|
{t('knowledge.noEnginesAvailable')}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/home/market?category=KnowledgeEngine"
|
to="/home/market?category=KnowledgeEngine"
|
||||||
className="text-sm text-primary hover:underline"
|
className="text-sm text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{t('knowledge.installEngineHint')}
|
{t('knowledge.installEngineHint')}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
'use client';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
@@ -10,7 +8,7 @@ import KBDetailContent from './KBDetailContent';
|
|||||||
|
|
||||||
export default function KnowledgePage() {
|
export default function KnowledgePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
const { refreshKnowledgeBases } = useSidebarData();
|
const { refreshKnowledgeBases } = useSidebarData();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
||||||
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
|
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
|
||||||
import React, {
|
import React, {
|
||||||
@@ -21,8 +19,8 @@ import {
|
|||||||
initializeUserInfo,
|
initializeUserInfo,
|
||||||
initializeSystemInfo,
|
initializeSystemInfo,
|
||||||
} from '@/app/infra/http';
|
} from '@/app/infra/http';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import Link from 'next/link';
|
import { Link } from 'react-router-dom';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { CircleHelp } from 'lucide-react';
|
import { CircleHelp } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -59,7 +57,7 @@ export default function HomeLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Initialize user info if not already initialized
|
// Initialize user info if not already initialized
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,14 +73,14 @@ export default function HomeLayout({
|
|||||||
// Always re-fetch to ensure we have the latest wizard_status from backend
|
// Always re-fetch to ensure we have the latest wizard_status from backend
|
||||||
await initializeSystemInfo();
|
await initializeSystemInfo();
|
||||||
if (systemInfo.wizard_status === 'none') {
|
if (systemInfo.wizard_status === 'none') {
|
||||||
router.replace('/wizard');
|
navigate('/wizard');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If fetching system info fails, don't redirect
|
// If fetching system info fails, don't redirect
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkWizard();
|
checkWizard();
|
||||||
}, [router]);
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarDataProvider>
|
<SidebarDataProvider>
|
||||||
@@ -101,7 +99,8 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
zh_Hans: '',
|
zh_Hans: '',
|
||||||
});
|
});
|
||||||
const { detailEntityName } = useSidebarData();
|
const { detailEntityName } = useSidebarData();
|
||||||
const pathname = usePathname();
|
const location = useLocation();
|
||||||
|
const pathname = location.pathname;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
|
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
|
||||||
@@ -139,7 +138,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem className="hidden md:block">
|
<BreadcrumbItem className="hidden md:block">
|
||||||
<BreadcrumbLink asChild>
|
<BreadcrumbLink asChild>
|
||||||
<Link href={sectionLink}>{sectionLabel}</Link>
|
<Link to={sectionLink}>{sectionLabel}</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
|
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -30,7 +28,7 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export default function MCPDetailContent({ id }: { id: string }) {
|
export default function MCPDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshMCPServers, mcpServers, setDetailEntityName } =
|
const { refreshMCPServers, mcpServers, setDetailEntityName } =
|
||||||
useSidebarData();
|
useSidebarData();
|
||||||
@@ -96,12 +94,12 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleServerDeleted() {
|
function handleServerDeleted() {
|
||||||
refreshMCPServers();
|
refreshMCPServers();
|
||||||
router.push('/home/mcp');
|
navigate('/home/mcp');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewServerCreated(serverName: string) {
|
function handleNewServerCreated(serverName: string) {
|
||||||
refreshMCPServers();
|
refreshMCPServers();
|
||||||
router.push(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
'use client';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MCPDetailContent from './MCPDetailContent';
|
import MCPDetailContent from './MCPDetailContent';
|
||||||
|
|
||||||
export default function MCPPage() {
|
export default function MCPPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { MessageDetails } from '../types/monitoring';
|
import { MessageDetails } from '../types/monitoring';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MetricCard from './MetricCard';
|
import MetricCard from './MetricCard';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { FilterState, TimeRangeOption, DateRange } from '../types/monitoring';
|
import { FilterState, TimeRangeOption, DateRange } from '../types/monitoring';
|
||||||
import { getPresetDateRange } from '../utils/dateUtils';
|
import { getPresetDateRange } from '../utils/dateUtils';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import { getPresetDateRange } from '../utils/dateUtils';
|
|||||||
* Custom hook for managing monitoring filters
|
* Custom hook for managing monitoring filters
|
||||||
*/
|
*/
|
||||||
export function useMonitoringFilters() {
|
export function useMonitoringFilters() {
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
// Initialize filters from URL params
|
// Initialize filters from URL params
|
||||||
const [selectedBots, setSelectedBots] = useState<string[]>(() => {
|
const [selectedBots, setSelectedBots] = useState<string[]>(() => {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { Suspense, useState, useMemo } from 'react';
|
import React, { Suspense, useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
||||||
@@ -13,7 +11,7 @@ import { Settings, Bug, BarChart3 } from 'lucide-react';
|
|||||||
|
|
||||||
export default function PipelineDetailContent({ id }: { id: string }) {
|
export default function PipelineDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||||
|
|
||||||
@@ -38,7 +36,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleNewPipelineCreated(newPipelineId: string) {
|
function handleNewPipelineCreated(newPipelineId: string) {
|
||||||
refreshPipelines();
|
refreshPipelines();
|
||||||
router.push(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
|
navigate(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Create Mode ====================
|
// ==================== Create Mode ====================
|
||||||
@@ -73,7 +71,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleDeletePipeline() {
|
function handleDeletePipeline() {
|
||||||
refreshPipelines();
|
refreshPipelines();
|
||||||
router.push('/home/pipelines');
|
navigate('/home/pipelines');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
@@ -129,7 +127,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
|||||||
onFinish={handleFinish}
|
onFinish={handleFinish}
|
||||||
onNewPipelineCreated={handleNewPipelineCreated}
|
onNewPipelineCreated={handleNewPipelineCreated}
|
||||||
onDeletePipeline={handleDeletePipeline}
|
onDeletePipeline={handleDeletePipeline}
|
||||||
onCancel={() => router.push('/home/pipelines')}
|
onCancel={() => navigate('/home/pipelines')}
|
||||||
onDirtyChange={setFormDirty}
|
onDirtyChange={setFormDirty}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -152,7 +150,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
|||||||
<PipelineMonitoringTab
|
<PipelineMonitoringTab
|
||||||
pipelineId={id}
|
pipelineId={id}
|
||||||
onNavigateToMonitoring={() => {
|
onNavigateToMonitoring={() => {
|
||||||
router.push('/home/monitoring');
|
navigate('/home/monitoring');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { backendClient } from '@/app/infra/http';
|
import { backendClient } from '@/app/infra/http';
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
'use client';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import PipelineDetailContent from './PipelineDetailContent';
|
import PipelineDetailContent from './PipelineDetailContent';
|
||||||
|
|
||||||
export default function PipelineConfigPage() {
|
export default function PipelineConfigPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const detailId = searchParams.get('id');
|
const detailId = searchParams.get('id');
|
||||||
|
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
||||||
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
|
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
|
||||||
|
|||||||
-2
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
|
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
|
||||||
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
|
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
|
||||||
import styles from '@/app/home/plugins/plugins.module.css';
|
import styles from '@/app/home/plugins/plugins.module.css';
|
||||||
@@ -33,11 +31,10 @@ enum PluginOperationType {
|
|||||||
UPDATE = 'UPDATE',
|
UPDATE = 'UPDATE',
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react/display-name
|
|
||||||
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const { refreshPlugins } = useSidebarData();
|
const { refreshPlugins } = useSidebarData();
|
||||||
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
|
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
|
||||||
const [showOperationModal, setShowOperationModal] = useState(false);
|
const [showOperationModal, setShowOperationModal] = useState(false);
|
||||||
@@ -163,7 +160,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
|
|
||||||
function handlePluginClick(plugin: PluginCardVO) {
|
function handlePluginClick(plugin: PluginCardVO) {
|
||||||
const pluginId = `${plugin.author}/${plugin.name}`;
|
const pluginId = `${plugin.author}/${plugin.name}`;
|
||||||
router.push(`/home/plugins?id=${encodeURIComponent(pluginId)}`);
|
navigate(`/home/plugins?id=${encodeURIComponent(pluginId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePluginDelete(plugin: PluginCardVO) {
|
function handlePluginDelete(plugin: PluginCardVO) {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -47,7 +45,7 @@ function MarketPageContent({
|
|||||||
installPlugin: (plugin: PluginV4) => void;
|
installPlugin: (plugin: PluginV4) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const validCategories = [
|
const validCategories = [
|
||||||
'Tool',
|
'Tool',
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user